출금과 잔액복구 사이 동시성 경합, 3곳 통일로 막다
목차
출금 상태전환과 잔액복구 사이에서 발생하던 race condition을 CAS(Compare-And-Swap) 선점 방식으로 통일해 보강했다. 결제 플랫폼에서는 동시에 일어나는 작업들 사이의 경합(race condition)이 잔액 손실이나 중복 복구 같은 심각한 문제로 이어질 수 있는데, 이번 수정은 그런 위험을 3개 지점에서 동시에 차단한 사례다.
문제: 출금-복구 사이의 타이밍 윈도우
일반적으로 결제 시스템에서 출금(withdrawal) 흐름은 이렇다:
1. 사용자 요청 → 출금 상태를 PENDING으로 변경
2. 외부 게이트웨이 호출
3. 성공하면 상태를 COMPLETED로, 실패하면 원래 잔액으로 복구
문제는 상태 변경과 잔액 복구 사이 짧지만 분명한 타이밍 윈도우에서 생긴다. 두 개의 요청이 거의 동시에 같은 거래를 처리하려 할 때:
- 요청 A가 상태를 PENDING으로 변경하고
- 요청 B가 동일한 거래에 대해 잔액을 복구하려 하면
- 상태 확인과 잔액 복구 사이에 요청 A의 상태 변경이 끼어들 수 있다
결과적으로 잔액이 중복 복구되거나, 반대로 필요한 복구가 누락될 수 있다. 특히 결제 플랫폼에서는 이런 1원 단위의 오류도 누적되면 수익 손실이나 사용자 불만으로 이어진다.
해결: CAS 선점으로 원자성 보장
이 fix는 세 곳(withdrawal, payment, utl 관련 모듈)에서 동일한 패턴을 적용했다. 핵심은 상태 확인과 잔액 복구를 한 번의 원자적 연산(CAS)으로 묶는 것이다.
// Before: 상태 확인 → 복구 (사이에 끼어들 여지)
if (withdrawal.getStatus() == PENDING) {
balance.recover(amount); // 중간에 다른 스레드가 상태를 바꿀 수 있음
}
// After: CAS로 원자적 처리
while (!compareAndSwapStatus(withdrawal, PENDING, COMPLETED)) {
// 상태가 PENDING이 아니거나 CAS 실패하면 재시도
if (withdrawal.getStatus() != PENDING) {
break; // 이미 다른 스레드가 처리함
}
}
balance.recover(amount); // 상태 변경 성공 후에만 복구
CAS는 "현재값이 기대값과 같으면 새 값으로 원자적으로 바꾼다"는 보장을 제공한다. 이를 통해 상태 확인과 변경을 한 번에 처리하므로, 다른 스레드가 끼어들 여지가 없다.
왜 3곳을 동시에 수정했는가
같은 로직이 출금(withdrawal), 결제(payment), 유틸리티(utl) 모듈 3곳에 분산되어 있었다. 이는 코드 중복의 일반적인 문제인데, 여기서는 보안/안정성 측면에서 더 심각하다. 한 곳만 수정하면 다른 두 곳은 여전히 경합에 노출되기 때문이다.
| 영역 | 주요 책임 | 영향 |
|---|---|---|
| withdrawal 모듈 | 출금 요청 처리 | 사용자 출금 중복 복구 가능 |
| payment 모듈 | 결제 상태 관리 | 결제 잔액 불일치 |
| utl 모듈 | 공용 잔액 처리 로직 | 모든 거래에 미치는 기초 계층 |
이런 구조에서는 일괄 통일이 필수다. 그렇지 않으면 누군가는 "우리는 withdrawal은 안전한데 왜 payment에서 에러가?"라는 질문에 직면하게 된다.
검증 프로세스의 가치
흥미로운 점은 이 문제가 "codex 8차 검증"에서 지적되었다는 것이다. 이는 정적 분석이나 단위 테스트로는 잘 잡히지 않는 종류의 버그라는 뜻이다. 동시성 관련 버그는 재현이 어렵고, 부하 테스트나 깊은 코드 리뷰 과정에서만 드러난다.
팀 입장에서 보면, 이런 검증 프로세스의 투자가 얼마나 가치 있는지 보여준다. 한두 번의 추가 검증 라운드가 프로덕션 서비스를 보호하는 버그를 찾아낸다. 특히 결제 시스템처럼 정확성이 곧 신뢰도인 도메인에서는 더더욱 그렇다.
회고: 경합 처리의 일관성 전략
이 작업을 마치며 든 생각은, 분산된 코드에서 동시성 처리를 할 때는 애초에 전략을 세워야 한다는 것이다. 새 기능을 추가할 때 "우리는 CAS를 쓸까, lock을 쓸까, 낙관적 잠금을 쓸까"를 미리 정하지 않으면, 시간이 지나면서 패턴이 흩어진다. 그러다 한 곳은 CAS, 한 곳은 synchronized, 한 곳은 database-level lock이 되어버린다.
다음엔 신규 결제 기능을 기획할 때부터 팀과 "동시성 처리 패턴 가이드"를 함께 정의하는 게 낫겠다는 생각이 들었다. 리뷰 시점에 "아, 여기도 CAS 적용하셔야겠네"라고 지적하는 것보다는, 처음부터 "이런 상황에선 CAS 패턴 사용"이 명문화되어 있으면 누군가는 놓치지 않을 테니까.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.