환불이 두 번 됐을까 걱정한 사용자, 실은 한 번도 안 됐습니다
어떤 B2B를 운영하던 중, 사용자에게서 우려 섞인 보고가 하나 들어왔습니다. 관리자 화면에서 테이크아웃 주문을 취소하려는데 버튼이 바로 반응하지 않아 같은 취소를 두 번 빠르게 클릭했다는 것입니다. 걱정의 핵심은 '내 포인트가 두 번 환불된 것 아니냐'였습니다.
이중 환불은 결제 도메인에서 실제로 흔한 사고입니다. 그래서 곧바로 DB를 열어 해당 회원의 포인트 잔액과 환불 로그를 확인했습니다. 그런데 예상과 정반대의 그림이 나왔습니다. 잔액은 두 번은커녕 한 번도 늘지 않았습니다.
사용자가 걱정한 문제(이중 환불)는 애초에 존재하지 않았고, 진짜 문제는 그 반대편에 있었습니다. 취소는 정상 처리됐는데 포인트 환불이 아예 일어나지 않은 상태였습니다. 사용자의 우려 보고가 엉뚱해 보였지만, 그 덕분에 훨씬 큰 버그의 실마리를 잡은 셈입니다.
같은 취소인데 채널마다 결과가 달랐다
원인을 추적하니 취소 경로가 두 갈래라는 점이 드러났습니다. 회원 본인이 앱에서 직접 주문을 취소하는 경로와, 관리자가 운영자 화면에서 주문 상태를 바꿔 취소하는 경로입니다. 두 경로 모두 사용자 입장에서는 똑같은 '주문 취소'였습니다.
문제는 이 둘의 내부 로직이 어긋나 있었다는 것입니다. 회원 앱 취소 경로에는 취소 시 포인트를 되돌려주는 환불 로직이 붙어 있었지만, 관리자 취소 경로에는 상태를 CANCELLED로 바꾸는 처리만 있고 환불 호출이 빠져 있었습니다. 한쪽만 구현되고 다른 쪽은 누락된, 전형적인 채널 비대칭이었습니다.
이런 누락은 유난히 자주 발생합니다. 같은 도메인 로직을 두 진입점에 각각 구현하다 보면, 처음 만든 쪽은 꼼꼼히 챙기고 나중에 붙인 쪽은 상태 전환만 처리하고 환불 같은 부수 효과를 잊는 일이 잦습니다. 코드가 두 곳으로 갈라진 순간부터 어긋날 여지가 생긴 것입니다.

관리자 취소에 환불을 붙이되 두 번은 안 되게 막았다
조치의 방향은 명확했습니다. 관리자 취소 경로에도 회원 앱과 동일한 환불 처리를 붙이는 것입니다. 관리자 상태변경 로직에 환불 호출을 추가해, 취소된 주문 라인의 포인트만큼 회원 포인트를 다시 적립하고 환불 로그를 남기도록 했습니다. 다만 관리자 화면은 주문 전체가 아니라 라인별 부분 취소도 지원하므로, 그에 맞춰 라인 단위 부분 환불까지 처리하도록 했습니다.
여기서 사용자가 원래 걱정했던 중복 클릭 문제를 같이 막아야 했습니다. 환불을 붙이기만 하면, 이번엔 진짜로 두 번 클릭했을 때 환불이 두 번 나갈 수 있기 때문입니다. 그래서 환불을 상태 전환에 묶었습니다. 주문 상태가 RESERVED에서 CANCELLED로 '처음' 바뀌는 그 순간에만 환불을 실행하도록 한 것입니다.
직전 상태를 확인해 이미 CANCELLED인 주문에 다시 취소 요청이 들어오면, 상태 전환이 일어나지 않으므로 두 번째 환불 호출은 그대로 건너뜁니다. 이것이 멱등 가드입니다. 같은 요청이 몇 번을 들어와도 실제 환불은 최초 전환 1회로 고정됩니다.
멱등성의 본질은 '요청을 몇 번 받았는가'가 아니라 '상태가 실제로 전환됐는가'를 기준으로 부수 효과를 딱 한 번만 흘리는 것입니다.

