한 달 전에 쓴 글의 마지막 문단에 이렇게 적어뒀습니다.
두 경로가 동시에 같은 거래를 처리하지 못하도록 조건부 상태 전이를 사용하겠습니다.
그때는 "다시 설계한다면" 이라는 가정법이었습니다. 한 달 뒤, 같은 계열의 문제를 다른 결제 경로에서 맞았습니다.
이 글은 QR 결제 중계 플랫폼에서 실제로 발생한 거래 1건을 분해한 기록입니다. 진단과 설계까지를 다룹니다 — 실제로 고치는 과정은 후속 글에 따로 적었습니다. 그리고 뒤에서 말씀드리겠지만, 이 버그는 자기 흔적을 스스로 지우기 때문에 피해 규모조차 세기 어렵습니다.
세 줄 요약
- POS 취소 요청이 원장에 남긴 취소 표시를, 1초 뒤 승인 결과 저장이 통째로 덮어썼습니다.
- 원장이 정상 승인 거래로 되살아나면서 망취소 배치의 조회 조건에서 빠졌습니다. 배치는 버그 없이 돌고 있었는데 이 거래를 한 번도 후보로 보지 않았습니다. 카드 승인은 살아남았고 POS만 취소로 알고 있습니다.
- 저는 이 사건을 정확히 반대로 진단했습니다. 로그는 "무엇을 썼는지"를 남기지만 "무엇이 남았는지"는 남기지 않기 때문입니다.
취소했는데 결제가 그대로 남아 있다
CS 문의는 단순했습니다. 매장에서 결제를 취소했는데 카드 명세에 승인이 그대로 있다는 것이었습니다. 이 유형이 월 5건 이상 쌓이고 있었습니다.
거래 하나의 로그를 시각 순으로 펼쳤습니다. 전체가 15초 안에 끝났고, 문제 구간은 그중 1초였습니다.
최종 상태는 이렇게 갈렸습니다.
| 주체 | 최종 인식 |
|---|---|
| 카드 승인 중계 | 승인 유지 — 취소되지 않음 |
| 간편결제사 | 정상 결제로 종결 |
| POS | 취소 완료로 인지 |
| 결제 서버 원장 | 승인 완료 — 취소 요청이 있었다는 사실이 남아 있지 않음 |
배치는 버그가 없었습니다. 이 거래를 안 봤을 뿐입니다
이 플랫폼에는 이미 망취소 배치가 있습니다. 주기적으로 취소 대상 거래를 찾아 카드 승인을 취소합니다. 바로 이런 상황을 수습하려고 만든 장치입니다.
배치 코드를 열었습니다. 아무 문제가 없었습니다. 조회도 정상이고 취소 요청도 정상이고 실패 처리도 정상이었습니다. 그날 다른 거래들은 잘 취소하고 있었습니다.
문제는 조회 조건이었습니다. 취소 대상 조회는 두 갈래인데, 되살아난 원장은 둘 다 만족하지 않았습니다.
| 조회 | 핵심 조건 | 되살아난 원장 |
|---|---|---|
| 조회 1 | 단계가 취소 계열 · 상태 ≠ 완료 · 승인번호 존재 | 단계가 승인 → 불일치<br>상태가 완료 → 불일치 |
| 조회 2 | 단계 조건 없음 · 상태 ≠ 완료 · 승인번호 존재 | 상태가 완료 → 불일치 |
두 조회 모두 "이 거래는 아직 정상적으로 안 끝났다" 는 신호를 원장에서 찾습니다. 그런데 그 신호가 지워졌습니다.
처음에는 배치 주기가 문제라고 생각했습니다. 이전 개선에서 한 번 주기를 줄여 효과를 본 적이 있어서 손이 그쪽으로 갔습니다. 그런데 주기를 아무리 줄여도 소용이 없습니다. 늦게 도착해서 못 잡은 게 아니라, 몇 번을 돌아도 후보 목록에 없기 때문입니다.
조회 조건을 넓히는 것도 답이 아니었습니다. 넓히려면 "정상 승인 거래"와 "취소됐어야 하는 거래"를 구분할 근거가 있어야 하는데, 원장에서 그 근거가 이미 지워졌습니다. 둘은 완전히 똑같이 생겼습니다.
덮어쓰기가 지운 건 컬럼 하나가 아니었습니다
두 경로가 같은 행을 씁니다.
| 경로 | 쓰기 전에 확인하는 조건 |
|---|---|
| 승인 결과 저장 | 없음 |
| POS 취소 마킹 | 거래가 진행 중일 때만 |
승인 처리 흐름은 진입할 때 거래를 한 번 읽어서 끝까지 들고 다닙니다. 저장 시점에 그 객체를 그대로 넘기면 ORM은 모든 컬럼을 포함한 UPDATE를 만듭니다. 그 사이에 다른 경로가 같은 행을 바꿨는지는 확인하지 않습니다.
취소 마킹: 읽기 → "진행 중" (승인 저장이 아직 커밋 전)
취소 마킹: 쓰기 → 취소 표시
승인 저장: 쓰기 → 승인 완료 ← 취소 표시가 덮여 사라짐
통지 발송: 원장이 승인 완료이므로 그대로 발송
교과서적인 Lost Update입니다. 읽은 시점의 상태로 판단하고, 쓰는 시점에 그 판단 근거가 아직 유효한지 확인하지 않습니다.
여기까지는 흔한 이야기입니다. 이 사례가 특별한 건 덮어쓰기가 지운 것이 무엇이었는가 입니다.
보통 Lost Update를 이야기할 때는 "A의 수정이 사라졌다"로 끝납니다. 잔액이 틀리거나 카운터가 안 맞거나. 그런데 여기서 사라진 컬럼은 자동 복구 장치가 대상을 식별하는 유일한 근거였습니다. 그 값이 사라지는 순간 이 거래는 안전망의 관측 범위 밖으로 나갔고, 그 시점부터 자동으로 복구될 방법이 존재하지 않게 됐습니다.
한 컬럼을 잃은 게 아니라 복구 경로를 통째로 잃었습니다. 그리고 그 사실은 어디에도 기록되지 않습니다.
저는 이 사건을 반대로 읽었습니다
처음 분석했을 때 저는 이렇게 결론 냈습니다.
원장에 취소 표시가 남았고, 배치가 그걸 잡아 카드는 취소했다. 다만 그전에 간편결제사에 성공 통지가 나가버려서 지갑 쪽 종결은 되돌리지 못했다.
정반대였습니다. 카드는 취소되지 않았고, 배치는 이 거래를 보지도 못했습니다.
왜 반대로 읽었는가가 이 글에서 제일 값진 부분이라고 생각합니다.
근거로 삼은 게 취소 처리기의 로그였습니다. 거기엔 "원장을 취소 표시로 갱신했다"가 또렷이 찍혀 있었습니다. 저는 그걸 보고 원장의 최종 상태가 취소 표시라고 읽었습니다.
그런데 그 로그가 말하는 건 그게 아닙니다.
로그는 "내가 무엇을 썼는가" 를 남깁니다. 원장은 "무엇이 남았는가" 를 담습니다. 경합이 없을 때 둘은 같습니다. 경합이 있을 때만 갈라집니다.
그리고 우리가 로그를 뒤지는 상황은 대부분 경합이 의심될 때입니다. 하필 둘이 갈라지는 바로 그 상황에서, 저는 둘을 같은 것으로 읽었습니다.
이게 왜 위험하냐면, 이 오진이 그럴듯하기 때문입니다. 반대로 읽은 시나리오도 코드상 실제로 가능한 순서였고, 증상도 설명이 됐고, 개선안도 나왔습니다. 틀렸다는 신호가 어디에도 없었습니다. 실무자가 "그거 취소 안 됐는데요"라고 짚어주기 전까지 저는 제가 맞다고 믿고 있었습니다.
경합 사건을 분석할 때 로그의 쓰기 기록과 최종 저장 상태를 반드시 따로 확인해야 한다는 걸 이렇게 배웠습니다. 두 값이 다르면 그 차이 자체가 경합의 증거입니다.
가드를 어디에 둘 것인가 — 순서가 있습니다
이 흐름에서 서버는 외부 효과를 두 번 일으킵니다. 카드 승인과 간편결제사 통지입니다. 사후 수습 관점에서 성질이 다릅니다.
| 외부 효과 | 사후 보상 | 조건 |
|---|---|---|
| 카드 승인 | 가능 — 취소 거래로 상쇄 | 배치가 그 거래를 찾을 수 있어야 함 |
| 간편결제사 통지 | 불가능 — 상대가 이미 종결 | — |
오른쪽 열이 이번에 배운 것입니다. "되돌릴 수 있다"는 성질은 그냥 주어지지 않습니다. 카드 승인이 되돌릴 수 있는 효과인 건 원장이 사실을 기억하고 있을 때뿐이고, 원장이 되살아나는 순간 그 전제가 조용히 깨집니다.
그래서 방어는 두 층이고, 순서가 있습니다.
①이 없으면 ②는 아무것도 막지 못합니다.
이건 제가 초안에서 정확히 반대로 적었던 부분이라 강조하고 싶습니다. 처음엔 ②가 핵심 방어선이라고 봤습니다. 되돌릴 수 없는 호출 바로 앞이니까요. 그런데 사건을 바로 읽고 나니 ②만으로는 소용이 없습니다. 통지 시점의 원장은 이미 승인 완료로 되살아나 있어서 검사를 그대로 통과합니다. ①이 전이를 거부해서 원장에 취소 표시가 남아 있어야, 그제야 ②가 볼 게 생깁니다.
②의 판정을 화이트리스트("승인 완료일 때만 통지")로 잡은 것도 같은 이유입니다. 블랙리스트("취소 계열이면 차단")는 열거하지 못한 상태에서 통지가 나가버립니다 — 되돌릴 수 없는 쪽으로 실패합니다. 화이트리스트는 오차단이 나도 결과가 "통지 안 감 → 배치가 카드 취소"라 되돌릴 수 있는 쪽으로 실패합니다.
한 가지 더. ①·② 모두 저장소에서 다시 읽어야 합니다. 처리 중 메모리에 들고 있는 거래 객체는 승인 저장이 이미 승인 완료로 바꿔놨기 때문에, 그걸 보면 언제나 통과합니다. 검사를 넣었는데 아무것도 안 막히는 코드가 되기 딱 좋습니다.
자동 취소를 태울 것인가 — 아직 못 정했습니다
가드를 넣으면 "진행 중에 상태가 무효로 바뀐 거래"를 감지할 수 있게 됩니다. 그다음이 남습니다.
| 안 | 동작 | 리스크 |
|---|---|---|
| A. 감지만 | 중단하고 경보. 취소는 사람이 판단 | 미결 거래가 쌓임 |
| B. 조건부 자동 | 취소 의도가 원장에 명시된 건만 자동 취소 | "명시적 의도"의 정의가 필요 |
| C. 전면 자동 | 진행 중이 아닌 모든 상태를 실패로 간주 | 정상 거래를 취소할 위험 |
C가 깔끔해 보이고 같은 플랫폼의 다른 결제 경로는 이미 그렇게 동작합니다. 그런데 이 플랫폼에는 C의 대가를 치른 기록이 있습니다. 이전 개선에서 배치의 취소 기준을 잘못 잡아 정상 승인 거래 1건을 자동 취소했고, 가맹점 매출이 사라져 회사가 보상했습니다.
그때 배운 건 과취소와 미취소가 대칭이 아니라는 것이었습니다. 미취소는 흔적이 남아 나중에 수습할 수 있고, 과취소는 이미 끝난 정상 거래를 파괴합니다.
…라고 쓰려다 멈췄습니다. 이번 사례는 미취소인데 흔적이 안 남았습니다. 제가 알던 비대칭이 이번엔 성립하지 않았습니다. B로 기울어 있지만, 이 판단은 "미취소로 인한 CS"와 "과취소로 인한 CS" 중 무엇을 감수할지의 문제이고 실제 CS 분포를 아는 쪽은 운영입니다. 지금은 결론 없이 열어뒀습니다.
고칠 것보다 찾을 것이 어렵습니다
이 버그의 마지막 성질이 여기 있습니다. 피해 거래를 원장에서 찾을 수 없습니다.
원장에는 정상 승인 거래로 남아 있습니다. 취소 요청이 있었다는 사실은 어디에도 없습니다. "취소 계열 상태인데 승인번호가 있는 거래"로 훑으면 한 건도 안 나옵니다. 실제로 이 조건으로 소급 조사를 계획했었고, 사건을 바로 읽고 나서야 그게 무의미하다는 걸 알았습니다.
찾으려면 POS 취소 요청 로그를 기준으로 역추적해야 합니다. 원장이 아니라 요청 로그가 유일한 증거입니다. 그리고 그 로그의 보관 기간을 넘긴 거래는 영영 알 수 없습니다.
지금까지 이야기한 월 5건은 고객이 문의를 준 건입니다. 금액이 작았거나 명세를 안 본 사람은 세지 않았습니다. 실제 숫자는 이것보다 클 텐데, 지금 구조로는 얼마나 큰지 알 방법이 없습니다.
정리 중인 후속 작업은 이렇습니다.
- 조건 없이 거래 상태를 덮어쓰는 지점 전수 조사. 이번 두 곳 말고 몇 곳이 더 있는지 아직 모릅니다.
- 상태 전이 규칙을 한 곳에 모으고, 허용되지 않는 전이는 저장 계층에서 거부. 개별 호출부의 성실함에 기대는 구조를 없애는 것이 목적입니다.
- 요청 로그 기준 소급 추출, 그리고 아직 승인이 살아 있는 건의 처리 판단.
한 달 전 글의 마지막 문단으로 돌아갑니다. 그때 "조건부 상태 전이를 쓰겠다"고 적어둔 대상은 다른 두 경로였습니다. 이번에 터진 건 또 다른 두 경로입니다. 대상만 달랐지 조건 없는 덮어쓰기가 두 곳에 있으면 순서가 결과를 정한다는 구조는 똑같았습니다.
회고에 적어둔 개선안을 그 자리에서만 적용하고, 같은 형태를 다른 곳에서 찾아보지 않았습니다. 회고는 그 사건에 대한 기록이 아니라 같은 형태를 찾아 나서라는 지시로 읽었어야 했습니다.
Related
- 고친 걸 되돌려도 테스트는 초록이었다 — 이 글의 후속. 여기서 정한 설계를 실제로 구현하면서, 내가 쓴 계획서의 테스트 다섯 개가 아무것도 지키지 않고 있었다는 걸 알게 된 기록
- 최종 결제 확정 API로 POS 상태 불일치 해소하기 — 같은 플랫폼의 상태 불일치 개선. 이 글에서 언급한 과취소 사고와 "조건부 상태 전이" 회고의 원문
- 환불 로직을 공통으로 끌어올릴 때 밟은 함정들 — 같은 취소·환불 경로의 구조 개선
- 동작한다는 것이 올바르다는 뜻은 아니다 — 각자 정상 동작하는 코드가 합쳐져 틀리는 경우
