Skip to content
hello tis
Go back

설계 노트: 포인트 차감과 결제 사이의 정합성 다루기

이 문서는 시스템 설계 스터디 결과를 공유하기 위한 문서다. 블로그 목록과 카테고리에는 노출하지 않고 직접 링크로만 공유한다. 시스템 설계 스터디 1회차(2026-07-26) 논의 중 “2. 포인트-결제 차감 순서와 정합성”을 정리했다. 1부는 구조 수준의 선택(설계안), 2부는 코드 수준의 고민(상세안)이다.

참고 자료

문제 정의

주문 한 건에 포인트 차감과 PG 결제가 함께 묶인다. 두 작업은 서로 다른 시스템(자사 포인트 서버, 외부 PG)에서 일어나므로 하나의 트랜잭션으로 묶을 수 없다. 그래서 두 가지를 정해야 한다.

  1. 순서 — 포인트와 결제 중 무엇을 먼저 차감할 것인가
  2. 정합성 보정 — 한쪽만 성공했을 때 어떻게 복구할 것인가

순서에 대한 전제 하나는 스터디에서 이견 없이 합의됐다. 지급(적립)은 항상 마지막이다. 먼저 지급하고 나중에 회수하는 구조는 사용자가 그 사이에 써버리면 회수할 방법이 없다. 차감 → 완료 확인 → 적립 순서가 안전하다.

문제가 되는 건 차감끼리의 순서다. 대표 장애 시나리오는 “결제는 성공했는데 포인트 차감이 실패한” 경우다. 사용자 화면에는 포인트가 남아있는 것처럼 보이고, 그대로 다음 주문에 다시 쓰면 이중 사용이 된다.


1부 : 설계안 — 구조 수준의 선택

설계 전반의 원칙 : Compensating Action 패턴

세 설계안 모두 “한쪽만 성공한 상태를 어떻게 되돌리느냐”를 다루므로, 개별 안을 보기 전에 Compensating Action 패턴의 원칙을 먼저 깔아둔다. 분산된 시스템 사이에는 트랜잭션 보장이 없으므로, 일단 낙관적으로 실행하고 실패하면 이미 완료된 작업에 취소 요청(보상 조치)을 보내는 접근이다. 여기서 나오는 원칙 네 가지가 아래 설계안 전체에 걸린다.

이 원칙 중 세 번째를 실무에서 어긴 적이 있다. 포인트 전환 시스템에서 차감 기능을 응집시키다가 실행 순서가 바뀌어, 외부 제휴사 전환은 성공하고 자사 차감은 실패하는 사고가 났다. 외부 전환은 이미 사용자에게 지급된 뒤라 되돌리기 어려운 조치인데 그걸 먼저 실행한 것이다. 순서를 되돌려 자사 차감을 앞에 두자, 외부 전환이 실패해도 보상은 자사 차감 복구로 끝나게 됐다. 자세한 경위는 외부 API 연동의 정합성, 분산락, 그리고 테스트 전략에 있다.

설계안 1 : 포인트 선차감 → 결제 (동기, 일관성 우선)

흐름

  1. 포인트를 먼저 차감한다.
  2. PG 결제를 요청한다.
  3. 결제가 실패하면 차감한 포인트를 복구(보상 트랜잭션)한다.
  4. 결제가 성공하면 주문을 확정하고, 적립이 있다면 이때 지급한다.

아키텍처

비즈니스 로직 수준 — 성공/실패 분기와 보상 판단.

설계안 1 비즈니스 로직 수준 — 포인트 차감, PG 결제 승인, 주문 확정 순서와 실패 시 보상 분기

인프라 수준 — 컴포넌트 연결과 장애 전파.

설계안 1 인프라 수준 — 주문 서버가 포인트 서버와 외부 PG를 동기로 호출하는 체인

선택 이유

주의점

설계안 2 : 결제 선진행 → 포인트 비동기 차감 + 사후 보정 (가용성 우선)

스터디에서 최종적으로 무게가 실린 방향이다.

흐름

  1. PG 결제를 먼저 진행한다.
  2. 포인트 차감은 이벤트(예: 카프카)로 발행만 하고 결제 흐름을 끝낸다.
  3. 컨슈머가 이벤트를 순차 소비해 포인트 원장에 반영한다.
  4. 포인트 서버 장애 등으로 반영이 늦어진 건은 주기적 배치로 대사(對査)해 보정한다.

아키텍처

비즈니스 로직 수준 — 성공/실패 분기와 보정 판단.

설계안 2 비즈니스 로직 수준 — PG 결제 승인 후 차감 이벤트 발행, 비동기 원장 기록, 대사 배치 보정 흐름과 이중 사용 창

인프라 수준 — 컴포넌트 연결과 장애 전파.

설계안 2 인프라 수준 — 카프카 브로커가 주문 서버와 포인트 컨슈머 사이를 완충하는 구조

선택 이유

주의점

설계안 비교

기준1. 포인트 선차감2. 결제 선진행 + 비동기 보정
우선 가치일관성가용성
포인트 서버 장애 시결제 불가결제 계속 진행
이중 사용차단허용 후 보정
실패 복구포인트 복구 (자사)배치 대사
구현 비용낮음중간 (배치 필수)

