← 모든 글

migration script, 한 번 더 돌려도 안전한가

멱등성 없는 마이그레이션 스크립트가 어떻게 데이터를 두 번 망치는지, 체크리스트로 정리했다.

스크립트를 다시 돌렸더니 row 가 두 배가 됐다. 이 문장이 농담처럼 들리면 아직 운이 좋은 것.

왜 멱등성이 migration 에서 유독 아픈가

배포 중 네트워크가 끊기거나, CI 가 timeout 으로 스크립트를 재실행하거나, 누군가 실수로 같은 스크립트를 staging 이 아닌 production 에 한 번 더 때리는 일은 생각보다 자주 일어난다. 올해 초 우리 팀에서는 배포 자동화 파이프라인이 DB migration step 실패를 오탐지해 같은 스크립트를 두 번 실행했다. 결과는 audit_log 테이블 row 43만 건이 두 배로 복제된 것. 복구에 세 시간이 걸렸고, 그 세 시간 동안 정산 API 가 틀린 집계를 내보내고 있었다.

멱등성이 없는 스크립트는 첫 번째 실행에서 성공해도 재실행 순간 폭탄이 된다. 그게 migration 에서 유독 위험한 이유는, 롤백 기회가 한 번뿐인 경우가 많아서다.

체크 1 — INSERT 전에 EXISTS 를 먼저 물어라

가장 흔한 실수는 INSERT INTO 를 그냥 치는 것이다. 처음엔 “어차피 한 번만 실행할 거잖아”라고 생각한다. 막상 해보면 재실행 빈도가 예상의 세 배는 된다.

  • INSERT INTO ... SELECT ... WHERE NOT EXISTS (...) 패턴을 기본으로 쓴다. 조건을 빠뜨리면 중복 row 가 쌓이는데, unique constraint 가 없는 테이블에서는 DB 가 아무 말도 안 한다는 게 더 무섭다. 실제로 위의 audit_log 사건이 이 패턴 누락에서 시작됐다.
  • PostgreSQL 이면 INSERT ... ON CONFLICT DO NOTHING 을 적극 활용한다. 단, conflict target 을 명시적으로 잡아야 한다. ON CONFLICT DO NOTHING 만 쓰면 어떤 컬럼 기준인지 알 수 없어서 나중에 읽는 사람이 의도를 못 파악한다.
  • 스크립트 상단에 SELECT COUNT(*) FROM target_table WHERE migrated_at IS NOT NULL 을 로그로 찍고, 이미 처리된 건수가 0 이 아니면 early return 하는 guard 를 넣어라. 두 줄짜리 코드인데 재실행 사고를 절반 이상 막는다.

체크 2 — UPDATE 는 WHERE 조건이 전부다

UPDATE 는 INSERT 보다 교묘하다. 같은 값으로 다시 SET 하는 거라 언뜻 안전해 보이지만, 스크립트가 중간 상태를 다루는 순간 달라진다.

  • status = 'pending' 인 row 만 업데이트하는 스크립트가 있다고 하자. 첫 실행 후 일부 row 가 status = 'processing' 으로 바뀌었는데, 재실행하면 그 row 들이 다시 pending 으로 돌아가는 경우가 생긴다. WHERE 절에 AND status = 'pending' 이 빠졌을 때 실제로 겪었다. 30분짜리 결제 처리가 롤백되는 효과였다.
  • migration 전용 migrated_flag boolean 컬럼을 두거나, 처리된 row 에 migrated_at TIMESTAMP 를 찍는 패턴이 제일 깔끔하다. WHERE 에 AND migrated_at IS NULL 하나 추가하면 재실행이 no-op 이 된다.
  • UPDATE 문 바로 위에 -- idempotent: WHERE migrated_at IS NULL 주석을 붙이는 것을 팀 컨벤션으로 만들었다. 리뷰 때 이 주석이 없으면 무조건 코멘트를 달게 했더니 누락이 확 줄었다.

체크 3 — 스키마 변경은 IF NOT EXISTS / IF EXISTS 로 감싸라

DDL 은 더 단순하면서도 더 잘 잊힌다.

  • ALTER TABLE ADD COLUMN new_col TEXT 를 그냥 치면 두 번째 실행에서 column already exists 에러로 전체 스크립트가 죽는다. PostgreSQL 기준 ADD COLUMN IF NOT EXISTS 가 9.6부터 지원되니 쓰지 않을 이유가 없다.
  • 인덱스 생성은 CREATE INDEX IF NOT EXISTS 를 기본으로. 이것도 재실행 시 조용히 통과한다. 팀에 합류한 지 얼마 안 된 엔지니어가 IF NOT EXISTS 없이 인덱스 생성 스크립트를 올린 적이 있었는데, staging 재실행 테스트에서 잡혀서 다행이었다. production 이었으면 배포가 그 자리에서 멈췄을 것이다.
  • 반대로 DROP COLUMNDROP COLUMN IF EXISTS 로. 이미 지운 컬럼을 다시 지우려 해서 스크립트가 터지는 경우도 드물지 않다. 특히 hotfix 브랜치와 main 브랜치가 엉킬 때 자주 발생한다.

체크 4 — 트랜잭션 경계를 명확히 잡아라

멱등성만 챙기고 트랜잭션을 놓치면 절반만 처리된 상태가 DB 에 남는다.

  • 스크립트 전체를 하나의 트랜잭션으로 묶는 게 기본이다. 중간에 실패하면 전체 롤백, 성공하면 한 번에 커밋. 단, 테이블 크기가 수천만 건이면 단일 트랜잭션이 lock 을 너무 오래 잡아서 서비스 응답이 튄다. 그 경우에는 배치 단위로 나눠서 각 배치를 독립 트랜잭션으로 처리하고, 배치 완료 여부를 별도 tracking 테이블에 기록한다.
  • tracking 테이블 패턴은 migration_runs(script_name, batch_id, completed_at) 으로 관리한다. 재실행 시 script_name + batch_id 가 이미 있으면 그 배치를 건너뛴다. 멱등성을 DB 레벨에서 보장하는 셈이다.
  • 사건 하나. 배치 200개짜리 migration 을 돌리다가 148번 배치에서 커넥션이 끊겼다. tracking 테이블 덕에 재실행 시 1번부터 147번은 자동으로 skip 하고 148번부터 이어서 진행됐다. 없었으면 처음부터 다시였다.

체크 5 — 재실행 테스트를 CI 에 넣어라

멱등성 체크리스트를 아무리 잘 만들어도 검증 루틴이 없으면 흐지부지된다.

  • CI 파이프라인에 migration script 를 두 번 연속 실행하는 step 을 추가했다. 두 번째 실행 후 row count 와 데이터 해시를 첫 번째 실행 결과와 비교해서 다르면 PR 이 merge 되지 않는다. 구현 자체는 쉬운데, 만들기 전까지 팀 내 누구도 재실행 테스트를 수동으로 하지 않고 있었다.
  • staging DB 를 production 스냅샷으로 주기적으로 교체하는 것도 세트로 필요하다. 빈 DB 에서 멱등성이 통과해도 실제 데이터 분포에서 실패하는 케이스가 있다. 특히 unique constraint 가 복합키일 때 그런 경우를 겪었다.

다음 한 가지

다음 migration PR 부터 description 첫 줄에 “이 스크립트를 두 번 돌리면 어떻게 되나” 한 문장을 의무로 적기로 했다. 쓰다 보면 빠진 게 보인다.


🛒 이 글과 어울리는 추천 상품

위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.