Post
PostgreSQL 무중단 스키마 변경 실전: Expand and Contract, Backfill, Concurrent Index로 운영 중 컬럼과 제약을 바꾸는 법
배경: 왜 스키마 변경은 로컬에서는 쉬운데 운영에서는 사고가 나는가
개발 환경에서는 컬럼 하나 추가하고, 타입 하나 바꾸고, 제약 하나 더 붙이는 일이 가볍게 느껴진다. ORM migration을 한 번 실행하면 끝나고, 데이터도 적고, 접속자도 거의 없으니 문제가 드러나지 않는다. 하지만 운영 DB에서는 같은 작업이 전혀 다른 성격을 띤다.
- 수천만 행 테이블에
ALTER TABLE을 걸었다가 예상보다 긴 락이 걸린다. - 애플리케이션은 아직 예전 컬럼을 읽는데 DB 스키마만 먼저 바뀌어 배포 직후 오류가 난다.
- 인덱스 생성이 길어지면서 쓰기 지연이 누적되고, 커넥션 풀이 밀리며 장애처럼 보인다.
NOT NULL이나UNIQUE를 한 번에 강제하다가 대량 검증 비용 때문에 배치와 API가 같이 흔들린다.- backfill을 한 번에 때렸다가 vacuum, replication lag, WAL 급증까지 연쇄적으로 터진다.
즉 운영에서의 스키마 변경은 단순히 DDL 문법 문제가 아니다. 락, 데이터량, 애플리케이션 버전 차이, 복제 지연, 롤백 전략, 배포 순서가 한꺼번에 얽힌다. 그래서 중급 이상 개발자에게 필요한 관점은 “마이그레이션 툴을 어떻게 쓰는가”보다 먼저 운영 중인 시스템에서 호환성을 유지한 채 변경을 단계적으로 흘려보내는 방법이다.
오늘 글은 PostgreSQL 기준으로 아래 질문에 답한다.
- 무중단 스키마 변경이 어려운 진짜 이유는 무엇인가
- 왜 대부분의 변경은
expand and contract패턴으로 풀어야 하는가 - 어떤 DDL은 가볍고, 어떤 DDL은 테이블 재작성과 긴 락을 유발하는가
- backfill은 왜 SQL 한 줄보다 운영 절차로 설계해야 하는가
CREATE INDEX CONCURRENTLY,NOT VALID,VALIDATE CONSTRAINT는 언제 써야 하는가- 컬럼 추가, 타입 변경, 제약 추가, 컬럼 폐기 작업을 어떤 순서로 배포해야 안전한가
- 흔한 실수와 사전 체크리스트는 무엇인가
먼저 핵심만 압축하면 이렇다.
- 운영 스키마 변경의 본질은 DDL 실행이 아니라 구버전 앱과 신버전 앱이 함께 살아도 깨지지 않게 만드는 호환성 설계다.
- 대부분의 위험한 변경은 한 번에 바꾸지 말고 expand, migrate, validate, contract의 여러 단계로 나눠야 한다.
- PostgreSQL에서는 같은
ALTER TABLE이라도 작업 종류에 따라 락 성격과 비용이 크게 다르므로, 메타데이터 변경인지, 검증이 필요한지, 테이블 재작성이 필요한지를 먼저 구분해야 한다. - 대량 backfill은 데이터 정합성 작업이면서 동시에 부하 제어 작업이다. 배치 크기, WAL, vacuum, replication lag를 같이 봐야 한다.
- 인덱스와 제약은 가능한 한 동시성 친화적 단계로 분리하고, 최종 강제는 충분히 관찰한 뒤 마지막에 한다.
- 무중단 변경의 성공 기준은 “DDL이 성공했다”가 아니라 배포 중 어느 순간에도 읽기와 쓰기가 일관되게 동작한다는 것이다.
먼저 큰 그림: 운영 스키마 변경이 위험한 이유는 세 가지가 동시에 바뀌기 때문이다
스키마 변경이 무서운 이유를 한 문장으로 줄이면 이렇다.
데이터 구조, 애플리케이션 코드, 현재 저장된 과거 데이터가 서로 다른 시간축에 놓여 있기 때문이다.
운영 시스템에는 항상 세 종류의 현실이 동시에 존재한다.
1) 이미 쌓여 있는 과거 데이터
DB 안에는 옛 규칙으로 저장된 데이터가 있다. 예를 들어 status가 문자열로 저장돼 있고, 일부 row는 대소문자 규칙도 들쭉날쭉할 수 있다. 새 스키마는 enum이나 코드 테이블 기반 정규화를 원하지만, 과거 데이터는 그대로 남아 있다.
2) 현재 배포 중인 여러 버전의 애플리케이션
롤링 배포, 카나리 배포, 워커 지연 재기동, 배치 프로세스 때문에 어떤 인스턴스는 새 코드를, 어떤 인스턴스는 예전 코드를 실행한다. 즉 짧은 순간이라도 “앱 버전은 하나”라는 가정이 틀린다.
3) 지금도 계속 들어오는 신규 쓰기
backfill 중에도 사용자 요청은 멈추지 않는다. 새 컬럼으로 데이터를 옮기는 동안 기존 컬럼에 계속 UPDATE가 들어오면, 단순 일괄 복사로는 정합성이 깨질 수 있다.
그래서 운영 스키마 변경은 다음 조건을 동시에 만족해야 한다.
- 구버전 앱이 있어도 읽기/쓰기가 깨지지 않을 것
- 신버전 앱이 있어도 아직 안 옮겨진 과거 데이터를 처리할 수 있을 것
- backfill 중 새로 들어오는 변경이 유실되지 않을 것
- 최종 강제 전까지 정합성 이상 여부를 관찰할 수 있을 것
- 중간 단계에서 멈춰도 서비스가 계속 동작할 것
이 기준을 만족시키는 가장 실용적인 패턴이 바로 expand and contract다.
핵심 개념 1: 무중단 스키마 변경의 기본은 Expand and Contract다
이 패턴은 이름은 단순하지만, 운영 변경의 거의 모든 사고를 예방하는 사고방식이다.
Expand
먼저 기존 구조를 깨지 않는 방향으로 새 구조를 추가한다.
- 새 컬럼 추가
- 새 인덱스 추가
- 새 제약을 아직 강제하지 않은 상태로 추가
- 앱이 구구조와 신구조를 모두 이해하도록 변경
핵심은 기존 코드를 깨지 않는 증분 변경이라는 점이다.
Migrate
기존 데이터를 새 구조로 옮긴다.
- backfill 배치 실행
- dual write 또는 read fallback 적용
- 모니터링으로 누락/충돌/지연 확인
핵심은 운영 중 데이터를 점진적으로 이동시키는 단계라는 점이다.
Validate
새 구조가 실제로 충분히 채워졌고, 제약을 걸어도 깨지지 않는지 검증한다.
- NULL 잔존 여부 확인
- 중복 여부 확인
- 참조 무결성 위반 여부 확인
- 앱 read path를 새 컬럼 기준으로 전환
핵심은 최종 강제 전에 사실관계를 확인하는 것이다.
Contract
마지막으로 더 이상 필요 없는 옛 구조를 제거한다.
- 구컬럼 읽기 제거
- dual write 제거
- 옛 인덱스/컬럼/제약 삭제
- 코드와 스키마를 새 규칙 하나로 수렴
핵심은 정말 안전하다고 확인된 뒤에만 제거한다는 점이다.
이 순서를 뒤집으면 거의 항상 사고가 난다. 특히 많이 하는 실수는 다음 두 가지다.
- DB에서 옛 컬럼을 먼저 지우고 앱을 나중에 바꾸는 것
- 신컬럼을 추가하자마자 곧바로
NOT NULL,UNIQUE, 타입 변경까지 한 번에 끝내려는 것
운영에서는 “깔끔하게 한 번에 끝난다”보다 “중간 상태가 길어져도 안전하다”가 훨씬 중요하다.
핵심 개념 2: PostgreSQL에서는 DDL마다 위험도가 다르다
운영에서 ALTER TABLE을 볼 때 가장 먼저 해야 할 질문은 SQL 문이 멋진가가 아니다.
이 작업은 메타데이터 변경인가, 검증 스캔이 필요한가, 테이블 재작성이 필요한가?
이 분류를 놓치면 변경 창구를 잡는 감각이 완전히 틀어진다.
상대적으로 가벼운 편인 작업
- 컬럼 이름 변경 자체
- nullable 컬럼 추가
- 일부 메타데이터 수준 기본값 추가(버전과 표현식 특성 확인 필요)
CREATE INDEX CONCURRENTLYADD CONSTRAINT ... NOT VALID후 나중에 검증
이 작업들도 락이 전혀 없는 것은 아니지만, 대개 긴 시간 전체 트래픽을 멈추게 하는 종류와는 다르다.
신중해야 하는 작업
- 대량 테이블에 대한 즉시
NOT NULL강제 - 기존 데이터 전체를 즉시 검증해야 하는 제약 추가
- 타입 변경 중 테이블 재작성이나 값 변환 비용이 큰 작업
- 기본값 표현식이 무겁거나 버전 특성상 rewrite 가능성이 있는 작업
- 대량
UPDATE한 방으로 끝내는 backfill
여기서 중요한 것은 “문법상 가능”과 “운영상 안전”이 다르다는 점이다. 예를 들어 PostgreSQL에서 타입 변경이 지원되더라도, 내부적으로 전체 테이블을 다시 써야 하거나 긴 검증이 필요하면 운영 시간대에는 위험하다.
실무적으로는 다음 질문을 먼저 체크한다.
- 이 DDL은 전체 row를 스캔하는가?
- 테이블 재작성 가능성이 있는가?
- 긴 트랜잭션 안에 묶이면 안 되는가?
- replication lag를 유발할 정도의 WAL을 만들 수 있는가?
- 롤백 시 되돌리는 비용이 더 큰가?
DDL의 문법을 외우는 것보다, 이 위험도 분류를 몸에 익히는 편이 훨씬 중요하다.
핵심 개념 3: 스키마 변경은 DB 변경이 아니라 애플리케이션 호환성 변경이다
운영 사고는 종종 DB가 아니라 앱이 만든다. 예를 들어 컬럼을 full_name에서 display_name으로 바꾸고 싶다고 해보자.
개발자는 이렇게 생각하기 쉽다.
- DB 컬럼 rename
- 코드에서 새 이름 사용
- 끝
하지만 실제 배포는 이렇지 않다.
- 아직 옛 바이너리를 가진 앱 인스턴스는
full_name을 읽으려 한다. - 배치 스크립트는 며칠 뒤에야 재배포될 수도 있다.
- 통계성 쿼리나 ad hoc SQL은 여전히 옛 컬럼명을 참조할 수 있다.
즉 rename 자체는 DB 관점에서 가벼울 수 있어도, 애플리케이션 생태계 전체의 호환성 문제는 전혀 가볍지 않다.
그래서 실무에서는 물리 rename보다 아래 패턴을 더 자주 쓴다.
- 새 컬럼 추가
- 앱에서 새 컬럼 우선 읽기 + 없으면 옛 컬럼 fallback
- 일정 기간 dual write
- backfill 및 검증
- 최종 전환 후 구컬럼 제거
처음 보면 번거로워 보이지만, 실제로는 이 방식이 가장 롤백 친화적이다. 중간에 문제가 생기면 앱 read path만 되돌려도 서비스가 살아남기 때문이다.
실무 예시 1: 문자열 상태 컬럼을 정규화된 새 컬럼으로 바꾸는 가장 안전한 순서
가장 흔한 시나리오 하나를 보자.
기존 테이블:
CREATE TABLE orders (
id bigint primary key,
status text,
updated_at timestamptz not null default now()
);
문제:
status값이paid,PAID,payment_done처럼 불규칙하다.- 서비스가 커지면서 상태 기반 집계와 제약이 필요해졌다.
- 앞으로는 정규화된
status_code컬럼을 쓰고 싶다.
이걸 운영 중에 안전하게 바꾸려면 보통 아래 순서가 좋다.
1단계, 새 컬럼 추가
ALTER TABLE orders ADD COLUMN status_code text;
이 시점에서는 절대로 곧바로 NOT NULL을 붙이지 않는다. 아직 과거 데이터가 비어 있고, 앱도 모두 새 컬럼을 쓰지 않기 때문이다.
2단계, 앱에 dual write 도입
새로 쓰이는 데이터부터는 두 컬럼을 함께 기록한다.
normalized = normalize_status(input_status)
update orders
set status = :legacy_status,
status_code = :normalized_status,
updated_at = now()
where id = :id
실무 포인트는 여기서 쓰기 경로를 먼저 바꾼 뒤 backfill을 시작하는 것이다. 그래야 backfill 중 새로 들어온 데이터가 다시 빈 상태로 남지 않는다.
3단계, 읽기 경로에 fallback 추가
COALESCE(status_code, legacy_status_to_code(status))
또는 애플리케이션에서:
status_code가 있으면 사용- 없으면 옛
status를 normalize해서 사용
이렇게 해야 backfill 완료 전에도 읽기가 안전하다.
4단계, backfill을 청크 단위로 실행
나쁜 예:
UPDATE orders
SET status_code = CASE ...
WHERE status_code IS NULL;
이 한 줄은 데이터량이 크면 너무 많은 row를 오래 잡고, WAL을 폭증시키고, autovacuum과 복제 지연을 악화시킨다.
더 안전한 방식은 배치 크기를 제한하는 것이다.
WITH cte AS (
SELECT id
FROM orders
WHERE status_code IS NULL
ORDER BY id
LIMIT 1000
)
UPDATE orders o
SET status_code = CASE
WHEN o.status IN ('paid', 'PAID', 'payment_done') THEN 'PAID'
WHEN o.status IN ('cancelled', 'canceled') THEN 'CANCELLED'
ELSE 'PENDING'
END
FROM cte
WHERE o.id = cte.id;
이 작업을 루프 형태로 반복하면서, 각 배치 사이에 짧은 휴지와 관측 구간을 넣는다.
5단계, backfill 검증
SELECT count(*)
FROM orders
WHERE status_code IS NULL;
그리고 의미상 대응도 확인한다.
SELECT status, status_code, count(*)
FROM orders
GROUP BY 1, 2
ORDER BY count(*) DESC;
6단계, 새 read path로 완전 전환
앱이 더 이상 옛 status에 의존하지 않게 만든다.
7단계, 제약과 인덱스 추가
예를 들어 status_code 조회가 많다면:
CREATE INDEX CONCURRENTLY idx_orders_status_code ON orders(status_code);
제약은 충분한 검증 후 붙인다.
8단계, 옛 컬럼 제거
정말 더 이상 사용하지 않는 것이 확인된 뒤에만 contract를 수행한다.
이 예시의 핵심은 SQL 한 줄이 아니라 쓰기 경로 변경, 읽기 fallback, 점진적 backfill, 최종 강제가 하나의 절차라는 점이다.
실무 예시 2: int에서 bigint로 키를 확장할 때 한 번에 타입 변경하면 왜 위험한가
트래픽이 커지면 ID 범위 확장이 필요할 때가 있다. 예를 들어 기존 int PK가 한계에 가까워지고, 장기적으로 bigint 전환이 필요할 수 있다. 이때 단순하게 이렇게 하고 싶어진다.
ALTER TABLE payments ALTER COLUMN id TYPE bigint;
문법은 단순하지만 운영에서는 매우 조심해야 한다. 이유는 다음과 같다.
- PK 컬럼 변경은 관련 인덱스와 FK, 앱 직렬화 로직까지 연쇄 영향을 준다.
- 자식 테이블 FK가 많으면 영향 범위가 예상보다 넓다.
- 내부 변환과 검증 비용 때문에 긴 락이나 높은 부하가 생길 수 있다.
- 앱이 아직
int로 가정하고 있으면 API, 캐시 키, JSON contract까지 깨질 수 있다.
실무적으로는 그림자를 하나 더 만드는 방식이 훨씬 안전하다.
1단계, 새 bigint 컬럼 추가
ALTER TABLE payments ADD COLUMN id_v2 bigint;
2단계, 신규 쓰기는 id_v2도 함께 채움
시퀀스 혹은 앱 생성 로직을 조정해 두 컬럼을 같이 관리한다.
3단계, 자식 테이블도 그림자 FK 컬럼 추가
예를 들어 payment_events.payment_id_v2를 추가한다.
4단계, parent와 child를 순서대로 backfill
이때 가장 중요한 건 참조 정합성이다. parent를 채우고, child가 그 값을 정확히 복사하게 해야 한다.
5단계, 새 FK용 인덱스를 CONCURRENTLY 생성
6단계, 앱 read/write를 새 키 체계로 전환
7단계, 검증 후 PK/FK 계약을 새 컬럼으로 교체
이 과정은 길지만, 한 번에 ALTER TYPE을 때리는 것보다 훨씬 예측 가능하다. 특히 여러 서비스가 같은 키를 참조하는 환경에서는 이 방식이 사실상 유일하게 운영 가능한 경우가 많다.
핵심 교훈은 이것이다.
타입 변경은 종종 “컬럼 정의 수정”이 아니라 “새 스키마로 이주하는 프로젝트”다.
실무 예시 3: 대형 테이블에 새 제약을 추가할 때는 인덱스와 검증을 분리하라
예를 들어 users 테이블에서 (tenant_id, email) 조합이 논리적으로 유일해야 하는데, 그동안 코드 레벨에서만 보장하고 있었다고 해보자. 이제 DB 제약으로 끌어올리고 싶다.
많은 팀이 바로 ALTER TABLE ... ADD CONSTRAINT UNIQUE ...를 떠올린다. 하지만 대형 테이블이라면 먼저 현실을 확인해야 한다.
- 이미 중복 데이터가 남아 있지는 않은가
- 인덱스 생성이 오래 걸리지는 않는가
- 트래픽 피크 시간에 즉시 강제해도 되는가
- soft delete 행까지 포함할지, active row만 제한할지
실무 절차는 보통 이렇다.
1단계, 중복 데이터 사전 탐지
SELECT tenant_id, email, count(*)
FROM users
WHERE deleted_at IS NULL
GROUP BY tenant_id, email
HAVING count(*) > 1;
중복이 조금이라도 있으면 먼저 데이터 정리를 해야 한다. 제약은 데이터를 고쳐주지 않는다.
2단계, 실제 비즈니스 규칙에 맞는 인덱스 설계
soft delete를 사용한다면 전체 unique가 아니라 partial unique가 맞을 수 있다.
CREATE UNIQUE INDEX CONCURRENTLY ux_users_tenant_email_active
ON users (tenant_id, email)
WHERE deleted_at IS NULL;
3단계, 충분한 관찰 후 제약으로 승격 여부 결정
일부 경우에는 “유니크 인덱스가 이미 규칙을 강제하므로 굳이 별도 제약 명칭이 꼭 필요한가”를 판단할 수도 있다. 운영 도구나 ORM 표준화 때문에 제약명이 필요하면 기존 인덱스를 활용해 붙일 수 있다.
4단계, 앱 에러 처리 정리
제약을 DB로 올리는 순간, 애플리케이션은 중복 삽입 시 DB 에러를 정상적인 business conflict로 처리할 준비가 돼 있어야 한다. 그렇지 않으면 갑자기 500이 늘어난다.
이 시나리오의 교훈은 명확하다.
- 제약 추가는 스키마 미화가 아니다.
- 먼저 데이터 품질을 현실적으로 확인해야 한다.
- 인덱스 생성과 제약 강제를 한 동작으로 보지 말고 분리해서 다뤄야 한다.
핵심 개념 4: Backfill은 쿼리 작성 문제가 아니라 부하 제어 문제다
운영에서 가장 많이 과소평가되는 부분이 backfill이다. DDL보다 오히려 backfill이 더 위험한 경우도 많다. 이유는 대량 UPDATE가 다음을 동시에 건드리기 때문이다.
- row lock 증가
- WAL 증가
- autovacuum 부담 증가
- 인덱스 업데이트 비용 증가
- replication lag 증가
- cache churn 및 I/O 경쟁
그래서 backfill 설계에서 중요한 질문은 “어떻게 빨리 끝낼까?”가 아니라 아래다.
- 배치 하나가 몇 row를 건드릴까
- PK 순서로 갈까, 시간 범위로 나눌까
- hot row 영역을 피할 수 있을까
- replica lag가 늘면 자동으로 배치 크기를 줄일까
- 배치 실패 시 같은 범위를 재실행해도 안전한가
- dual write 중복 반영과 충돌하지 않는가
좋은 backfill의 특징
- 청크 크기가 고정 또는 동적으로 제한된다.
- 각 청크는 짧은 트랜잭션으로 끝난다.
- 이미 처리된 row는 다시 건드려도 부작용이 적다.
- 진행률을 SQL로 측정할 수 있다.
- 운영자가 중단하고 재개하기 쉽다.
예를 들어 아래 조건이 좋다.
WHERE new_col IS NULLORDER BY idLIMIT 500또는LIMIT 1000- 각 배치 커밋 후 짧은 관찰
더 나은 운영 패턴
- 트래픽이 낮은 시간대에만 실행
- WAL/replica lag 임계치 초과 시 자동 감속
- worker 여러 개를 돌리더라도 범위를 명시적으로 분리
- hot partition은 별도 창구로 처리
대용량 환경에서는 backfill worker를 애플리케이션 배치 코드로 만드는 편이 낫다. 이유는 속도 제어, 재시도, metrics, 관찰성이 훨씬 좋기 때문이다.
핵심 개념 5: CREATE INDEX CONCURRENTLY는 필수 도구지만 만능은 아니다
PostgreSQL에서 온라인에 가까운 인덱스 추가를 이야기할 때 CREATE INDEX CONCURRENTLY는 거의 기본 도구다. 이유는 일반 인덱스 생성보다 쓰기 차단을 크게 줄일 수 있기 때문이다.
CREATE INDEX CONCURRENTLY idx_orders_created_at_status
ON orders (created_at, status_code);
하지만 실무에서는 이 도구도 오해가 많다.
장점
- 대형 테이블에 인덱스를 추가할 때 일반 생성보다 운영 친화적이다.
- 읽기/쓰기 트래픽이 살아 있는 상태에서 적용 가능성이 높다.
- expand 단계에서 미리 인덱스를 준비하기 좋다.
주의점
- 트랜잭션 블록 안에서 실행할 수 없다.
- 실패하면 invalid index가 남을 수 있어 후속 정리가 필요하다.
- 생성 시간이 짧다는 뜻은 아니다. 대형 테이블에서는 오래 걸릴 수 있다.
- 생성 중 시스템 부하는 여전히 발생한다.
- 동시에 여러 개를 무분별하게 만들면 오히려 I/O 경쟁이 심해진다.
즉 이것은 “온라인 인덱스 생성”이 아니라 “차단 성격을 줄인 인덱스 생성” 정도로 이해하는 편이 정확하다.
실무 팁은 다음과 같다.
- 피크 시간대에 여러 개 동시 실행하지 않는다.
- 대상 컬럼 분포와 쿼리 패턴이 이미 검증된 뒤 만든다.
- 실패 시 invalid index 탐지 쿼리를 미리 준비한다.
- 마이그레이션 툴이 자동 트랜잭션을 감싸는지 확인한다.
핵심 개념 6: 제약 추가는 “즉시 강제”와 “나중 검증”을 분리할 수 있다
운영 중에는 제약도 한 번에 세게 걸기보다 단계를 나누는 것이 안전하다. PostgreSQL은 일부 제약에 대해 NOT VALID와 VALIDATE CONSTRAINT라는 매우 유용한 패턴을 제공한다.
예를 들어 외래 키를 추가한다고 하자.
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_order_id
FOREIGN KEY (order_id)
REFERENCES orders(id)
NOT VALID;
이 상태에서는 기존 데이터 전체를 즉시 검증하지 않는다. 대신 새로 들어오는 데이터에는 제약을 적용하면서, 과거 데이터는 나중에 별도 검증할 수 있다.
ALTER TABLE order_items
VALIDATE CONSTRAINT fk_order_items_order_id;
이 패턴이 좋은 이유는 다음과 같다.
- 스키마 확장 시점의 충격을 줄인다.
- 기존 데이터 품질을 먼저 점검할 시간을 준다.
- 서비스 트래픽이 적은 시간대에 검증만 따로 수행할 수 있다.
CHECK 제약도 같은 사고방식으로 접근할 수 있다. 예를 들어 새 컬럼이 eventually non-null이어야 한다면, 바로 SET NOT NULL로 들어가기보다 먼저 의미상 동등한 CHECK (new_col IS NOT NULL) NOT VALID를 도입하고, backfill 후 validate하는 방식이 훨씬 운영 친화적일 수 있다.
핵심은 이것이다.
제약은 선언 자체보다 검증 타이밍이 더 중요하다.
핵심 개념 7: 무중단 변경의 성패는 DDL 문법보다 락 대기 제어에서 갈린다
운영에서 더 무서운 것은 “DDL이 오래 실행되는 것”만이 아니다. 실제로는 DDL이 시작도 못 하고 어떤 세션을 기다리다가, 그 대기가 다시 다른 쿼리들을 줄줄이 막는 상황이 더 자주 사고로 이어진다.
예를 들어 짧게 끝날 것 같은 ALTER TABLE ADD COLUMN도, 이미 그 테이블에 오래 붙어 있는 트랜잭션이나 메타데이터 변경과 충돌하는 세션이 있으면 대기열이 형성될 수 있다. 그리고 그 대기열 뒤에서 평범한 읽기/쓰기까지 함께 막히면 체감상 갑자기 서비스가 멈춘 것처럼 보인다.
그래서 무중단 변경에서는 SQL 본문만큼 아래 설정과 절차가 중요하다.
1) lock_timeout을 보수적으로 둔다
DDL이 특정 시간 이상 락을 못 잡으면 차라리 빨리 실패하게 만드는 편이 낫다.
SET lock_timeout = '3s';
SET statement_timeout = '30min';
이런 식으로 두면,
- 락을 바로 못 잡는 위험한 시점에는 빠르게 포기하고
- 실제 작업이 시작된 뒤에는 충분한 실행 시간을 허용할 수 있다
실무에서는 이 두 값을 분리해서 생각해야 한다. statement_timeout만 길게 두면 “오래 기다리다 겨우 시작해서 결국 피크 시간 전체를 흔드는” 상황이 생긴다.
2) 장기 트랜잭션과 idle in transaction 세션을 먼저 점검한다
DDL 직전에는 최소한 아래 정도는 확인하는 편이 좋다.
SELECT
pid,
usename,
application_name,
state,
now() - xact_start AS xact_age,
wait_event_type,
wait_event,
query
FROM pg_stat_activity
WHERE datname = current_database()
AND xact_start IS NOT NULL
ORDER BY xact_start ASC;
특히 경계해야 하는 것은 아래다.
- 배치가 길게 잡고 있는 트랜잭션
- 운영자 세션이
BEGIN후 방치된 경우 - ORM 세션이
idle in transaction상태로 오래 남아 있는 경우 - 대형 보고서 쿼리가 replica가 아니라 primary에서 도는 경우
DDL 자체보다 이런 세션이 더 큰 장애 원인인 경우가 많다.
3) 마이그레이션 툴의 자동 트랜잭션 정책을 이해한다
많은 프레임워크는 migration 전체를 하나의 트랜잭션으로 감싸는 것을 기본값으로 둔다. 일반적인 schema change에는 좋을 수 있지만, PostgreSQL의 일부 작업에서는 오히려 위험하거나 아예 허용되지 않는다.
대표적으로 CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없다. 그런데 팀이 이 사실을 놓치고 배포 시점에 처음 실패를 보면, 단순 SQL 문제가 아니라 배포 파이프라인 문제로 번진다.
즉 실제 운영 전에는 아래를 꼭 확인해야 한다.
- 이 migration은 자동 트랜잭션을 꺼야 하는가
- 한 파일에 여러 DDL을 묶지 말아야 하는가
- 실패 시 재실행이 안전한가
- 이미 적용된 단계와 미적용 단계를 어떻게 식별할 것인가
4) “짧은 락이라 괜찮다”는 말을 관찰 없이 믿지 않는다
PostgreSQL 문서나 여러 글에서 특정 변경이 메타데이터 수준이라 짧은 락만 필요하다고 설명하는 경우가 많다. 큰 방향은 맞다. 하지만 실무에서는 다음 변수가 결과를 바꾼다.
- 해당 테이블의 실시간 트래픽 강도
- 동시에 붙어 있는 장기 세션 존재 여부
- 관련 DDL/DDL-like 작업의 동시 실행 여부
- replication 환경과 운영 버전 차이
- migration 툴이 실제로 추가한 wrapper SQL
그래서 중요한 변경일수록 staging이나 복제된 샘플 데이터 환경에서 “문법 검증”이 아니라 실제 적용 시간과 대기 패턴을 봐야 한다.
핵심은 이것이다.
무중단 변경은 락이 전혀 없는 변경이 아니라, 락을 잡지 못할 때 빨리 포기하고 다시 시도할 수 있게 만드는 운영 기술이다.
핵심 개념 8: 어떤 변경은 컬럼 이주가 아니라 Shadow Table Cutover가 더 현실적이다
모든 변경을 컬럼 추가와 backfill로 해결할 수 있는 것은 아니다. 아래 같은 경우에는 아예 새 테이블 구조를 만든 뒤 cutover하는 편이 더 현실적일 수 있다.
- 파티셔닝 구조 자체를 바꾸고 싶을 때
- PK 설계나 clustering 방향이 크게 달라질 때
- 너무 많은 컬럼과 인덱스가 얽혀 있어 부분 이주가 더 위험할 때
- 대규모 타입 재해석이나 row format 재설계가 필요할 때
- 온라인 변경으로는 장기간 이중화 코드 비용이 과도할 때
이 경우 절차는 대체로 아래와 같다.
- 새 스키마를 가진 shadow table 생성
- 필요한 인덱스와 제약 준비
- 기존 데이터 bulk copy 또는 점진 copy
- 일정 기간 dual write
- 검증 쿼리로 old/new row count 및 checksum 비교
- 읽기 경로 전환 또는 rename/cutover
- 안정화 후 구테이블 정리
이 전략의 장점은 새 구조를 더 자유롭게 설계할 수 있다는 것이다. 예를 들어 파티셔닝 키를 바꾸거나, 지나치게 비대해진 hot table을 재구성하거나, 오래된 nullable 스키마를 깔끔하게 정리할 때 유리하다.
반면 단점도 분명하다.
- 저장공간이 일시적으로 많이 든다.
- dual write 범위가 커질 수 있다.
- copy와 검증 절차가 더 길어진다.
- cutover 순간의 운영 절차가 더 정교해야 한다.
그래도 어떤 변경은 이 방법이 오히려 단순하다. 특히 “기존 테이블을 조금씩 고쳐서 목적지에 도달하기 너무 어려운 경우”에는 shadow table이 더 예측 가능하다.
개인적으로는 아래 질문에 두 번 이상 “예”가 나오면 shadow table을 검토한다.
- 변경 대상 컬럼이 너무 많아 old/new 이중화가 코드 전체로 번지는가
- 타입 변화가 단순 캐스팅이 아니라 의미 변화까지 포함하는가
- 기존 인덱스와 제약을 절반 이상 손봐야 하는가
- 데이터 이동 자체보다 최종 구조 정리가 더 중요한가
운영 관측 포인트: migration 중 무엇을 봐야 사고를 빨리 감지할 수 있는가
무중단 스키마 변경에서 관측은 선택이 아니라 필수다. DDL이나 backfill을 실행한 뒤 대시보드를 안 보고 있으면, 문제를 “사용자 문의”로 처음 알게 된다. 그건 이미 늦다.
내가 기본적으로 보는 항목은 아래다.
DB 내부 지표
- active session 수
- lock wait 수와 대기 시간
- long transaction 수
- dead tuple 증가 추세
- checkpoint 및 WAL 생성량
- replica lag
- 대상 테이블의 sequential scan / index scan 변화
애플리케이션 지표
- p95, p99 응답 시간
- DB timeout, deadlock, constraint violation 에러 수
- write endpoint 실패율
- 배치 큐 적체 여부
migration 전용 지표
- backfill 처리 속도(rows/sec)
- 미처리 row 수
- old/new 불일치 row 수
- dual write 실패 건수
- invalid index 생성 여부
특히 좋은 습관은 진행률을 SQL 한 줄로 말할 수 있게 만드는 것이다. 예를 들어 아래처럼 현재 상태를 바로 볼 수 있어야 한다.
SELECT
count(*) FILTER (WHERE new_col IS NULL) AS remaining,
count(*) FILTER (WHERE new_col IS NOT NULL) AS migrated
FROM target_table;
혹은 old/new 값이 의미상 같아야 한다면 샘플링 쿼리도 준비해야 한다.
SELECT id, old_col, new_col
FROM target_table
WHERE normalize_old(old_col) <> new_col
LIMIT 100;
운영에서 무서운 것은 느린 migration보다 상태를 설명할 수 없는 migration이다. 지금 어디까지 왔고, 무엇이 위험하고, 멈추면 어떤 상태인지가 보이면 대응이 쉬워진다.
배포 순서 실전: 컬럼 추가부터 폐기까지 추천 시퀀스
아래는 가장 많이 재사용되는 무중단 변경 시퀀스다. 컬럼 추가, 데이터 이동, 최종 강제, 구컬럼 제거를 한 흐름으로 정리하면 다음과 같다.
단계 0, 사전 분석
- 대상 테이블 row 수, 인덱스 크기, 쓰기 TPS 확인
- 관련 배치, API, 워커, ad hoc 쿼리 목록화
- replica lag, WAL, lock wait 관찰 지표 준비
- 롤백 경로 정의
단계 1, expand 스키마 적용
- 새 nullable 컬럼 추가
- 필요한 새 인덱스를 가능하면
CONCURRENTLY로 준비 - 필요한 제약은 즉시 강제보다 단계적 검증 가능한 형태로 추가
단계 2, 앱 쓰기 경로 변경
- 신규 쓰기는 old/new 컬럼을 함께 기록
- 변환 로직은 한 곳에 모아 일관성 있게 적용
단계 3, 앱 읽기 경로 호환화
- 새 컬럼 우선, 없으면 구컬럼 fallback
- 혹은 read model에서 두 구조를 모두 수용
단계 4, backfill 수행
- 청크 단위 업데이트
- 진행률, 에러율, replica lag 관찰
- 재시작 가능하게 설계
단계 5, 검증
NULL잔존 row 수 확인- old/new 값 불일치 샘플링
- 중복 및 참조 무결성 검사
- 신 read path에서 오류율 확인
단계 6, 강제
- 필요한 제약 validate
- 최종
NOT NULL, unique contract, FK 승격 등 수행 - 이 단계는 가장 보수적으로 창구를 잡는다
단계 7, contract
- 구 read path 제거
- dual write 제거
- 구컬럼/구인덱스/구제약 제거
- 운영 문서와 쿼리 스니펫 정리
이 시퀀스가 중요한 이유는 어떤 단계에서든 멈출 수 있기 때문이다. 4단계에서 문제가 나면 backfill만 멈추면 되고, 5단계에서 값 불일치가 발견되면 read path를 유지한 채 데이터를 다시 맞추면 된다. 반면 한 번에 강제한 뒤 깨지면 되돌리는 비용이 훨씬 크다.
트레이드오프: 빠르게 한 번에 끝내기 vs 길지만 안전하게 나누기
운영에서는 항상 트레이드오프가 있다. 아래 표처럼 생각하면 판단이 쉬워진다.
| 선택 | 장점 | 단점 | 언제 적합한가 |
|---|---|---|---|
한 번에 ALTER |
구현이 단순하고 문서가 짧다 | 큰 락, 긴 검증, 롤백 어려움 | 데이터가 매우 작고 트래픽이 낮을 때 |
| expand-contract | 롤백 친화적, 배포 호환성 좋음 | 코드와 스키마가 한동안 이중화됨 | 대부분의 운영 서비스 |
| SQL 단일 backfill | 작성이 빠름 | 부하 제어 어려움, 긴 트랜잭션 위험 | 작은 테이블, 단기 유지보수 창구 |
| 배치 워커 기반 backfill | 속도 제어와 관찰성이 좋음 | 구현 비용이 듦 | 대형 테이블, 반복 가능한 운영 작업 |
| 즉시 제약 강제 | 규칙 수렴이 빠름 | 데이터 품질 문제를 바로 드러내며 장애화 가능 | 이미 사전 검증이 충분할 때 |
NOT VALID 후 검증 |
운영 충격 분산 | 최종 완료까지 시간이 더 걸림 | 대규모 테이블, 단계적 전환 |
중급 이상 개발자에게 중요한 건 “가장 우아한 SQL”보다 전체 시스템의 리스크 곡선을 완만하게 만드는 선택이다. 개인적으로 운영 환경에서는 거의 항상 expand-contract 쪽이 맞다. 코드가 조금 지저분해져도 서비스가 덜 위험하다면 그 편이 이긴다.
변경 유형별 추천 전략: 무엇을 바로 바꿔도 되고, 무엇을 꼭 단계화해야 하는가
실무에서는 모든 변경을 같은 템플릿으로 다루면 비효율적이다. 반대로 변경 성격을 잘못 분류하면 장애로 간다. 내가 자주 쓰는 판단 기준을 유형별로 정리하면 아래와 같다.
1) 새 nullable 컬럼 추가
대체로 가장 쉬운 편이다. 그래도 확인할 것은 있다.
- ORM이 새 컬럼을 반드시 요구하도록 생성되지는 않는가
- 기본값 표현식이 무겁거나 버전 특성상 rewrite 위험은 없는가
- 컬럼이 추가된 직후 select * 기반 코드가 예기치 않게 깨지지 않는가
대부분은 expand 단계로 바로 넣고, 나중에 backfill과 제약 강제로 이어가면 된다.
2) 새 non-null 컬럼 추가
문법상 한 줄로 끝내고 싶어도 운영에서는 보통 이렇게 하지 않는다.
- 우선 nullable로 추가
- 신규 쓰기부터 채움
- backfill
- 검증
- 마지막에 non-null 강제
이 순서를 무시하고 한 번에 끝내려 하면 결국 과거 데이터와 롤링 배포 호환성 문제를 만나게 된다.
3) 컬럼 rename
DB 내부에서는 가벼운 작업일 수 있어도, 애플리케이션과 외부 소비자까지 포함하면 의외로 까다롭다. 그래서 나는 정말 작은 시스템이 아니면 rename을 “새 컬럼 도입 + 점진 전환 + 구컬럼 제거”로 해석하는 편이다.
특히 아래가 있으면 직접 rename보다 점진 전환이 안전하다.
- 여러 서비스가 같은 DB를 참조함
- SQL 문자열이 코드 곳곳에 흩어져 있음
- 분석 쿼리와 운영 스크립트가 많음
- 롤링 배포 구간이 김
4) 타입 변경
가장 보수적으로 접근해야 하는 영역 중 하나다. 값 변환이 단순하고 데이터가 작으면 직접 변경도 가능할 수 있다. 하지만 아래라면 거의 이주 프로젝트로 보는 편이 맞다.
- PK/FK 컬럼
- JSON contract에 직접 노출된 값
- 코드 여러 군데에서 파싱 규칙이 얽힌 값
- 단순 캐스팅이 아니라 의미 변환이 필요한 값
이 경우 shadow column 또는 shadow table 전략이 더 안전하다.
5) 인덱스 추가
조회 성능을 위해 빨리 추가하고 싶지만, 운영에서는 항상 질문이 필요하다.
- 정말 필요한 인덱스인가
- write amplification을 감수할 가치가 있는가
- 기존 쿼리가 새 인덱스를 실제로 탈 것인가
- concurrent 생성이 필요한가
인덱스는 “있으면 좋은 것”이 아니라 지속적으로 쓰기 비용을 내는 구조물이다. migration 타이밍에 관성적으로 늘리기보다, 새 read path와 직접 연결되는 것만 넣는 편이 낫다.
6) Unique/FK/Check 제약 추가
이건 거의 항상 두 단계가 좋다.
- 먼저 데이터 품질 측정
- 가능한 경우 인덱스/제약 생성과 검증 시점을 분리
특히 FK는 애플리케이션에서 당연히 맞다고 느껴도, 운영 데이터에는 예외가 숨어 있는 경우가 많다. 배포 창구에서 처음 발견하지 말고 사전 탐지 쿼리로 먼저 확인해야 한다.
7) 컬럼 삭제
생각보다 가장 늦게 해야 하는 작업이다. 많은 팀이 새 구조를 잘 도입한 뒤에도 마지막 contract에서 사고를 낸다. 이유는 삭제 자체보다 “아직 누가 쓰는지 모르는 참조”가 남아 있기 때문이다.
그래서 컬럼 삭제 직전에는 아래가 필요하다.
- 코드 검색
- 쿼리 로그/BI 도구 점검
- 대시보드/배치/운영 스크립트 확인
- 일정 기간 read fallback 제거 후 이상 없는지 관찰
구조를 추가하는 일보다 구조를 지우는 일이 더 위험하다는 감각이 중요하다.
운영 런북 예시: 실제 배포 창구에서 어떤 순서로 움직일 것인가
개념을 이해해도, 실제 배포 순간에는 순서가 흔들리기 쉽다. 그래서 팀 차원의 런북이 있으면 좋다. 예를 들어 새 컬럼 이주 작업이라면 나는 대략 이런 순서를 권한다.
배포 전날 또는 사전 준비
- 대상 변경에 대한 설계 문서 1장 정리
- old/new 컬럼 관계, dual write 방식, 검증 쿼리, 롤백 방법 명시
- 운영 대시보드 링크와 담당자 확인
- staging 또는 샘플 데이터 환경에서 DDL 실행 시간 측정
1차 배포, expand + 쓰기 호환화
- 새 컬럼 추가
- 필요한 인덱스 생성 시작 또는 예약
- 애플리케이션 배포: 신규 쓰기 dual write 적용
- 읽기는 아직 fallback 포함 상태 유지
이 단계의 성공 기준은 “새 컬럼이 생겼다”가 아니라, 지금부터 들어오는 신규 데이터가 old/new 모두에 안정적으로 기록된다는 점이다.
관찰 구간
- 신규 row에서 old/new 불일치 없는지 확인
- 에러율 증가 없는지 확인
- write latency 변화 확인
2차 작업, backfill
- 작은 배치 크기부터 시작
- replica lag와 DB load를 보며 점진적으로 증속
- 중간중간 남은 row 수와 불일치 row 수 측정
- 필요 시 언제든 일시 중지 가능하게 운영
이 단계의 성공 기준은 단순 처리 속도가 아니라 안전한 속도로 끝까지 갈 수 있는지다.
3차 배포, read path 전환
- 애플리케이션이 새 컬럼을 기준으로 읽도록 전환
- fallback은 짧은 기간 남기거나, 일부 경로만 단계적으로 제거
- 주요 API와 배치를 집중 관찰
이 단계에서는 기능 에러보다 데이터 해석 오류가 더 위험하다. 응답은 성공하지만 값 의미가 달라지는 문제를 꼭 샘플링으로 확인해야 한다.
4차 작업, validate + contract
NULL, 중복, FK 위반 등 검증 완료- 필요한 제약 최종 강제
- dual write 제거
- 구컬럼/구인덱스 제거
이 단계의 성공 기준은 “코드가 새 구조 하나만 알게 되었다”는 점이다. 여기까지 와야 비로소 migration이 끝난다.
이런 런북의 장점은 사람을 덜 믿어도 된다는 것이다. 운영 배포는 종종 피곤한 시간대에 이뤄지고, 팀 내 맥락이 100% 공유되지 않는다. 그래서 절차를 문서화해두면 개인 숙련도 차이를 줄일 수 있다.
흔한 실수 1: backfill 전에 dual write를 안 넣는다
이 실수는 매우 흔하다. 과거 데이터만 채우면 끝이라고 생각하고 backfill부터 시작한다. 그런데 backfill이 30분, 2시간, 하루가 걸릴 수 있다. 그동안 신규 쓰기가 계속 들어오면 새 컬럼은 다시 비게 된다.
결과적으로:
- backfill 완료 직후에도
NULL이 남는다. - 어느 row가 과거 데이터 누락인지, 신규 쓰기 누락인지 구분이 안 된다.
- 최종 검증이 자꾸 실패한다.
정답은 거의 항상 같다.
- 신규 쓰기부터 old/new 동시 기록
- 그 다음에 과거 데이터 backfill
순서가 반대면 정합성 관리가 급격히 어려워진다.
흔한 실수 2: 읽기 fallback 없이 바로 읽기 경로를 바꾼다
새 컬럼을 추가하고 일부 데이터만 채운 상태에서 앱이 곧바로 새 컬럼만 읽게 바꾸면, backfill 전 row에서 오류나 잘못된 기본값이 발생한다. 특히 리스트 API나 배치에서 특정 row만 비어 있어도 문제는 매우 찾기 어렵다.
좋은 패턴은 아래 둘 중 하나다.
- 앱 레벨 fallback:
new_col ?? transform(old_col) - DB 레벨 fallback:
COALESCE(new_col, ...)
그리고 fallback 제거는 backfill 완료 + 값 검증 + 충분한 관찰 뒤에 한다.
흔한 실수 3: 제약 위반 데이터를 먼저 정리하지 않는다
UNIQUE, FK, NOT NULL을 추가하려는데 이미 더러운 데이터가 남아 있으면, 제약 추가는 단순 실패가 아니라 운영 이벤트가 된다. 예를 들어 배포 창구 안에서 제약 검증이 실패하면,
- 배포 상태가 반쯤 어정쩡해지고
- 일부 앱은 새 코드, 일부는 옛 코드인 상태에서 팀이 수작업 정리를 시작하게 되고
- 장애인지 배포 실패인지 경계가 흐려진다.
이 문제를 피하려면 먼저 데이터 품질을 측정해야 한다.
- 중복 수
- NULL 수
- 참조 깨진 row 수
- old/new 불일치 수
제약은 마지막에 규칙을 고정하는 장치다. 데이터를 정리하는 도구가 아니다.
흔한 실수 4: 애플리케이션 외부 소비자를 잊는다
API 서버만 바꾸면 끝이라고 생각하지만, 실제로는 아래가 자주 빠진다.
- ETL/ELT 쿼리
- BI 도구 대시보드
- 데이터 export 스크립트
- 운영자 수동 SQL
- 캐시 워머나 비동기 워커
- 다른 서비스의 read replica 질의
구컬럼을 너무 빨리 제거하면 이런 바깥 소비자들이 조용히 깨진다. 특히 replica를 읽는 분석성 쿼리는 메인 앱 배포와 독립적으로 오래 남아 있는 경우가 많다.
그래서 contract 전에 꼭 해야 할 일이 있다.
- 레포 전체 검색
- 쿼리 로그/대시보드 확인
- 배치와 스케줄러 목록 확인
- 문서와 runbook 수정
컬럼 제거는 기술 작업이면서 동시에 커뮤니케이션 작업이다.
흔한 실수 5: 롤백을 “migration down”으로만 생각한다
운영에서의 롤백은 단순히 DDL을 거꾸로 실행하는 문제가 아니다. 이미 새 데이터가 들어갔고, 일부 앱은 새 경로를 쓰고, backfill이 절반 진행됐을 수 있다.
그래서 더 현실적인 롤백 전략은 보통 다음과 같다.
- 스키마는 일단 유지
- 앱 read path만 구경로로 되돌림
- dual write는 유지하거나 임시 중단
- 백그라운드 작업 중단
- 데이터 정합성 확인 후 다시 진행
즉 롤백 가능한 시스템은 대개 “되돌리는 DDL”보다 “중간 상태로 버틸 수 있는 설계”를 가진 시스템이다. 이 점에서 expand-contract는 사실상 롤백 전략 자체이기도 하다.
체크리스트: 운영 전 반드시 확인할 것
배포 직전 체크리스트를 짧게 정리하면 아래와 같다.
변경 전
- 대상 테이블 row 수와 일평균 쓰기량을 파악했는가
- 이 변경이 메타데이터 수준인지, 검증/재작성이 필요한지 확인했는가
- 관련 앱, 워커, 배치, 대시보드, 외부 소비자를 목록화했는가
- 롤백 시 read path를 어떻게 되돌릴지 정했는가
- replica lag, WAL, lock wait, error rate 대시보드를 준비했는가
expand 단계
- 새 컬럼은 우선 nullable로 추가했는가
- 새 인덱스는 필요 시
CONCURRENTLY전략을 선택했는가 - 제약은 즉시 강제보다 단계적 검증 가능 여부를 검토했는가
migrate 단계
- 신규 쓰기에 dual write를 먼저 넣었는가
- 읽기 fallback을 넣었는가
- backfill은 청크 기반이며 중단/재개 가능한가
- backfill 진행률을 SQL로 측정할 수 있는가
validate 단계
NULL, 중복, 참조 위반, old/new 불일치를 모두 측정했는가- 새 read path에서 오류율과 성능을 확인했는가
- 최종 강제 전에 충분한 관찰 시간을 가졌는가
contract 단계
- 구컬럼 참조가 코드/배치/대시보드에서 모두 제거됐는가
- dual write 제거 시점이 명확한가
- 구인덱스, 구제약, 구컬럼 삭제 순서를 정했는가
- 문서와 운영 쿼리를 업데이트했는가
이 체크리스트만 지켜도 상당수의 스키마 변경 사고를 피할 수 있다.
한 단계 더: 언제 “정말 무중단”이 아니라 “짧은 유지보수 창구”가 더 나은가
모든 변경을 무조건 무중단으로 만드는 것이 정답은 아니다. 아래 같은 경우에는 오히려 짧고 통제된 유지보수 창구가 더 낫다.
- 데이터량이 매우 작아 즉시 변경 비용이 거의 없을 때
- 서비스 특성상 야간 수분 정지가 충분히 허용될 때
- 여러 서비스 계약이 얽혀 expand-contract 구현 비용이 과도할 때
- 이미 시스템이 단일 테넌트 내부용이라 운영 리스크보다 개발 단순성이 중요할 때
다만 여기서도 기준은 같다.
- “쉽다”가 아니라 실제로 영향이 작은가
- 유지보수 창구 안에 검증과 롤백이 가능한가
- 팀이 그 창구를 운영적으로 통제할 수 있는가
운영에서 가장 나쁜 선택은 복잡한 변경을 단순한 변경처럼 취급하는 것이다. 반대로 정말 작은 변경에 과도한 절차를 붙여 속도를 잃는 것도 낭비다. 결국 중요한 것은 변경 비용을 데이터량과 트래픽 현실에 맞게 평가하는 감각이다.
실전 권장 기준: 내가 기본값으로 두는 판단 순서
PostgreSQL 운영 스키마 변경을 해야 할 때, 나는 보통 아래 순서로 판단한다.
- 이 변경은 구버전 앱과 공존 가능한가
- 새 구조를 먼저 추가하고 나중에 전환할 수 있는가
- 대량 데이터 이동이 필요하면 배치 속도를 제어할 수 있는가
- 인덱스와 제약은 분리해서 도입할 수 있는가
- 검증 후에야 최종 강제를 할 수 있는가
- 중간 상태에서 멈춰도 서비스가 정상 동작하는가
이 여섯 질문에 모두 “예”라고 답할 수 있으면 무중단 변경 가능성이 높다. 반대로 하나라도 “아니오”라면 설계를 더 쪼개야 한다.
운영 시스템은 DDL 성공 여부보다 실패했을 때의 모양이 더 중요하다. 한 번의 멋진 migration보다, 중간에 멈춰도 버티는 boring한 절차가 더 좋은 설계다.
한 줄 정리
PostgreSQL 무중단 스키마 변경의 핵심은 ALTER TABLE을 잘 쓰는 것이 아니라, expand, dual write, backfill, validate, contract 순서로 구버전 앱과 과거 데이터를 끝까지 호환시키는 운영 절차를 설계하는 것이다.
댓글