db push 한 번에, 원격에 기록되지 않은 옛 마이그레이션이 통째로 다시 실행됐다
보안 수정을 담은 새 마이그레이션 하나를 적용하려고 npx supabase db push --linked를 실행했다. 평소처럼 별생각 없이 돌렸는데, 이번엔 대기 목록을 확인하지 않고 바로 실행한 게 화근이었다. 결과적으로 이미 예전에 반영됐어야 할 옛 마이그레이션까지 원격 DB에서 통째로 다시 실행됐고, 게임 맵 오브젝트 테이블에 중복 행이 생겼다.
돌이켜보면 이런 실수는 명령어 자체가 익숙해질수록 오히려 생기기 쉽다. db push를 수십 번 문제없이 돌리다 보면 그 명령이 "지금 이 순간 무엇을 실행할지"를 매번 새로 판단하는 게 아니라, 항상 같은 일을 하는 명령처럼 느껴지게 된다. 그러다 어느 한 번, 실제로는 대기 목록이 평소와 다른 상태였는데도 습관대로 확인 없이 실행해버리는 순간이 온다.
무슨 일이 있었나
원격 DB의 마이그레이션 이력 테이블에는 007번과 008번이 기록되어 있지 않은 상태였다. 로컬에는 파일이 있는데 원격 이력에는 없으니, db push 입장에서는 이 둘을 “아직 적용되지 않은 마이그레이션”으로 판단했다. 그래서 내가 새로 추가한 보안 수정 마이그레이션과 함께 007, 008번까지 한 번에 실행해버렸다.
문제는 007번이 멱등하지 않은 시드였다는 점이다. 조건 없는 insert ... values로 작성돼 있어서, 이미 한 번 들어간 행이라도 다시 실행되면 똑같은 행이 또 들어간다. 결과적으로 게임 맵 오브젝트 테이블에 6행이 중복 생성됐다.
멱등성이란 같은 연산을 몇 번 반복해도 결과가 한 번 실행했을 때와 같은 성질을 말한다. update로 특정 값을 정확히 지정하는 연산은 대체로 멱등하다 — 열 번을 실행해도 결과는 같은 값이다. 반면 조건 없는 insert는 실행할 때마다 새 행을 만들어내므로 멱등하지 않다. 한 번만 실행된다는 전제가 깨지는 순간 바로 문제가 드러나는 연산이다. 마이그레이션은 정의상 "한 번만 실행된다"는 보장 위에서 짜여지기 쉽지만, 이번 사고처럼 그 보장 자체가 깨질 수 있다는 걸 전제로 하면 이야기가 달라진다.
복구
다행히 복구는 어렵지 않았다. 이번 db push로 중복 삽입된 행들은 이번 실행 시각의 타임스탬프를 그대로 갖고 있었다. updated_at 컬럼으로 “이번에 새로 들어온 행”을 정확히 골라낼 수 있었고, 그것만 지우면 원본 데이터는 건드리지 않고 정리가 끝났다. 원본 행은 손상되지 않았다.
이 복구가 수월했던 건 순전히 타임스탬프 컬럼이 이미 있었기 때문이다. 만약 이 테이블에 언제 행이 만들어졌는지 남기는 컬럼이 없었다면, 어느 행이 원본이고 어느 행이 이번에 중복 생성된 것인지 구분할 방법이 없었을 것이다. 내용이 완전히 똑같은 행이니 값만 봐서는 구분이 안 된다. 결국 복구 가능성은 사고가 나기 전에 테이블을 어떻게 설계해뒀는지에 이미 좌우된다는 뜻이다.
재발 방지: 실행 전에 눈으로 확인하기
재발 방지책은 단순하다. db push 전에 반드시 npx supabase migration list --linked로 대기 목록을 먼저 확인한다. 무엇이 실행될지 눈으로 보고 나서 실행 여부를 결정한다. 이후 세 개의 마이그레이션을 추가로 적용할 때는 이 절차를 지켜서 문제없이 넘어갔다.
이 사고를 계기로 docs/randchat.md 같은 운영 문서에도 “db push를 함부로 실행하지 말 것”이라는 원칙을 못박아 두었다. 스키마 변경은 SQL 파일을 migrations/에 추가한 뒤 대기 목록을 확인하고 나서 반영하는 흐름으로 굳혔다.
부수적으로 배운 것
복구 과정에서 두 가지를 더 배웠다. 하나는, 프로덕션 DB에서 행을 지우는 작업은 자동화 도구의 안전 분류기에 걸려 막힐 수 있다는 것이다. 파괴적인 작업이니 당연하다면 당연한데, 실제로 겪어보니 삭제 승인 절차 자체를 미리 염두에 두고 작업을 설계해야 한다는 걸 체감했다.
다른 하나는 검증용 트랜잭션이다. 스키마나 트리거가 의도대로 동작하는지 확인하고 싶을 때, 실제로 행을 넣어봐야 확실히 알 수 있는 경우가 있다. 이럴 때 begin으로 시작해서 확인만 하고 rollback으로 끝내면, DB에 흔적이 전혀 남지 않고 별도의 삭제 승인도 필요 없다. 실제로 DB 트리거가 제대로 동작하는지 확인할 때 이 방법을 썼는데, 이후로는 프로덕션 DB에서 뭔가 찔러봐야 할 때 기본으로 쓰는 패턴이 됐다.
두 배움은 사실 같은 방향을 가리킨다. 프로덕션 DB를 다룰 때는 되돌릴 수 있는 방법과 되돌릴 수 없는 방법을 구분해서, 가능하면 항상 되돌릴 수 있는 쪽부터 시도하는 게 맞다. rollback으로 끝나는 트랜잭션은 실행 결과를 실제로 확인하면서도 DB에는 아무 흔적도 남기지 않는, 되돌릴 수 있는 쪽의 가장 확실한 형태다. 반대로 이번 사고처럼 일단 실행되면 되돌리기 어려운 명령은, 실행 전에 무엇이 벌어질지 미리 보여주는 단계를 하나 끼워 넣는 수밖에 없다.
교훈
- 마이그레이션 시드는 처음부터 멱등하게 쓴다.
on conflict do nothing이든 존재 검사든, 두 번 실행돼도 결과가 같게 만든다. “이건 한 번만 실행될 것”이라는 가정은 이력이 어긋나는 순간 그대로 깨진다. - 원격 이력과 로컬 파일 목록은 어긋날 수 있다. 도구가 “아직 적용되지 않았다”고 판단하는 근거는 실제 DB 상태가 아니라 원격 이력 테이블일 뿐이다. 이 둘이 항상 같다고 믿으면 안 된다.
- 되돌리기 어려울 수 있는 명령 앞에는 무엇이 실행될지 미리 보여주는 단계를 반드시 하나 끼워 넣는다.
migration list한 번이면 충분했을 사고였다. - 파괴적인 작업일수록 되돌릴 수 있는 형태로 먼저 확인한다. 실행 결과를 미리 눈으로 보는 단계와, 트랜잭션을
rollback으로 끝내 흔적 없이 검증하는 습관은 결국 같은 원칙의 다른 적용일 뿐이다.