← 모든 글

Migration Script, 한 번 더 돌려도 괜찮은가

멱등성 없는 마이그레이션 스크립트가 일으키는 사고와 실전 체크리스트

사고는 항상 두 번째 실행에서 난다

처음 돌릴 때는 긴장하고 돌린다. 문제는 두 번째다. “아까 중간에 끊겼으니까 다시 돌려야지” — 그 순간이 위험하다.

최근 팀에서 겪은 사례. users 테이블에 plan_migrated_at 컬럼을 추가하고 기존 데이터를 채우는 스크립트였다. 첫 번째 실행이 네트워크 타임아웃으로 3만 건 쯤에서 멈췄다. 담당자가 다시 돌렸다. 이미 처리된 3만 건에 updated_at 이 또 찍혔고, 감사 로그는 6만 건이 됐다. 데이터 자체는 살았지만 감사 로그를 믿을 수 없게 됐다.

멱등성은 “같은 입력을 여러 번 줘도 결과가 동일한 성질”이다. 마이그레이션 스크립트에서는 두 번 돌려도 상태가 같아야 한다는 뜻. 말은 쉬운데 막상 짜다 보면 놓치는 지점이 꼭 있다.

INSERT 가 아니라 UPSERT 로 시작하기

INSERT INTO 로 시작하는 스크립트를 보면 일단 의심한다. 중복 실행 시 unique 제약에 걸려 죽거나, 제약이 없으면 행이 두 배로 복제된다. 둘 다 나쁘다.

  • INSERT ... ON CONFLICT DO NOTHING — 가장 단순한 안전장치. 이미 있으면 건너뛴다. PostgreSQL 기준으로 conflict target 을 명확히 써야 한다. ON CONFLICT (id) DO NOTHING 처럼 컬럼을 지정하지 않으면 unique 제약 전체를 뒤지게 돼 실수의 여지가 생긴다.
  • INSERT ... ON CONFLICT DO UPDATE SET col = EXCLUDED.col — 값을 최신으로 덮어야 할 때. 다만 updated_at = now() 를 같이 넣으면 두 번째 실행에서도 타임스탬프가 바뀐다. 감사 추적이 필요한 테이블이라면 조건부 UPDATE 를 써야 한다. DO UPDATE SET col = EXCLUDED.col WHERE target.col IS DISTINCT FROM EXCLUDED.col 처럼. 값이 같으면 아예 건드리지 않는다.
  • 배치 단위로 commit 하면서 진행 상황을 별도 테이블에 기록하는 방식도 있다. migration_checkpoints (script_name, last_processed_id, ran_at) 같은 테이블을 두고, 재시작 시 last_processed_id 부터 이어 달린다. 3만 건에서 끊긴 그 사고 이후 우리가 도입한 패턴이다.

DELETE 와 UPDATE 에 WHERE 조건이 없으면 즉시 멈춰라

UPDATE orders SET status = 'migrated' — WHERE 가 없다. 두 번 돌리면 전체 업데이트가 두 번 된다. 데이터가 idempotent 한 값이면 운이 좋은 거고, 아니면 사고다.

  • WHERE 에 이미 처리된 상태를 제외하는 조건을 반드시 건다. WHERE status != 'migrated' 처럼. 단, 이 조건이 인덱스를 타는지 확인해야 한다. 10만 건짜리 테이블에서 full scan 이 나오면 프로덕션 락이 걸린다. 우리는 한 번 status 컬럼에 인덱스가 없어서 7초짜리 락을 만들었다. 다음 날 인덱스를 먼저 추가하는 것으로 순서를 바꿨다.
  • DELETE 는 더 조심해야 한다. 삭제 대상을 먼저 SELECT 해서 숫자를 확인하고, 트랜잭션 안에서 ROLLBACK 테스트를 한 번 한 뒤 실제 COMMIT 하는 절차를 팀 룰로 잡았다. 번거롭지만 DELETE FROM sessions WHERE expired_at < now() 가 타임존 설정 오류로 전체를 날릴 뻔한 경험 이후로 타협하지 않는다.
  • 스크립트 맨 위에 SELECT count(*) 를 찍어두는 것도 습관이다. 예상 숫자와 다르면 멈추는 early exit 용이다. 150건 예상인데 14만 건이 찍히면 그 자리에서 중단한다.

외부 시스템 호출은 별도로 추적한다

DB 만 건드리는 스크립트라면 트랜잭션으로 원자성을 보장할 수 있다. 문제는 중간에 외부 API 를 호출할 때다. Stripe 에 구독 생성, SendGrid 에 메일 발송, S3 에 파일 복사 — 이런 사이드 이펙트는 DB 롤백해도 되돌아오지 않는다.

  • 외부 호출 결과를 DB 에 기록하고 재실행 시 이미 완료된 항목은 건너뛴다. stripe_migration_log (user_id, stripe_customer_id, created_at) 같은 테이블. 두 번 돌리면 로그 테이블을 먼저 확인하고 기존 항목은 패스한다. Stripe 에 중복 고객이 생기는 사고를 이걸로 막았다.
  • 외부 API 가 자체적으로 idempotency key 를 지원하면 반드시 넘긴다. Stripe 는 Idempotency-Key 헤더를 받는다. 같은 키로 두 번 요청하면 두 번째는 첫 번째 응답을 그대로 반환한다. 스크립트에서 user_{id}_{migration_version} 형태로 키를 만들어 헤더에 싣는다.
  • 메일 발송처럼 idempotency key 가 없는 외부 서비스는 더 보수적으로 간다. 발송 여부를 emails_sent (user_id, template, sent_at) 에 기록하고 재실행 시 이미 있으면 절대 안 보낸다. 중복 메일은 유저가 직접 클레임을 넣는다. 팀 대화에서 가장 빠르게 올라오는 장애 유형 중 하나다.

스크립트 자체에 dry-run 모드를 만든다

--dry-run 플래그 하나 추가하는 데 30분이면 된다. 근데 귀찮다고 안 넣는다. 그리고 사고 난다.

  • dry-run 모드에서는 트랜잭션을 열고 모든 쿼리를 실행한 뒤 마지막에 ROLLBACK 한다. 실제 변경 없이 “이 스크립트가 무엇을 바꾸려는지” 를 로그로 찍어준다. [DRY-RUN] UPDATE 4821 rows in orders 같은 출력.
  • 실행 환경마다 카운트가 다를 수 있다. 스테이징과 프로덕션의 데이터 분포가 달라서 스테이징에서 100건이었던 게 프로덕션에서 40만 건으로 나온 적이 있다. dry-run 으로 먼저 확인했기 때문에 인덱스 추가 후 배치 처리로 전략을 바꿀 수 있었다.
  • 팀 룰로 “프로덕션 첫 실행은 반드시 dry-run 먼저” 를 박아뒀다. PR 리뷰 체크리스트에도 항목으로 들어가 있다.

다음 한 가지

다음 마이그레이션 스크립트를 짤 때, 첫 줄 주석에 “이 스크립트를 두 번 돌리면 어떻게 되는가” 를 한 문장으로 적는다. 그 문장을 못 쓰겠으면 코드부터 다시 본다.


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

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