포인트 기능은 실제 금전을 다루기에 버그가 발생했을 때 빠르게 대응하는 것이 중요합니다. 이 글에서는 실제로 포인트 기능을 운영하며 마주친 문제와 해결 전략에 다룹니다.
Lot 동시성 문제
저는 전편에서 언급한 포인트의 불변식이 실제로 지켜지고 있는지 확인하기 위해 매일 새벽 4시에 모든 Wallet을 순회하며 불일치를 검사하는 스케줄러를 운영했습니다. 만약 불일치가 감지되면 곧바로 사내 Slack 채널로 통보가 가도록 해두었습니다.
그리고 해당 알림이 실제 production 환경에서 와버렸습니다.

문제 상황
DB를 확인해보니 해당 유저는 두 건의 결제를 진행한 상태였고, 각 결제마다 적립 예정으로 만들어진 Lot이 두 개 모두 PENDING 상태로 남아 있었습니다. 그리고 운영자가 두 건의 결제를 거의 동시에 취소 처리하면서, 두 PENDING Lot에 대한 void 트랜잭션이 동시에 진행되었습니다.
서로 다른 lot을 다루는 별개의 트랜잭션이었지만, 둘 다 동일한 Wallet의 적립 예정 포인트를 갱신하는 과정에서 race condition이 발생하여 Wallet의 pendingPoints가 두 Lot 합산과 어긋나게 된 것이었습니다.
해결
당시 코드는 Wallet에는 비관적 락을 걸고 있었지만 Lot 자체에는 별도의 락을 걸지 않고 있었습니다. 두 void 트랜잭션은 각자 Wallet 락을 잡고 풀었기에 wallet 갱신 자체는 직렬화되었지만, 그 사이 lot 상태 변경 시점이 어긋나며 wallet의 pending 잔액이 정확히 계산되지 않았던 것입니다.
이를 해결하기 위해 lot 상태가 변경되는 모든 경로에서 lot 자체에도 비관적 쓰기 락을 획득하도록 변경했습니다. 또한 락 획득 순서는 Wallet → Lot으로 통일하여 데드락 가능성도 함께 차단했습니다.
Lot 원본 복원의 한계
이번 오류는 production 환경에서 발생하지는 않았지만, QA 단계에서 발견한 엣지 케이스 중 하나로 흥미로워 가져오게 되었습니다.
문제 상황
아래 시나리오를 고려해보겠습니다.
1. Lot A: 100P 적립
2. 100P 전액 사용 → Lot A: EXHAUSTED (잔여 0)
3. 관리자가 적립 자체를 취소 → Lot A: REVERSED + 지갑에 debt 100 발생
4. 이후 결제 환불 발생 → 사용했던 100P를 돌려줘야 함
여기서 4번 단계에 문제가 발생합니다. 초기 설계에서는 환불이 일어날 시 소비했던 원본 lot을 찾아서 소비된 만큼 다시 복원하도록 되어 있었습니다. 그러나 위와 같은 시나리오에서는 lot 자체가 적립이 취소 되었기에 100P를 귀속시킬 lot이 존재하지 않습니다.
기존 코드에서는 이런 경우 원본 lot이 EXPIRED이거나 REVERSED이면 복원 대신 만료 처리한다는 분기로 처리하고 있었습니다. 그러나 이럴 경우 3번에서 적립 취소로 이미 debt 100이 잡혀 있고 4번에서 사용 취소분 100마저 만료 처리되기에 사용자는 이중 패널티를 받게 되었습니다.
해결
결국 restore 방식에 한계가 있다는 것을 깨닫고 환불을 Lot A의 복원이 아니라 새로운 Lot B의 발급으로 재설계했습니다.
환불 발생
→ 원본 lot의 expireAt을 승계한 새 Lot B 생성 (status = AVAILABLE)
→ 만료일이 이미 지났으면 → 생성 즉시 expire 처리
이를 통해 원본 lot의 상태를 전혀 고려하지 않아도 되고 lot의 상태 전이도 완전한 단반향으로 바뀌게 되며 코드가 매우 예측 가능하게 변했습니다.
PENDING ──→ AVAILABLE ──→ EXHAUSTED
│ │
└→ VOIDED ├→ EXPIRED
└→ REVERSED
지금까지 제가 실제로 겪었던 문제상황들과 해결전략들에 대해 알아보았습니다. 추후 다른 버그를 맞닥뜨리게 되면 추가로 작성해보도록 하겠습니다.