이 문서는 시스템 설계 스터디 결과를 공유하기 위한 문서다. 블로그 목록과 카테고리에는 노출하지 않고 직접 링크로만 공유한다. 시스템 설계 스터디 1회차(2026-07-26) 논의 중 “2. 포인트-결제 차감 순서와 정합성”을 정리했다. 1부는 구조 수준의 선택(설계안), 2부는 코드 수준의 고민(상세안)이다.
참고 자료
문제 정의
주문 한 건에 포인트 차감과 PG 결제가 함께 묶인다. 두 작업은 서로 다른 시스템(자사 포인트 서버, 외부 PG)에서 일어나므로 하나의 트랜잭션으로 묶을 수 없다. 그래서 두 가지를 정해야 한다.
- 순서 — 포인트와 결제 중 무엇을 먼저 차감할 것인가
- 정합성 보정 — 한쪽만 성공했을 때 어떻게 복구할 것인가
순서에 대한 전제 하나는 스터디에서 이견 없이 합의됐다. 지급(적립)은 항상 마지막이다. 먼저 지급하고 나중에 회수하는 구조는 사용자가 그 사이에 써버리면 회수할 방법이 없다. 차감 → 완료 확인 → 적립 순서가 안전하다.
문제가 되는 건 차감끼리의 순서다. 대표 장애 시나리오는 “결제는 성공했는데 포인트 차감이 실패한” 경우다. 사용자 화면에는 포인트가 남아있는 것처럼 보이고, 그대로 다음 주문에 다시 쓰면 이중 사용이 된다.
1부 : 설계안 — 구조 수준의 선택
설계 전반의 원칙 : Compensating Action 패턴
세 설계안 모두 “한쪽만 성공한 상태를 어떻게 되돌리느냐”를 다루므로, 개별 안을 보기 전에 Compensating Action 패턴의 원칙을 먼저 깔아둔다. 분산된 시스템 사이에는 트랜잭션 보장이 없으므로, 일단 낙관적으로 실행하고 실패하면 이미 완료된 작업에 취소 요청(보상 조치)을 보내는 접근이다. 여기서 나오는 원칙 네 가지가 아래 설계안 전체에 걸린다.
- 보상은 롤백이 아니다. 보상을 마쳐도 원래 상태로 돌아가는 게 아니라 “보상된 상태”에 도달한다. 계좌 이체를 취소하면 이체 0건이 아니라 이체 2건이 남는다. 잔액은 맞지만 이력이 남는다는 뜻인데, 포인트 차감 취소도 마찬가지로 이력 삭제가 아니라 반대 방향 이력 추가로 표현해야 한다. append-only 원장과 자연스럽게 맞는 지점이다.
- 보상 조치도 실패할 수 있다. 계좌 취소 전에 소유자가 돈을 인출하면 보상이 성공할 수 없다. 은행도 이렇게 실패한 보상은 결국 손실로 상각한다. 보상 조치를 쓴다고 100% 일관성이 보장되는 게 아니므로, 대사 배치와 손실충당금이 최후 방어선으로 필요하다.
- 가장 되돌리기 어려운 조치를 마지막에 둔다. 보상 조치가 없거나 비싼 작업일수록 뒤로 미뤄서, 그 작업을 되돌려야 할 확률 자체를 줄인다. 차감 순서를 정하는 기준이 이 원칙에서 바로 나온다.
- 여러 단계를 보상할 때는 역순으로 되돌린다. 사가(saga)라고 부르는 구조다.
이 원칙 중 세 번째를 실무에서 어긴 적이 있다. 포인트 전환 시스템에서 차감 기능을 응집시키다가 실행 순서가 바뀌어, 외부 제휴사 전환은 성공하고 자사 차감은 실패하는 사고가 났다. 외부 전환은 이미 사용자에게 지급된 뒤라 되돌리기 어려운 조치인데 그걸 먼저 실행한 것이다. 순서를 되돌려 자사 차감을 앞에 두자, 외부 전환이 실패해도 보상은 자사 차감 복구로 끝나게 됐다. 자세한 경위는 외부 API 연동의 정합성, 분산락, 그리고 테스트 전략에 있다.
설계안 1 : 포인트 선차감 → 결제 (동기, 일관성 우선)
흐름
- 포인트를 먼저 차감한다.
- PG 결제를 요청한다.
- 결제가 실패하면 차감한 포인트를 복구(보상 트랜잭션)한다.
- 결제가 성공하면 주문을 확정하고, 적립이 있다면 이때 지급한다.
아키텍처
비즈니스 로직 수준 — 성공/실패 분기와 보상 판단.
인프라 수준 — 컴포넌트 연결과 장애 전파.
- 전부 동기 체인이다. 포인트 서버가 죽으면 ①에서 막혀 결제 전체가 멈춘다.
- ② 실패 시 보상(포인트 복구)도 같은 동기 경로를 거꾸로 탄다. 복구 실패분만 재시도 큐로 빠진다.
선택 이유
- “가장 되돌리기 어려운 조치를 마지막에”와 일치한다. PG 결제는 외부 시스템의 취소 API에 의존하고, 취소 수수료나 환불 지연이 따라붙을 수 있는 가장 되돌리기 어려운 조치다. 이걸 마지막에 두면 실패 복구가 자사 포인트 복구로 끝나고, 우리가 직접 재시도할 수 있다. 포인트 전환 시스템에서 순서를 이렇게 고친 뒤 실패 대응이 자사 안에서 닫힌 경험이 있다.
- 이중 사용을 원천 차단한다. 결제 시점에 이미 포인트가 차감돼 있으므로, 잔액이 남아있는 것처럼 보여 재차 쓰는 시나리오가 생기지 않는다.
- 돈이 걸린 흐름의 기본값으로 보수적이다. 정합성이 깨진 상태로 사용자에게 노출되는 구간이 없다.
주의점
- 포인트 서버가 죽으면 결제 전체가 막힌다. 포인트 장애 = 결제 불가 시간 = 매출 손실. 스터디에서 이 방식에 무게를 싣지 않은 이유가 이것이다. 포인트 몇 건의 과다 사용(소액 손실)보다 결제가 멈추는 쪽이 비즈니스에 더 치명적이다.
- 복구 실패 케이스가 남는다. 결제 실패 후 포인트 복구마저 실패하면(서버 다운 등) 사용자 포인트가 마이너스로 잠긴 상태가 된다. 보상 조치도 실패한다는 원칙 그대로다. 복구를 멱등하게 만들고 재시도 큐에 태우는 구현은 상세안 3에서 다룬다.
- 결제 지연 시간만큼 포인트가 잠긴다. PG 응답이 느리면 차감된 채로 대기하는 시간이 길어지고, 사용자가 그 사이 다른 주문을 시도하면 잔액 부족으로 실패한다.
설계안 2 : 결제 선진행 → 포인트 비동기 차감 + 사후 보정 (가용성 우선)
스터디에서 최종적으로 무게가 실린 방향이다.
흐름
- PG 결제를 먼저 진행한다.
- 포인트 차감은 이벤트(예: 카프카)로 발행만 하고 결제 흐름을 끝낸다.
- 컨슈머가 이벤트를 순차 소비해 포인트 원장에 반영한다.
- 포인트 서버 장애 등으로 반영이 늦어진 건은 주기적 배치로 대사(對査)해 보정한다.
아키텍처
비즈니스 로직 수준 — 성공/실패 분기와 보정 판단.
인프라 수준 — 컴포넌트 연결과 장애 전파.
- 동기 구간은 ① 하나다. 포인트 쪽(컨슈머, DB)이 죽어도 결제는 계속 진행된다.
- 차감이 원장에 반영되기 전까지 포인트 조회 서버는 차감 안 된 잔액을 보여준다. 이중 사용 창이 여기서 생긴다.
선택 이유
- 포인트 서버가 죽어도 결제가 멈추지 않는다. 차감 요청은 이벤트로 남아있으므로, 서버가 복구되면 그 시점부터 순차 재처리된다. 카프카는 기본 7일간 메시지를 보존하므로 그 안에 복구되면 유실이 없다.
- 컨슈머가 순차 처리하므로 차감 자체의 동시성 이슈가 사라진다. 같은 사용자의 차감 이벤트가 한 줄로 서서 처리된다.
- 오차를 감수하는 근거가 회계에 이미 있다. 회계에는 실손 금액에 대한 복구 충당금 개념이 있다. 1원 단위까지 실시간으로 맞추는 대신, 어느 정도의 정산 오차를 감수하고 주기적 배치로 사후 정산하는 쪽이 개발 리소스 대비 현실적이다. 은행이 영업 마감 후 야간 배치로 원장과 실계좌를 맞추는 방식과 같은 구조다.
- 핀테크 규제 관점에서도 유리하다. 장애 시간이 누적되면 금감원 제재 대상이 될 수 있어, “일단 멈추지 않는 것”이 사업 존속에 중요하다는 의견이 있었다.
- 선례가 있다. 토스뱅크가 환전 시스템을 같은 구조로 풀었다(SLASH 24 발표). 비동기라 사용자가 기다리지 않아도 되고, 메시징이라 결과적 정합성을 보장할 수 있다는 두 가지를 근거로 들었다.
주의점
- 이벤트 재처리에는 멱등성이 필수다. 카프카는 최소 1회(at-least-once) 전달이 기본이라, 장애 복구 과정에서 같은 차감 이벤트가 두 번 소비될 수 있다. 중복 차감을 DB 제약조건으로 막는 구현은 상세안 1에서 다룬다.
- 이중 사용 손실을 상수로 안고 간다. 차감이 반영되기 전까지 사용자에게는 포인트가 남아있는 것처럼 보이고, 그 사이 재차 사용할 수 있다. 이 손실이 소액이라는 전제가 깨지는 순간(고액 포인트, 어뷰징) 이 설계의 근거도 깨진다. 1인당 사용 한도, 어뷰징 탐지 같은 방어선이 같이 필요하다.
- 조회 정합성은 별도 문제다. 카프카는 죽고 포인트 조회 서비스만 살아있으면, 사용자는 차감이 반영되지 않은 잔액을 계속 보게 된다. 발행만 하고 끝나는 구조라 조회 쪽 보정 로직을 따로 설계해야 한다.
- 보정 배치가 설계의 일부다. “나중에 맞추면 된다”가 성립하려면 대사 배치가 실제로 돌고, 불일치를 찾아내고, 보정 결과를 기록해야 한다. 배치가 없으면 이 설계는 가용성 우선이 아니라 그냥 정합성 포기다.
- 마이너스 잔액을 허용해야 한다. 보정 시점에 이미 잔액보다 많이 쓴 사용자가 존재할 수 있다. 마이너스 상태를 데이터로 표현하고, 다음 적립에서 상계할지 회수를 요청할지 정책이 필요하다.
설계안 비교
| 기준 | 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 의존도 줄어든다.
주의점
UNIQUE추가 제약을 못 둬서 확장성이 떨어진다. 멱등성 판단을 DB 유니크 인덱스에 맡기는 순간 테이블이 그 제약에 묶인다. 샤딩하면 유니크가 전역으로 보장되지 않고, 파티셔닝하면 유니크 인덱스에 파티션 키가 포함돼야 하며, 데이터가 커질수록 유니크 인덱스를 새로 추가하거나 유지하는 비용이 커진다. 트래픽과 데이터가 늘어 DB를 쪼개야 하는 시점에 이 방식이 걸림돌이 된다. 중복 판단 기준에 기간·상태 같은 조건이 섞여 컬럼 유니크로 표현할 수 없는 경우도 마찬가지다.- 중복 키 외의 오류도 함께 무시된다.
IGNORE는 오류를 경고로 낮추는 키워드라, 잘못된 값이 조용히 보정돼 들어가는 등 중복이 아닌 문제까지 삼킬 수 있다. affected rows 0을 “중복”으로만 해석하면 다른 실패를 놓치므로, 경고 발생 여부를 함께 확인해야 한다. - 중복으로 무시된 경우에도 AUTO_INCREMENT 값은 증가한다. ID 연속성을 가정하는 로직이 있다면 깨진다.
대안 : DB 제약을 걸 수 없다면 큐로 직렬화한다
UNIQUE 제약이 성립하지 않으면 중복 제거를 DB에 맡길 수 없으므로, 메시지 큐로 같은 키의 요청을 직렬화한다. 카프카 기준으로 다음처럼 동작한다.
- 동작 방식 : 사용자 ID를 메시지 키로 잡는다. 프로듀서가
hash(키) % 파티션 수로 배치하므로, 같은 사용자의 요청은 항상 같은 파티션에 순서대로 쌓인다. - 파티션 수 : 토픽 생성 시 고정된 값이다. 사용자가 아무리 많아도 파티션이 늘어나지는 않는다.
- 중복 제거 : 컨슈머가 순차 처리하면서 처리 이력과 대조해 중복을 걸러낸다. 동시 요청이 애초에 직렬화되므로 DB 수준의 원자적 선점이 필요 없다.
- 재사용 : 설계안 2의 이벤트 경로를 쓰고 있다면 추가 인프라 없이 같은 구조를 재사용할 수 있다.
이 방식의 주의점은 두 가지다.
- 파티션 증설 시 순서 역전 : 파티션 수를 늘리면
hash % N결과가 바뀌어, 같은 사용자의 메시지가 증설 전후로 다른 파티션에 갈 수 있다. 증설 시점에 순서 역전 창이 생긴다. - 핫 키 : 특정 사용자에게 요청이 몰리면 그 키가 배정된 파티션 하나만 뜨거워진다.
상세안 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”로 구현하면 두 가지가 깨진다. 감사 관점에서 “차감했다가 복구했다”는 사실이 사라지고, 복구 요청이 중복 실행됐을 때 이중 복구를 막을 방법이 없다.
해결
- 복구는 반대 방향 이력 INSERT로 표현한다. 보상은 롤백이 아니라 보상된 상태라는 원칙의 코드 버전이다. 원장에는 차감 1건 + 복구 1건이 남는다.
- 복구 이력에 원본 차감의 트랜잭션 ID를
UNIQUE로 건다. 상세안 1과 같은 방식으로, 복구 요청이 두 번 와도 두 번째는 무시된다. 복구가 멱등해지므로 재시도 큐에 태워 될 때까지 반복해도 안전하다. - 복구 실패는 대사 배치가 최후에 잡는다. 재시도로도 안 되는 건(포인트 서버 장기 다운 등)은 자사 원장과 결제(PG) 기록을 대조하는 배치에서 발견하고 보정한다.
상세안 4 : 정합성 테스트 — 일어나지 말아야 할 일과 불변식
적용 지점 : 모든 설계안. 특히 차감 순서가 정합성의 핵심인 만큼, 순서가 리팩토링으로 바뀌는 걸 테스트가 잡아줘야 한다.
문제 : 앞서 말한 포인트 전환 사고에서 테스트는 계속 녹색이었다. 예외가 발생한 사실만 확인했을 뿐, 실행 순서가 바뀌어 외부 API가 먼저 호출된 부작용은 검증하지 않았기 때문이다. 테스트의 침묵은 정상 동작을 의미하지 않는다.
해결 : 세 가지 검증을 계층적으로 쓴다.
-
일어나지 말아야 할 일을 명시한다. 잔액 부족 예외가 났다면 외부 API와 차감이 호출되지 않았어야 한다.
assertThrows(교환예외::class.java) { 제휴사포인트_유스케이스.포인트전환(/* ... */) } assertAll( { verify { 제휴사포인트_서비스.전환요청(/* ... */) wasNot Called } }, { verify { 포인트서비스.포인트차감(/* ... */) wasNot Called } }, ) -
순서가 중요하면 순서를 검증한다. Mockito
InOrder로 검증 → 차감 → 외부 호출 순서를 명시한다. “가장 되돌리기 어려운 조치를 마지막에”가 코드에서 뒤집히면 테스트가 깨진다. -
불변식으로 실패 시나리오를 검증한다. 어떤 실패가 일어나든 “자사 포인트 + 제휴사 포인트 환산 총합은 거래 전후로 같다”는 조건은 참이어야 한다. 실행 경로를 일일이 검증하는 대신 불변식 하나로 모든 실패 시나리오를 덮을 수 있다.
assertThat(이후_보유포인트 + 내부_포인트_전환(이후_제휴사보유포인트)) .isEqualTo(이전_보유포인트 + 내부_포인트_전환(이전_제휴사보유포인트))
남은 질문
다음 스터디(스텝 1·2 심화, 아키텍처 발표)에서 확인할 것들.
- 포인트-결제 차감 순서의 결정 기준을 서비스 특성(포인트 단가, 어뷰징 노출)별로 어떻게 세울 것인가
- 장애 시나리오별 보정 전략 — 카프카 발행 실패, 컨슈머 다운, 조회 서비스만 생존한 경우 각각의 대응
- 트래픽이 10배~100배로 늘 때 이 구조를 어떻게 바꿀 것인가 (읽기 레플리카, 샤딩)