개발 slecs

출금과 잔액복구 사이 동시성 경합, 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

첫 댓글 달아줘.