문제
정산 원장은 주문 옵션이 출고 완료, 구매 확정, 환불 완료 같은 상태에 닿을 때마다 그때 금액을 한 행씩 쌓는 기록인데, (옵션, 상태) 조합에 유니크 키가 없어 같은 기록이 두 번 쌓일 수 있었습니다.
2026년 9월 14일 표본에서 중복 묶음 2천여 건을 찾았고, 출고 완료가 약 44%, 구매 확정이 약 35%, 환불 완료가 약 21%였습니다.
중복 한 쌍마다 그 옵션의 월 정산 합계가 2배로 부풀려졌습니다.
봇이 찾은 중복을 사람이 승인해 지워도 2시간이 안 돼 다시 생겼고, 그 뒤에도 매일 두 자릿수 규모로 재발했습니다.
2020년 1월에도 쿼리 수정으로 한 번 막았지만, 그 뒤 쓰기 경로가 늘며 다시 생겼습니다.
그래서 문제를 쓰기 경로 하나하나를 막는 일이 아니라, “한 옵션의 한 상태는 원장에 한 행만 있다”는 규칙을 원장이 지키게 하는 일로 정했습니다.
접근
- 원인 1(출고 완료): 두 서비스가 서로 다른 Redis 인스턴스의 서로 다른 잠금을 잡고 같은 행이 있는지 확인한 뒤 거의 동시에 넣었고, 커밋 전이라 서로의 행을 보지 못했습니다. 4일 치 로그에서 두 서비스가 밀리초 단위로 같은 행을 각각 넣은 기록을 수십 건 확인했고, 두 서비스가 쓰는 상태 일자 기준도 달라 값까지 어긋났습니다.
- 원인 2(환불 완료): 해외 파트너 한 곳의 레거시 PHP 코드가 커밋 전에 환불 완료 이벤트를 보내, 받는 쪽이 아직 커밋되지 않은 행을 보지 못하고 한 번 더 넣었습니다. 이 경로는 그 파트너 채널에만 있어 다른 10여 개 국가에서는 0건이었습니다.
- 원인 3(교환 보정): 옵션 교환이 원본 행 금액을 고치는 시점이 파생 행을 만들기 전이냐 뒤냐에 따라, 같은 사건인데 값이 다른 행이 남았습니다.
- 대안: 처음 초안은 쓰기 지점 20여 곳 가운데 대부분을 끄고 정산 쪽 한 곳만 쓰게 하는 안이었고, 가드 테이블, 공통 행 잠금, 이름 잠금, 잠금 읽기, 부분 유니크, 트리거, 늦은 정리 배치, Redis 공유 잠금 등 12개가 넘는 대안을 검토했습니다. 잠금 읽기는 갭 잠금 교착 위험 때문에 뺐습니다.
- 최종 결정표는 7개 안을 원장에서 중복 차단 30%, 정산 값 보존 20%, 운영 단순성 15%, 구현·전환 부담 15%, 재처리 안전성 10%, 수작업 감소 10%로 매겼습니다. 값 통일 후 원장 유니크 키는 구현·전환 부담에서만 −1, 운영 단순성 0, 나머지 넷은 +1로 +0.55였고, 쓰기 주체 통합은 운영 단순성과 구현·전환 부담에서 모두 −1이라 +0.40이었습니다. 원장은 천만 행이 넘어 인덱스 추가에 사전 승인이 필요한 테이블입니다.
- 값 통일 후 원장 유니크 키+0.55
- 쓰기 주체 통합+0.40
- 가드 테이블+0.10
- 공통 행 잠금+0.10
- 커밋 후 발행+0.05
- 현행 유지0.00
- 주기적 정리-0.05
각 가중치를 5%p씩 흔든 141가지 조합에서 1위는 바뀌지 않았습니다. 2026년 9월 기준 제안 단계입니다.
- 민감도: 각 가중치를 5%p씩 흔든 141가지 조합에서 1위가 바뀌지 않았고, 1·2위 차이는 가장 작을 때 0.10이었습니다. 쓰기 주체 통합이 준비돼 운영 부담이 사라지면 두 안이 +0.55로 같은 점수가 된다는 조건도 적었습니다.
결정
- 모든 생성 경로 20여 곳의 정산 값, 상태 일자, 검증 상태를 만드는 규칙을 먼저 통일하고, 과거 중복과 오류 행을 정리한 뒤, (옵션, 상태) 유니크 키를 모든 상태에 둡니다. 환불 완료도 “옵션당 클레임 1건”이라는 코드 전제를 확인해 포함했습니다.
- 원장이 스스로 중복을 거부하므로, 쓰기 경로가 늘어도 같은 문제가 다시 생기지 않습니다.
결과
- 2026년 9월에 해결안과 실행 순서를 ADR로 제안했습니다.
- 검증: 141가지 가중치 조합으로 결론이 버티는지 확인했고, 결정표 문서는 따로 검토 단계를 거쳐 논리가 맞는지 확인받았습니다.
- 제안 시점에는 봇으로 찾아 지우는 정리를 되풀이했고, 제안대로 되면 원장이 두 번째 기록을 거부합니다.
기록과 자동화
- 문서: ADR 2건(채택하지 않은 초안, 현재 제안), 민감도 분석을 담은 결정표 문서, 값 통일·충돌 처리·과거 데이터 정리 순서를 적은 실행 항목, 이 페이지의 가중치 조정 표를 남겼습니다.
배운 점
- 중복은 쓰는 쪽 여러 곳에서 막기보다, 저장하는 곳이 스스로 거부하게 만드는 방법을 먼저 봅니다.
- 상황: 중복을 찾아 지워도 2시간이 안 돼 다시 생겼고, 쓰기 경로는 여러 서비스의 20여 곳에 걸쳐 있었습니다.
- 한 일: 모든 생성 경로의 값을 먼저 통일한 뒤 원장에 유니크 키를 두는 안을 제안했습니다.
- 결과: 쓰기 경로가 늘어도 같은 문제가 다시 생기지 않는 구조를 제안했습니다.
- 가중 결정표는 가중치를 흔들어도 결론이 버티는지 확인하고, 결론이 바뀌는 조건도 함께 적습니다.
- 상황: 대안 7개를 기준 6개의 가중 결정표로 비교했습니다.
- 한 일: 각 가중치를 5%p씩 흔든 141가지 조합을 모두 확인하고, 쓰기 주체 통합이 준비되면 두 안이 +0.55로 같은 점수가 된다는 조건을 적었습니다.
- 결과: 141가지 조합 모두에서 1위가 바뀌지 않았고, 1·2위 차이는 가장 작을 때 0.10이었습니다.
- 원인은 코드를 읽고 추정하는 데서 멈추지 않고, 로그에서 실제로 겹친 기록을 확인한 뒤 정합니다.
- 상황: 같은 정산 기록이 두 번 쌓이는 원인을 정해야 했습니다.
- 한 일: 여러 서비스의 코드 근거를 조사하고, 4일 치 로그에서 두 서비스가 밀리초 단위로 같은 행을 각각 넣은 기록을 확인했습니다.
- 결과: 서로 다른 잠금을 잡은 두 서비스의 경합과, 커밋 전에 보낸 이벤트라는 원인을 특정했습니다.