← 모든 글

Migration Script, 한 번 더 돌려도 안전한가

멱등성 없는 마이그레이션이 새벽 3시에 어떻게 터지는지, 체크리스트로 정리했다.

멱등성이 없으면 재시도가 곧 장애다

마이그레이션 스크립트를 딱 한 번만 돌릴 자신이 있다면 이 글은 건너뛰어도 된다. 막상 현실은 다르다. 네트워크 끊기고, 타임아웃 나고, 누군가 실수로 다시 돌리고. 그 상황에서 스크립트가 두 번 돌아도 아무 일 없어야 한다는 게 멱등성(idempotency)이다.

최근 사내에서 user_role 컬럼 기본값 변경 마이그레이션을 배포하다가 터졌다. 1차 실행이 절반쯤 진행되다 DB 커넥션 풀 고갈로 죽었다. 담당자가 스크립트를 다시 돌렸고, 이미 업데이트된 row 에 또 UPDATE 를 때렸다. 결과적으로 데이터는 맞았지만, 그 과정에서 exclusive lock 이 40초간 풀리지 않아 API 타임아웃이 줄줄이 났다. “한 번 더 돌렸을 뿐인데”가 장애 보고서 첫 줄이 됐다.


체크 1 — WHERE 조건에 이미 된 것을 걸러라

가장 흔한 실수는 조건 없는 UPDATE 다. 테이블 전체를 다시 긁는다. 처음엔 빠른데, 두 번째부터 불필요한 lock 이 생긴다.

  • WHERE migrated_flag IS NULL 또는 WHERE status != 'new_value' 로 이미 완료된 row 를 아예 건드리지 않게 한다. 우리는 migrated_at 컬럼을 추가해 NULL 인 row 만 처리하도록 바꿨더니, 2차 실행 시 영향받는 row 가 0건이 됐고 lock 시간이 0에 수렴했다.

  • INSERT 는 INSERT ... ON CONFLICT DO NOTHING 또는 INSERT IGNORE 를 기본으로 쓴다. 그냥 INSERT 를 쓰면 PK 충돌로 에러가 나거나, UPSERT 가 되어 의도치 않게 기존 값을 덮어쓴다. 어느 쪽도 원하지 않는다.

  • 시퀀스·auto increment 를 손대는 경우라면, 현재 값이 목표값보다 낮을 때만 setval 을 실행하는 조건을 달아야 한다. 이걸 빠뜨려서 시퀀스를 과거로 되돌리는 사고가 실제로 난 적 있다.


체크 2 — DDL 도 조건부로

DDL 은 멱등하다는 착각이 있다. ALTER TABLE ADD COLUMN 을 두 번 돌리면 컬럼이 이미 있다는 에러가 난다. 스크립트가 멈추고 이후 DML 이 실행되지 않는다.

  • PostgreSQL 기준 ADD COLUMN IF NOT EXISTS, MySQL 은 information_schema 를 미리 조회해서 없을 때만 ALTER 를 실행하는 래퍼 프로시저를 쓴다. 코드가 좀 길어지지만 두 번 돌려도 에러가 없다.

  • 인덱스 생성도 마찬가지다. CREATE INDEX IF NOT EXISTS 를 쓰거나, 이름이 같은 인덱스가 이미 있으면 SKIP 하는 조건을 앞에 붙여라. PostgreSQL 14 이상이면 CREATE INDEX IF NOT EXISTS 가 기본 지원된다.

  • DROP COLUMN IF EXISTS 도 챙겨야 한다. 롤백 스크립트에서 빠뜨리면 정방향은 멱등한데 역방향이 깨진다. 우리 팀은 up.sql / down.sql 을 짝으로 리뷰할 때 양쪽 모두 IF EXISTS / IF NOT EXISTS 가 있는지 확인하는 항목을 PR 템플릿에 넣었다.


체크 3 — 트랜잭션 경계를 의도적으로 설계해라

스크립트 전체를 하나의 트랜잭션으로 감싸고 싶은 욕구가 있다. 작은 테이블이면 괜찮다. 수백만 row 면 트랜잭션이 길어질수록 undo log / WAL 이 쌓이고, 중간에 죽으면 롤백에 실행 시간만큼 다시 걸린다.

  • 배치 단위로 COMMIT 을 끊어라. 1000건씩 처리하고 커밋, 실패 시 해당 배치만 재시도. 이미 커밋된 배치는 WHERE 조건(체크 1)이 걸러줘서 중복 실행이 방지된다.

  • 스크립트 시작 시점에 checkpoint 테이블에 run_id, started_at, last_processed_id 를 기록해두면, 재시도 시 어디서부터 이어받을지 알 수 있다. 구현이 귀찮아 보이지만, 500만 건 테이블에서 230만 건 처리 후 죽었을 때 이 테이블 하나가 새벽 2시를 살렸다.

  • 멱등하지 않은 외부 호출(이메일 발송, 웹훅 등)이 마이그레이션 루프 안에 있으면 꺼내라. DB 트랜잭션과 외부 I/O 를 같은 루프에 묶으면 재시도 시 중복 발송이 난다. 분리 못 하면 processed 플래그를 먼저 기록하고 외부 호출을 뒤에 배치하는 순서 제어로 최소화한다.


체크 4 — 실제로 두 번 돌려봐라, 스테이징에서

체크리스트를 다 지켰다고 생각해도 실제로 두 번 돌려야 검증이 된다. 읽고 끄덕이는 것과 직접 실행하는 것은 다르다.

  • 스테이징 DB에 프로덕션 스냅샷을 붓고 스크립트를 1회 실행, 결과 row 수 기록. 2회 실행 후 같은 row 수인지, 에러 없는지 확인. 이 두 줄짜리 검증이 생각보다 많은 것을 잡아낸다.

  • 1회와 2회 사이에 스크립트 실행 시간을 비교해라. 멱등하게 짜여졌다면 2회 실행은 1회보다 현저히 빠르거나 거의 0에 가까워야 한다. 2회가 1회와 비슷하다면 WHERE 조건이 실제로 row 를 걸러내지 못하고 있다는 신호다.

  • CI 파이프라인에 idempotency test 스텝을 넣어두면 리뷰어가 직접 확인 안 해도 된다. make migrate && make migrate 가 모두 exit 0 이면 병합 허용. 우리는 이 스텝을 추가한 뒤 “혹시 두 번 돌려봤어요?” 라는 리뷰 댓글이 사라졌다.


다음 한 가지

다음 마이그레이션 PR 에서 up.sql 두 번 실행 결과를 스크린샷으로 첨부하는 것을 머지 조건으로 넣는다.


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

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