05 · 프로덕션 전략 — expand → migrate → contract
질문: 운영 중 서비스에서 컬럼 이름 하나 바꾸는 일이 어떻게 한 PR로 끝낼 수 없는 일이 되나? 한 줄 답: zero-downtime 마이그레이션은 expand → migrate → contract의 3단계다 — 새 컬럼을 추가하고, 코드를 양쪽 호환으로 배포하고, 데이터를 복사한 뒤, 마지막에 옛 컬럼을 제거한다. 한 PR로 끝낼 수 없다.
Pyramid Top
운영 중 시스템에서 스키마를 바꾼다는 건 몇 분~며칠에 걸친 작업이다. 가장 큰 두 함정:
- table-level lock: PostgreSQL/MySQL의 ALTER가 큰 테이블에 수 분 ~ 수십 분의 lock을 잡는다 — 그 시간 동안 모든 INSERT/UPDATE 차단.
- 롤링 배포 중 옛 코드와 새 코드가 동시에 살아있는 시간: 옛 코드가 없는 컬럼을 읽으려 하거나, 새 코드가 옛 컬럼을 기대하면 런타임 에러.
expand-contract 패턴은 이 둘을 동시에 푼다 — 3개 이상의 PR로 시간을 가른다.
사고 흐름
Why — 왜 한 PR로 끝낼 수 없나
① 롤링 배포 중에는 두 버전의 코드가 동시에 살아있다
만약 PR이 *“username 컬럼 추가 + 옛 name 컬럼 삭제 + 코드는 username만 사용”*을 한 번에 한다면 — 배포 중 v1 인스턴스는 없어진 name 컬럼을 읽으려 한다 → 런타임 에러.
② 큰 테이블의 ALTER는 서비스를 멈춘다
| DDL | PostgreSQL의 lock | 대상 |
|---|---|---|
ADD COLUMN ... NULL | 짧은 ACCESS EXCLUSIVE, 빠름 | 안전 |
ADD COLUMN ... NOT NULL DEFAULT | 전 row 재작성 (PG 11 미만) | 수 분~수십 분 lock |
ADD COLUMN ... NOT NULL DEFAULT 상수 (PG 11+) | 메타데이터만 변경, 빠름 | 안전 |
DROP COLUMN | 메타데이터, 빠름 | 안전 (디스크는 안 회수) |
ALTER COLUMN TYPE | 전 row 재작성 | 수 분~수십 분 lock |
CREATE INDEX | 쓰기 lock | 수 분 lock |
CREATE INDEX CONCURRENTLY | 거의 없음 (느림) | 안전 |
ADD CONSTRAINT FOREIGN KEY | 전 row 검증 | 큰 테이블에서 수 분 lock |
ADD CONSTRAINT FOREIGN KEY ... NOT VALID + 별도 VALIDATE | 짧음 + 백그라운드 검증 | 안전 |
외울 필요는 없다 — 규칙 하나만 기억: 큰 테이블에 ALTER를 던지기 전에 lock 종류를 반드시 확인.
③ 데이터 복사는 작은 batch로 나눠야 한다
-- ❌ 위험: 천만 row를 한 번에
UPDATE "user" SET username = name;
-- ✅ 안전: 1만 row씩 batch
UPDATE "user" SET username = name
WHERE id IN (
SELECT id FROM "user" WHERE username IS NULL LIMIT 10000
);한 번에 천만 row UPDATE는 transaction log 폭발 + 긴 lock + 동시 트래픽 차단을 동시에 일으킨다.
How — expand-contract의 4 PR 패턴
대표 케이스: name 컬럼을 username으로 rename.
PR 1 — Expand (확장)
// Migration
public async up(qr: QueryRunner): Promise<void> {
await qr.query(`ALTER TABLE "user" ADD COLUMN "username" varchar(255) NULL`);
}
public async down(qr: QueryRunner): Promise<void> {
await qr.query(`ALTER TABLE "user" DROP COLUMN "username"`);
}// 코드: 양쪽에 쓰기 (dual write)
@Entity()
export class User {
@Column() name!: string;
@Column({ nullable: true }) username!: string | null;
}
// 저장 시
user.name = input;
user.username = input; // dual write
await repo.save(user);이 시점에:
- 새 인스턴스는 둘 다 쓴다.
- 옛 인스턴스는 name만 쓰지만 — username은 nullable이라 안전.
PR 2 — Migrate (배치)
public async up(qr: QueryRunner): Promise<void> {
// 작은 batch로 데이터 복사
while (true) {
const result = await qr.query(`
UPDATE "user" SET username = name
WHERE id IN (
SELECT id FROM "user" WHERE username IS NULL LIMIT 10000
)
RETURNING id
`);
if (result.length === 0) break;
}
}
public async down(qr: QueryRunner): Promise<void> {
// username = NULL로 되돌리는 건 *위험* — 일부러 비워두거나 명시
throw new Error('Forward-only migration');
}이 migration은 데이터 변경이지 스키마 변경이 아니다 —
transaction: false로 두고 resumable하게 짠다. 중간에 실패해도 다시 돌리면 남은 row만 복사.
PR 3 — Read switch (읽기 전환)
// 코드: username으로 읽기 시작, 쓰기는 여전히 양쪽
const user = await repo.findOneBy({ id });
return { name: user.username }; // ← username 사용
// 저장은 dual write 유지이 시점에 모든 read가 username을 본다. 옛 인스턴스가 섞여 있어도 — 둘 다 쓰는 상태라 모든 row에 username이 있음.
PR 4 — Contract (수축)
// Migration
public async up(qr: QueryRunner): Promise<void> {
// username을 NOT NULL로
await qr.query(`ALTER TABLE "user" ALTER COLUMN "username" SET NOT NULL`);
// 옛 컬럼 제거
await qr.query(`ALTER TABLE "user" DROP COLUMN "name"`);
}// 코드: name 완전 제거
@Entity()
export class User {
@Column() username!: string; // name 삭제
}이제 옛 인스턴스가 없다고 확신하면 — 옛 컬럼을 안전하게 제거.
What — 변경 종류별 expand-contract 패턴
| 변경 | Expand | Migrate | Contract |
|---|---|---|---|
| 컬럼 rename | 새 컬럼 추가 + dual write | 데이터 복사 | 옛 컬럼 제거 |
| 컬럼 NOT NULL 추가 | nullable로 추가 + default 채우기 | 모든 NULL 채우기 | NOT NULL로 변경 |
| 타입 변경 | 새 컬럼 (새 타입) 추가 + dual write | 데이터 복사 (캐스팅) | 옛 컬럼 제거 |
| 인덱스 추가 (큰 테이블) | — | CREATE INDEX CONCURRENTLY | (없음) |
| FK 추가 (큰 테이블) | FK NOT VALID로 추가 + 쓰기 차단 | VALIDATE CONSTRAINT | (없음) |
| 테이블 split | 새 테이블 + dual write | 데이터 복사 | 옛 컬럼/테이블 제거 |
| enum value 추가 | enum에 value 추가 | (없음) | (없음) — 추가는 안전 |
| enum value 제거 | 코드에서 사용 중단 → 모든 row 다른 값으로 → 데이터 검증 | (없음) | enum에서 value 제거 |
What — 드물게 한 PR이 가능한 경우
| 케이스 | 왜 안전한가 |
|---|---|
| 새 작은 테이블 추가 | 누구도 안 쓰던 테이블 |
| 새 nullable 컬럼 추가 (small table) | 옛 코드가 모르더라도 영향 없음 |
| 인덱스 추가 (small table) | lock 시간이 짧음 |
| 코드에서 전혀 안 쓰는 컬럼 제거 | 모든 코드 경로가 안 쓰는 게 확인되면 |
나머지 거의 모든 변경은 여러 PR이다 — 다운타임이 없는 운영의 실제 비용.
What-if — 한 PR로 강행하면
| 시도 | 결과 |
|---|---|
큰 테이블에 ADD COLUMN NOT NULL DEFAULT 'x' (PG 11 미만) | 전 row 재작성 → 수 분 lock → 모든 INSERT/UPDATE 차단 |
| 한 PR에 컬럼 rename | 롤링 배포 중 옛 인스턴스가 새 컬럼 모름 → 런타임 에러 |
| 천만 row를 한 번에 UPDATE | transaction log 폭발, replication lag 폭증, lock 충돌 |
큰 테이블에 CREATE INDEX (CONCURRENTLY 없이) | 쓰기 lock → 서비스 멈춤 |
| 큰 테이블에 FK 추가 (NOT VALID 없이) | 전 row 검증 → 수 분 lock |
What — 흔히 쓰는 보조 도구
| 도구 | 용도 |
|---|---|
| pt-online-schema-change (Percona) | MySQL에서 큰 테이블 ALTER를 백그라운드 복사로 |
| gh-ost (GitHub) | 같은 목적, MySQL replication 활용 |
| pgroll (Xata) | PostgreSQL의 expand-contract을 YAML로 |
| TypeORM의 chunked migration | 위의 batch UPDATE 예시 |
TypeORM 자체는 이 도구들과 통합되어 있지 않다 — 수동으로 SQL을 짜거나, 별도 도구를 호출하는 단계가 필요하다. Prisma 진영도 이 부분은 직접 짜야 한다.
What — 데이터 복사 batch 패턴
public async up(qr: QueryRunner): Promise<void> {
const BATCH = 10000;
let count = 0;
while (true) {
const r = await qr.query(`
WITH batch AS (
SELECT id FROM "user"
WHERE "username" IS NULL
LIMIT ${BATCH}
FOR UPDATE SKIP LOCKED
)
UPDATE "user" u
SET username = u.name
FROM batch
WHERE u.id = batch.id
`);
if (r.affected === 0) break;
count += r.affected;
// log + sleep 1초 (replication lag 보호)
await new Promise((res) => setTimeout(res, 1000));
}
}FOR UPDATE SKIP LOCKED: 동시 트래픽과의 lock 충돌 회피.LIMIT: 한 트랜잭션의 크기 제한 → log 폭발 방지.- 중간 sleep: replication lag 따라잡을 시간.
흥미로운 이야기
“GitHub은 큰 ALTER를 한 번도 직접 돌리지 않는다 — gh-ost로 모두 우회”
GitHub의 MySQL 테이블 일부는 수십 TB다. 그런 테이블에 그냥 ALTER를 던지면 몇 시간 lock이 잡혀 모든 PR 작업이 멈춘다. 그래서 GitHub 인프라 팀은 gh-ost를 만들었다 — replica를 새로 만들고, 그쪽에 ALTER를 적용한 뒤, replication으로 따라잡고, 마지막에 atomic하게 swap하는 도구. 전 운영 시간 동안 lock 없음. 이런 도구가 오픈소스로 풀린 이유는 — 큰 회사들이 다 같은 문제에 부딪혔기 때문. TypeORM의
migration:run은 작은~중간 테이블까지만 안전하고, 그 너머는 별도 도구의 영역이다. 마이그레이션은 테이블이 커지면 전혀 다른 도메인이 된다.
Insight — 시간이 명시적 변수가 되는 패턴
코드에서 if-else 분기를 시간 축에 펼친 셈이다. 각 PR은 그 시점의 약속만 갖고 있고, 시간이 흐르며 약속이 다음 약속으로 인수인계된다. expand-contract은 분산 시스템 시대의 마이그레이션의 유일한 안전한 길.
한 단락 요약
운영 중 서비스의 zero-downtime 마이그레이션은 expand → migrate → contract의 3단계다. 새 컬럼을 추가하고, 코드를 양쪽 호환으로 배포한 뒤, 데이터를 작은 batch로 복사하고, 읽기를 새 쪽으로 전환한 다음, 마지막에 옛 컬럼을 제거한다. 한 PR로 끝낼 수 없는 일 — 보통 3~4개 PR + 사이사이 배포와 검증 시간. 큰 테이블의 ALTER는 table lock으로 서비스를 멈춘다 —
CONCURRENTLY,NOT VALID,pt-online-schema-change,gh-ost가 보조 도구. 마이그레이션은 테이블 크기가 1GB를 넘는 순간 전혀 다른 도메인이 된다. 다음:06-cli-and-typeorm-config— 마이그레이션을 돌리기 전에 막히는 CLI 설정 함정들.