결정 기준은 결국 CAP의 트레이드오프다. 포인트 장애 시 결제가 막히는 손실(매출)과 이중 사용 손실(소액)을 저울에 올리면, 커머스에서는 가용성 우선(설계안 2)에 무게가 실린다. 다만 포인트 단가가 크거나 어뷰징 노출이 큰 서비스라면 이중 사용 손실이 소액이라는 전제가 깨지므로, 설계안 1로 돌아갈 이유가 생긴다.


2부 : 상세안 — 코드 수준의 고민

설계안이 정해져도 코드 수준에서 다시 갈림길이 나온다. 이전 포인트 시스템 작업에서 검증한 방법들을 설계안에 대응시켜 정리한다.

상세안 1 : 중복 요청 선점 — INSERT IGNORE로 멱등성 보장

적용 지점 : 설계안 2의 이벤트 재처리, 설계안 1의 복구 재시도, 외부 제휴사의 중복 요청.

문제 : 같은 차감/적립 요청이 두 번 도착한다. 카프카 재처리, 네트워크 재시도, 사용자 따닥 클릭 모두 원인이 된다. 기존에는 분산락 + 중복 검증 쿼리로 막았는데, 순차 처리를 위해 Redis에 의존하고 SELECT → INSERT로 DB를 2번 호출하는 비효율이 있었다.

해결 : 트랜잭션 ID에 UNIQUE 제약을 걸고 INSERT IGNORE로 선점한다.

INSERT IGNORE INTO issue_history (transaction, company, history_id)
VALUES ('TX-001', 'EST', 0);
-- affected rows = 1 → 신규, 0 → 중복

affected rows 값 하나로 신규와 중복을 판단한다. DB의 UNIQUE 제약조건이 원자적으로 중복을 막으므로 분산락이 필요 없어지고, Redis 의존도 줄어든다.

주의점

대안 : DB 제약을 걸 수 없다면 큐로 직렬화한다

UNIQUE 제약이 성립하지 않으면 중복 제거를 DB에 맡길 수 없으므로, 메시지 큐로 같은 키의 요청을 직렬화한다. 카프카 기준으로 다음처럼 동작한다.

이 방식의 주의점은 두 가지다.

상세안 2 : 락 점유 시간 줄이기 — 외부 호출을 락 밖으로

적용 지점 : 설계안 1처럼 동기 흐름 안에 외부 API 호출이 끼는 구조.

문제 : 포인트 전환에 @DistributedLock 애너테이션을 메서드 레벨로 걸었더니 외부 API 호출까지 락 범위에 들어갔다. 측정해보니 락 점유 시간의 65%가 외부 API 호출이었다.

@DistributedLock(key = 사용자_포인트_거래키)
fun 포인트전환(/* ... */) {
    포인트_서비스.차감(/* ... */)
    제휴사포인트_서비스.전환요청(/* ... */)   // 외부 호출인데 락 안에 있다
    포인트_서비스.차감롤백(/* ... */)
}

해결 : 람다 기반 락으로 바꿔 락이 필요한 구간만 감싼다. 락 점유 시간 p95가 193ms에서 67ms로 줄었다.

fun 포인트전환(/* ... */) {
    lockPort.lock(사용자_포인트_거래키) {
        포인트_서비스.차감(/* ... */)
    }

    제휴사포인트_서비스.전환요청(/* ... */)   // 락 밖

    lockPort.lock(사용자_포인트_거래키) {
        포인트_서비스.차감롤백(/* ... */)
    }
}

같은 원칙이 DB 트랜잭션에도 적용된다. 트랜잭션이 길면 undo log가 비대해지므로, 트랜잭션 안에는 DB 작업만 남기고 외부 호출은 밖으로 뺀다.

주의점 : 락 점유 시간은 줄지만 락 획득 횟수가 늘어 경합이 늘 수 있다. 락 획득 성공률·조기 방출률·점유 시간 p95를 지표로 수집해서(spring boot actuator + micrometer + prometheus) 반대 방향의 문제가 생기는지 확인해야 한다. 수집 방법은 외부 API 연동의 정합성, 분산락, 그리고 테스트 전략에 정리했다.

상세안 3 : 보상 조치 구현 — 이력 추가와 멱등 복구

적용 지점 : 설계안 1의 결제 실패 복구.

문제 : 보상을 “차감 이력 DELETE” 또는 “이력 UPDATE”로 구현하면 두 가지가 깨진다. 감사 관점에서 “차감했다가 복구했다”는 사실이 사라지고, 복구 요청이 중복 실행됐을 때 이중 복구를 막을 방법이 없다.

해결

상세안 4 : 정합성 테스트 — 일어나지 말아야 할 일과 불변식

적용 지점 : 모든 설계안. 특히 차감 순서가 정합성의 핵심인 만큼, 순서가 리팩토링으로 바뀌는 걸 테스트가 잡아줘야 한다.

문제 : 앞서 말한 포인트 전환 사고에서 테스트는 계속 녹색이었다. 예외가 발생한 사실만 확인했을 뿐, 실행 순서가 바뀌어 외부 API가 먼저 호출된 부작용은 검증하지 않았기 때문이다. 테스트의 침묵은 정상 동작을 의미하지 않는다.

해결 : 세 가지 검증을 계층적으로 쓴다.


남은 질문

다음 스터디(스텝 1·2 심화, 아키텍처 발표)에서 확인할 것들.


Share this post:

Previous Post
설계 노트: 재고 원장과 스냅샷으로 동시성 다루기
Next Post
2026년 8월 1주차 회고: JVM 네이티브 메모리 추적기 2편