개발 slecs

출금 승인 실패도 동시성 제어로 정합성 보장

목차

출금 일괄 승인 기능에서 성공 경로와 실패 경로의 동시성 제어 방식을 통일했다. 구체적으로, 실패 분기에서도 CAS(Compare-And-Swap) 기반의 선점-복구(optimistic locking & rollback) 패턴을 적용해서 데이터 정합성을 동일 수준으로 보장하도록 정정한 작업이다.

왜 이 문제가 생겼나

결제 플랫폼의 출금 승인은 대량 건을 일괄 처리하는 배치성 작업이다. 시스템 설계 초기에 성공 경로는 CAS 패턴으로 동시성을 제어했는데, 예외·실패 처리는 다른 방식으로 구현되어 있었다. 예를 들어 부분 실패, 타임아웃, 중복 요청 같은 에지 케이스에서는 기존 전략이 누락되어 있었던 것.

이런 불일치는 언뜻 사소해 보이지만, 실제로는 심각한 결과를 초래한다:
- 경쟁 조건(race condition): 동시에 같은 출금을 승인하려는 요청이 오면, 성공 분기는 보호되지만 실패 분기는 그렇지 않을 수 있다.
- 더블 처리: 부분 실패 후 재시도할 때, 데이터가 중간 상태에 머물면서 잘못된 계산이 누적될 수 있다.
- 정산 오류: 최종적으로 출금액이 중복 차감되거나 손실될 가능성.

이런 문제는 고주파 거래나 야간 자동화 배치에서 아주 드물게 발생하지만, 발생하면 비용이 크다.

CAS 패턴으로 통일한 접근

동시성 제어의 핵심은 "누가 먼저 업데이트를 완료했는지"를 판단하는 것이다. CAS 패턴은 이를 버전 번호나 타임스탬프로 추적한다:

// 의사코드: 성공/실패 분기 모두 동일 패턴
boolean processWithdrawal(Withdrawal w, long expectedVersion) {
  // 1. 현재 버전 확인 (선점)
  long currentVersion = getVersion(w);

  if (currentVersion != expectedVersion) {
    // 누군가 이미 처리했음 → 복구 로직으로 통일
    return rollback(w, currentVersion);
  }

  // 2. 업데이트 시도
  boolean success = approve(w);  // 성공 또는 실패

  // 3. 버전 증가
  incrementVersion(w);

  return success;
}

핵심은 성공과 실패 모두에서:
1. 버전을 먼저 확인 (낙관적 잠금)
2. 예외 상황에서 복구 로직 실행 (버전 불일치 감지 시)
3. 연산 결과와 무관하게 추적 상태 업데이트 (재진입 방지)

이렇게 하면, 같은 요청이 여러 번 들어와도, 또는 부분 실패가 발생해도 시스템이 "이미 처리됨"을 감지하고 중복 처리를 피할 수 있다.

회고: 대칭성의 중요성

이번 작업을 하면서 느낀 점은, 예외 경로야말로 동시성 제어가 더 중요하다는 것이다. 성공 경로는 테스트도 많고, 관찰도 쉽기 때문에 동시성 버그가 드물다. 반면 실패·복구·재시도 경로는 테스트하기 어렵고, 때문에 구현이 느슨해지기 쉽다.

이런 불일치는 코드 리뷰 때 특히 눈여겨봐야 할 부분이다. 마찬가지로 팀에 공유할 때도 "왜 성공과 실패에서 다른 전략을 쓰는가"를 묻는 게 좋다. 대부분 "이건 실패 경로니까 괜찮을 거야" 같은 약한 가정인 경우가 많다.

또 하나, 이번처럼 후발적으로 발견되는 불일치를 줄이려면:
- 예외 처리의 영역을 명시적으로 정의 (어떤 에러는 CAS로 복구하고, 어떤 에러는 수동 개입으로 가는지)
- 동시성 테스트 시 성공/실패를 모두 포함 (부분 실패 + 재시도 시나리오)
- 배치성 작업의 멱등성(idempotency)을 설계 체크리스트에 넣기

이런 작은 정정이 누적되면 시스템의 신뢰도가 한 단계 올라간다.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.