정산 유니크 제약 누락으로 중복 적재되던 문제 수정
목차
로컬에서 Redis 띄워놓고 작업하다 보면 워킹 디렉토리 어딘가에 dump.rdb 가 슬며시 생긴다. 대부분은 눈치채고 스테이징 전에 걷어내는데, 다른 변경이 많은 날 git add . 한 방에 같이 빨려 들어가는 경우가 생긴다. 한 번 히스토리에 박히면 수십 MB 바이너리가 클론마다 따라붙고, 무엇보다 스냅샷 안에 뭐가 들었는지 알 수가 없어서 보안적으로도 찜찜하다.
이번에야말로 못박아두기로 했다.
# 로컬 캐시 스냅샷
dump.rdb
*.rdb
dump.rdb 하나만 넣지 않고 *.rdb 까지 같이 올린 건, 모듈마다 파일명을 살짝 바꿔서 떨어뜨리는 경우가 있어서다. cache.rdb, dev.rdb 같은 식. 어차피 .rdb 가 버전 관리에 들어가야 할 이유는 없으니 한 줄로 다 막는 편이 마음 편하다. 이미 트래킹 중인 파일이 있다면 git rm --cached dump.rdb 로 인덱스에서 제거해줘야 .gitignore 가 실제로 동작한다는 것도 가끔 놓치는 부분이라 팀에 공유해뒀다.
정산 테이블 유니크 제약 누락
진짜 본론은 이쪽이었다. 이커머스 정산을 파트너별, 일별로 집계하는 배치가 있는데, 운영에서 같은 파트너의 같은 일자 레코드가 두 건씩 들어가 있다는 게 잡혔다. 월마감 직전 재집계 잡이 짧은 간격으로 두 번 트리거되면서 정확히 터진 거였다.
원인을 추적해보니 유니크 제약이 파트너 식별자 컬럼 하나에만 걸려 있었다. 일자 컬럼이 UK에 빠진 채로 설계된 것. 아마 초기에는 파트너당 하나의 정산 레코드만 있다는 가정이었을 텐데, 일별 집계로 스펙이 바뀌면서 제약이 따라오지 못한 것 같다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| UK 구성 | 파트너 식별자 단일 | 파트너 식별자 + 정산일자 |
| 중복 차단 방식 | 애플리케이션 레이어 분기 의존 | DB 레벨 차단 |
| 재집계 잡 동시 실행 | 중복 적재 발생 | upsert 로 안전 처리 |
애플리케이션에서 중복을 막는 로직이 있어도 DB 레벨 제약이 없으면 레이스 컨디션 앞에서 결국 뚫린다. 두 트랜잭션이 동시에 SELECT 해서 없음을 확인하고 동시에 INSERT 하면 둘 다 통과해버린다. 정산처럼 금액이 관여되는 테이블에서 애플리케이션 로직만 믿는 건 위험한 베팅이다.
수정 자체는 간단했다. 기존 UK를 드롭하고 복합 UK로 재생성했다.
-- 기존 단일 UK 제거
ALTER TABLE settlement DROP INDEX uq_partner;
-- 파트너 식별자 + 정산일자 복합 UK
ALTER TABLE settlement
ADD CONSTRAINT uq_partner_date UNIQUE (partner_id, settled_date);
UK가 바뀌면 INSERT/UPDATE 패턴도 같이 손봐야 한다. 재집계 잡은 이제 INSERT ... ON DUPLICATE KEY UPDATE 또는 MERGE 방식으로 처리하도록 변경했다. 기존 레코드가 있으면 덮어쓰고, 없으면 새로 넣는다. 이게 붙어야 UK 위반 에러 없이 재집계를 안전하게 돌릴 수 있다.
DDL 분리와 롤백 짝 맞추기
ALTER 문은 별도 파일로 뗐고 파일명에 날짜를 박아뒀다. 이렇게 해두면 운영 반영할 때 누가 언제 만든 변경인지 한눈에 보이고, 롤백 짝 맞추기도 쉽다.
인라인 마이그레이션에 합치면 나중에 추적이 힘들어진다는 걸 예전에 한 번 경험했다. 특히 정산 쪽 DDL은 월마감 전후로 반영 타이밍이 중요한데, 코드 배포와 DDL 반영을 따로 제어할 수 있어야 유연하다. 분리해두면 DDL만 먼저 반영하고 앱 배포는 이후에 할 수 있고, 롤백도 DDL 파일 기준으로 DROP INDEX / ADD INDEX 짝을 명확히 관리할 수 있다.
정산처럼 민감한 도메인일수록 DDL은 무조건 분리하는 편을 고수하고 있다.
한 커밋에 묶은 이유와 되짚기
원래는 두 변경을 나눌까 했는데 둘 다 "쌓여있던 잡일 청소" 성격이라 chore 프리픽스로 묶었다. 기능 추가도, 버그 수정도 아니고 환경 관리와 스키마 정비이다 보니 같은 메시지 아래 두는 게 맥락상 더 자연스러웠다.
되짚어 보면 세 가지 패턴이 명확하다.
- 로컬에서 생기는 산출물은 발견 즉시 ignore에 넣는다. 미루면 결국 한 번은 커밋에 따라 들어간다.
- DB 레벨 제약은 애플리케이션 로직의 보험이 아니라 실질적인 첫 번째 방어선이다. 특히 동시성이 엮이면 로직 레이어만으로는 못 막는다.
- 마감 직전 재집계 같은 엣지 케이스가 평소엔 안 보이던 제약 누락을 끌어올린다. 평시에는 조용히 넘어가던 게 트래픽이나 시나리오가 겹치는 순간 터지는 패턴이 흔하다. DDL 변경은 날짜 박은 별도 파일로 분리해두는 게 운영 반영, 롤백 둘 다 편하다.
작은 커밋이지만 다음 번 마감 때 재집계 잡이 두 번 돌아도 한숨 한 번은 줄어든다.
댓글 0
첫 댓글 달아줘.