이미 누락된 과거 건은 데이터로 되돌렸다
코드를 고쳐도 이미 새어 나간 과거 데이터는 그대로 남아 있습니다. 관리자 채널로 취소됐지만 환불되지 않은 주문이 얼마나 되는지 찾아야 했습니다. 기준은 명확했습니다. 주문이 취소 상태이고 포인트 사용 로그는 있는데 그에 대응하는 환불 로그가 없는 건입니다.
이 조건으로 조회하니 누락 건은 2건이었습니다. 각각 1000P, 2000P가 사용됐지만 되돌려지지 않은 상태였고, 해당 회원의 잔액은 0P에 멈춰 있었습니다. 이 두 건에 환불 로그를 넣고 잔액을 0P에서 3000P로 복구했습니다.
복구에서 신경 쓴 지점은 세 가지였습니다.
잔액 정합: 각 환불의 before/after 잔액이 순서대로 이어지도록 맞춰, 나중에 잔액 변동을 역추적해도 앞뒤가 맞게 했습니다.
식별 가능한 사유: 복구 로그에 '관리자 누락분 보정'이라는 사유를 명시해, 정상 환불과 사후 보정 건을 구분하고 감사 때 추적할 수 있게 했습니다.
사후 검증: 복구 후 사용 로그와 환불 로그가 다시 짝을 이루는지 재검증해, 남은 누락이 없는지 확인했습니다.
데이터 복구는 잘못 건드리면 오히려 손상을 키웁니다. 그래서 반드시 사전 백업을 떠 두고, 복구 후에도 짝 검증으로 결과를 확인하는 절차를 지켜야 합니다.
멱등성과 중복 클릭은 다른 자리에서 막아야 한다
백엔드에서 멱등 가드로 환불이 한 번만 나가도록 막았지만, 그것과 별개로 프론트에도 방어를 뒀습니다. 백엔드가 두 번째 요청을 skip하더라도, 처리 중인 버튼을 그대로 두면 사용자는 여전히 '반응이 없다'며 계속 누르게 되기 때문입니다.
처리 중인 주문 id를 집합으로 관리해, 같은 주문이 처리 중이면 추가 클릭을 무시하고 버튼을 '처리중...' 상태로 비활성화했습니다. 중요한 건 전역 락이 아니라는 점입니다. 처리 중인 그 주문의 버튼만 잠기고, 다른 주문의 취소 버튼은 정상적으로 동작합니다.
멱등 가드가 데이터 정합을 지키는 방어라면, 중복 클릭 비활성화는 사용자 경험을 지키는 방어입니다. 둘은 목적이 다르므로 어느 하나로 다른 하나를 대체할 수 없습니다. 한쪽은 돈이 두 번 나가는 것을 막고, 다른 한쪽은 사용자가 불안해서 계속 누르는 상황을 막습니다.
이 사건에서 남길 것들
이번 건에서 반복해서 쓸 수 있는 원칙 몇 가지를 정리했습니다.
같은 도메인의 두 채널이 서로 다른 로직을 갖고 있으면 의심한다. 한쪽을 만들고 다른 쪽에서 부수 효과를 빠뜨리는 일이 흔하다.
사용자의 우려 보고는 문자 그대로가 아니라 실마리로 읽는다. '이중 환불' 우려가 '환불 누락'이라는 진짜 버그를 드러냈다.
멱등은 요청 횟수가 아니라 상태 전환을 기준으로 건다. 최초 전환에서만 부수 효과를 흘린다.
데이터 복구는 식별 가능한 사유를 남기고, 사전 백업과 사후 검증을 필수로 둔다.
데이터 정합 방어와 UX 방어는 별개 자리에 각각 둔다.
다만 채널 비대칭이 항상 버그인 것은 아닙니다. 권한에 따라 두 채널이 의도적으로 다른 로직을 가져야 하는 경우도 있습니다. 그래서 비대칭을 발견하면 곧장 '버그'로 단정하기 전에, 그 차이가 의도된 것인지부터 확인하는 편이 안전합니다.
이런 결제·포인트 일관성 문제는 결국 '같은 도메인 규칙을 여러 군데서 각자 지키지 않게 한 군데로 모을 수 있는가'로 귀결됩니다. 회원 앱과 관리자의 환불 로직이 사실상 중복 코드였던 만큼, 앞으로는 공통 모듈로 추출해 두 채널이 같은 규칙을 공유하도록 만드는 편이 근본 처방에 가깝습니다.
마지막으로, 같은 비대칭이 다른 기능에도 숨어 있을 수 있어 라운지 예약 취소 경로에도 동일한 환불 누락이 없는지 점검 목록에 올렸습니다. 한 곳에서 이런 결함이 나왔다면, 비슷한 구조를 가진 다른 곳도 같은 병을 앓고 있을 가능성이 높기 때문입니다.