🗄️ TypeORM6. Migration & Schema05 · 프로덕션 전략 (expand-contract)

05 · 프로덕션 전략 — expand → migrate → contract

질문: 운영 중 서비스에서 컬럼 이름 하나 바꾸는 일어떻게 한 PR로 끝낼 수 없는 일이 되나? 한 줄 답: zero-downtime 마이그레이션은 expand → migrate → contract의 3단계다 — 새 컬럼을 추가하고, 코드를 양쪽 호환으로 배포하고, 데이터를 복사한 뒤, 마지막에 옛 컬럼을 제거한다. 한 PR로 끝낼 수 없다.


Pyramid Top

운영 중 시스템에서 스키마를 바꾼다는 건 몇 분~며칠에 걸친 작업이다. 가장 큰 두 함정:

  1. table-level lock: PostgreSQL/MySQL의 ALTER가 큰 테이블수 분 ~ 수십 분의 lock을 잡는다 — 그 시간 동안 모든 INSERT/UPDATE 차단.
  2. 롤링 배포 중 옛 코드와 새 코드가 동시에 살아있는 시간: 옛 코드가 없는 컬럼읽으려 하거나, 새 코드가 옛 컬럼기대하면 런타임 에러.

expand-contract 패턴은 이 둘을 동시에 푼다 — 3개 이상의 PR시간을 가른다.


사고 흐름


Why — 왜 한 PR로 끝낼 수 없나

① 롤링 배포 중에는 두 버전의 코드가 동시에 살아있다

만약 PR이 *“username 컬럼 추가 + 옛 name 컬럼 삭제 + 코드는 username만 사용”*을 한 번에 한다면 — 배포 중 v1 인스턴스없어진 name 컬럼읽으려 한다런타임 에러.

② 큰 테이블의 ALTER는 서비스를 멈춘다

DDLPostgreSQL의 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 UPDATEtransaction 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 유지

이 시점모든 readusername을 본다. 옛 인스턴스가 섞여 있어도둘 다 쓰는 상태모든 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 패턴

변경ExpandMigrateContract
컬럼 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를 한 번에 UPDATEtransaction 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 + 사이사이 배포와 검증 시간. 큰 테이블의 ALTERtable lock으로 서비스를 멈춘다CONCURRENTLY, NOT VALID, pt-online-schema-change, gh-ost가 보조 도구. 마이그레이션은 테이블 크기가 1GB를 넘는 순간 전혀 다른 도메인이 된다. 다음: 06-cli-and-typeorm-config마이그레이션을 돌리기 에 막히는 CLI 설정 함정들.