Search
📮

결제하기 - SAGA패턴을 이용한 설계

작성자: 김현지
 작성일자: 2025년 11월 25일
목차

00. 결제 서비스 내부 처리 로직

01. 결제서비스가 발행하는 토픽과 발행조건

토스페이먼츠 결제 승인 성공
topic : payment-paid
토스페이먼츠 결제 승인 실패
topic : payment-failed

성공흐름

1.
[payment-service] 토스페이먼츠 결제 승인 성공
a.
payment-paid 토픽 발행
2.
[order-service] payment-paid 토픽 수신
3.
[order-service] 주문, 주문 상세 상태 변경 (PREPARING)

보상흐름

1.
[payment-service] 토스페이먼츠 결제 승인 실패
a.
payment-failed 토픽 발행
2.
[order-service] payment-failed 토픽 수신
3.
[order-service] 주문, 주문 상세 상태 변경 (FAILED), 쿠폰 원복 : 멱등성 체크 필요 (이미 FAILED 인지)
a.
order-failed 토픽 발행
4.
[product-service] order-failed 토픽 수신
5.
[product-service] 상품 재고 원복

카프카 설정

브로커 : 3
2개의 브로커 중 1개만 다운되어도 쿼럼(2개)을 충족할 수 없어 메타데이터 관리 기능이 중단되기 때문에 최소 3개 있는것이 좋다
쿼럼이란?
클러스터의 의사결정을 내리기 위해 과반수의 동의를 얻어야 하는 최소 노드 수를 의미.
노드 N 개일 때, 쿼럼은 N/2+1N/2 + 1 이다. (소수점은 버린다)
파티션 : 3
이유
브로커 개수의 배수. 이후에 테스트를 통해 필요하다면 추가
각 파티션에서 브로커는 리더로 선출되는데, 파티션 수가 브로커 수의 배수일 때 카프카는 각 브로커에 리더 역할을 최대한 동일하게 배분하려고 노력한다
리더를 맡은 브로커가 실질적인 메세지 중계역할을 하기 때문에 리더 역할을 동일하게 분배한다면 부하도 효율적으로 분산된다
레플리카 : 3
메시지 보관 기간 : 7일

02. PG사 에러 핸들링

토스페이먼츠 결제 승인 API 에러 코드
Feign의 ErrorDecoder를 활용해 Custom Exception으로 변환
토스페이먼츠 응답
CustomException
설명
time out, 50x 에러 코드
PaymentTossServerException
이 예외가 발생했을때 토스패이먼츠에 재요청을 시도한다
409, 422, 400(ALREADY_PROCESSED_PAYMENT)
PaymentAlreadyProcessedException
이 예외가 발생하면 보상트랜잭션을 실행하지 않는다. 이유 : 이미 결제 처리한 요청에 대해 중복처리하고 있기 때문에 롤백하지 않는다
이외 40x
PaymentRejectedException
잔액부족, 한도초과 등 예외는 재시도 하지 않고 바로 보상트랙잭션을 실행한다

Feign 재시도 전략

횟수 : 3번
일시적인 장애에 대해 재시도
영구적이거나 긴 장애는 보상트랜잭션
지수 백오프 적용
다음 시도까지 대기시간을 점진적으로 증가시켜, 초기 실패는 빠르게 재시도 하고 반복적으로 실패가 일어난다면 천천히 재시도

03. DLQ (Dead Letter Queue)

DLQ는 시스템 오류, 잘못된 데이터 형식, 데이터 손상 등의 이유로 처리에 실패한 메시지를 별도로 저장하는 대기열
목적: 실패한 메시지를 버리지 않고 모아두었다가, 나중에 원인을 분석하거나 재처리하기 위함
주의점: 메시지 처리 순서가 엄격하게 지켜져야 하는 시스템에서는 DLQ 사용 시 순서가 뒤섞일 수 있으므로 주의
Spring Kafka 용어: Spring Kafka에서는 이 개념을 Dead Letter Topic (DLT)라고 부른다

적용 위치 1) [order-service] payment-paid 토픽 수신

payment-paid 토픽 수신 → [order-service] 주문, 주문 상세 상태 변경 (PREPARING)
주문 상태변경에 실패했을 경우 메시지 저장
dlq 토픽 : payment-paid-dlt
위치
필드
정보
목적
value
orderId
주문 ID
원본 메시지 구성
orderStatus
변경하려는 status
원본 메시지 구성
header
kafka_original-offset
원본 offset
오류 위치 식별
kafka_original-topic
원본 토픽 ⇒ payment-paid
오류 위치 식별
kafka_dlt-original-consumer-group
원본 컨슈머 그룹
오류 위치 식별
kafka_original-partition
원본 파티션
오류 위치 식별
kafka_exception-cause-fqcn
발생한 오류
오류 원인 식별
kafka_exception-message
발생한 오류 메시지
오류 원인 식별

적용 위치 2) [order-service] payment-failed 토픽 수신

payments-failed 토픽 수신 → [order-service] 주문, 주문 상세 상태 변경 (FAILED), 쿠폰 원복
주문상태변경/쿠폰 원복에 실패 했을 경우 메시지 저장
dlq 토픽 : payment-failed-dlt
위치
필드
정보
목적
value
orderId
주문 ID
원본 메시지 구성
paymentId
결제 ID
원본 메시지 구성
orderStatus
변경하려는 status
원본 메시지 구성
header
kafka_original-offset
원본 offset
오류 위치 식별
kafka_original-topic
원본 토픽 ⇒ payment-failed
오류 위치 식별
kafka_dlt-original-consumer-group
원본 컨슈머 그룹
오류 위치 식별
kafka_original-partition
원본 파티션
오류 위치 식별
kafka_exception-cause-fqcn
발생한 오류
오류 원인 식별
kafka_exception-message
발생한 오류 메시지
오류 원인 식별

적용 위치 3) [product-service] order-failed 토픽 수신

order-failed 토픽 수신 → [product-service] 상품 재고 원복
재고 원복에 실패 했을 경우 메시지 저장
dlq 토픽 : order-failed-dlt
위치
필드
정보
목적
value
List
원본 메시지 구성
optionValueId
옵션 ID
원본 메시지 구성
quantity
롤백하려는 재고 개수
원본 메시지 구성
header
kafka_original-offset
원본 offset
오류 위치 식별
kafka_original-topic
원본 토픽 ⇒ order-failed
오류 위치 식별
kafka_dlt-original-consumer-group
원본 컨슈머 그룹
오류 위치 식별
kafka_original-partition
원본 파티션
오류 위치 식별
kafka_exception-cause-fqcn
발생한 오류
오류 원인 식별
kafka_exception-message
발생한 오류 메시지
오류 원인 식별
활용 방안
1.
DLQ 컨슈머를 통한 재처리
일시적인 장애로 인한 처리 실패는 일정 시간 후 재처리 할 수 있다
주문 시스템에서 실패한 주문이 계속 남아있으면 안됨
실패한 주문에 대해서는 반드시 재고 원복이 필요함
2.
수동복구
3.
모니터링
DLQ에 쌓이는 메시지 수를 모니터링해서 운영 장애를 감지
flowchart LR
    A[Kafka Consumer <br> 메시지 처리 시작] --> B{처리 성공?}

    B -->|Yes| C[정상 처리 완료]

    B -->|No| D[예외 발생]

    D --> E{재시도 가능? <br> Retry Count < Max Attempts}

    E -->|Yes| F["메시지 재처리"]

    E -->|No| G[DLT로 이동<br> 원본 메시지 + Header 기록]

    G --> H[운영자 분석, 수동 재처리]

    style G fill:#ffdddd,stroke:#ff0000
    style H fill:#fff3cd,stroke:#e0a800
    style A fill:#e7f1ff,stroke:#3399ff
Mermaid
복사
sequenceDiagram
    participant K as Kafka Broker
    participant C as Consumer (@RetryableTopic)
    participant S as Business Logic
    participant D as Dead Letter Topic (DLT)

    K->>C: 메시지 전달
    C->>S: 메시지 처리 호출

    alt 처리 성공
        S-->>C: 성공
        C-->>K: Commit Offset
    else 처리 실패
        S-->>C: 예외 발생
        C->>C: Retry 1 (백오프 대기)
        C->>S: 재시도 처리

        alt 재시도 성공
            S-->>C: 성공
            C-->>K: Commit Offset
        else 재시도 실패
            C->>C: Retry 2 (백오프 2배 증가)
            C->>S: 재시도 2차
            alt 모두 실패
                C->>D: 메시지 이동(-dlt)\n원본 header + exception 저장
                D->>C: DLQ Consumer 재처리 가능
            end
        end
    end
Mermaid
복사

04. PENDING 상태의 장기 체류 주문 스케줄러 처리

결제 지연, 네트워크 오류 등으로 인해 완료되지 못하고 장기간 PENDING 상태로 체류 중인 주문을 정리하여 데이터 정합성을 확보하고, 묶여있던 재고 및 쿠폰 자원을 즉시 복구하는 것을 목표로 한다.
타임아웃 기준 : 5분
5분 이상 PENDING 상태로 체류한 주문을 처리 대상한다.
실행 주기 : 1분
실행 방법 : 1분마다 타임아웃 된 주문을 찾아 상태를 변경하고 외부 시스템에 결제 상태 변경, 재고 롤백을 요청한다

개선 방안

1.
외부 서비스 호출을 비동기 이벤트기반으로 전환한다
주문서비스는 DB업데이트 후 롤백에 필요한 정보를 Kafka 토픽에 발행하고 종료한다.
외부서비스인 상품, 결제 시스템이 각 토픽을 구독하여 메시지를 받으면 자체적으로 롤백을 수행한다
주문 서비스는 외부 시스템의 응답을 기다리지 않으므로 성능이 극대화되고, 외부 시스템 장애 시에도 메시지가 유실되지 않아 안정성이 높아진다
2.
청크 처리
현재는 한번에 모든 pending 상태의 주문을 조회해 처리하고 있다
주문을 대량으로 처리할 때 시스템 부하를 줄이기 위해 청크를 도입한다
한 번에 최대 청크 사이즈만큼만 조회해 pending 주문을 처리하고 더이상 처리할 대상이 없을때까지 반복한다.

05. 결제하기 자체의 멱등성 확보

Toss의 Idempotency Key를 이용해서 멱등성을 확보한다
현재 결제하기 로직에서 따로 쿠폰 차감이나 재고 차감을 진행하고 있지 않기 때문에
중복된 요청에 대해 멱등성 확보는 중복된 결제 제거로 이루어질 수 있다

시나리오

중복된 토스 승인 요청
시나리오 단계:
1.
첫번째요청이 처리되기 전 클라이언트에서 중복된 요청이 들어온다.
2.
validatePaymentStatus 검증을 통과합니다. (첫번째요청이 처리되기 전 이므로 아직 PENDING상태 )
3.
tosspaymentsAPI.confirmPayment 호출 성공 → 2번 결제 됨
문제:
결제가 2번 요청됨
해결 방법:
멱등키를 활용하면 동일 Payment 요청 시 동일한 멱등키로 토스에 요청을 보내게 된다
동일한 멱등키로 요청을 보내면 실제 결제는 되지 않지만 토스에서 기존과 동일한 응답이 옴
성공 시나리오는 단순 상태 업데이트이므로 멱등성에 영향을 미치지 않음

방법

토스페이먼츠 결제 승인 요청시 헤더에 멱등키(Idempotency-Key)를 포함에 요청한다
멱등키는 무작위적인 고유값을 Client측에서 생성해 요청에 포함한다
대표적인 생성 방법으로는 UUIDv4가 있다
중복 요청을 막기 위해서 멱등키는 결제에 종속되어야 한다.
즉, 결제하기 요청시마다 새로 생성되는게 아니라 결제 record에 종속되어야한다
따라서 주문 생성 → 결제 생성 시점에 멱등키도 함께 생성해 보관한다
토스 내부적으로 멱등키를 처리하는 방법
멱등성이란?
두번이상 요청하더라도 같은 요청에 대해서는 같은 응답을 뱉는다.
또한 서버 상태(DB)에도 영향을 미치지 않는다.

06. payments.paid 토픽 발행 실패 시 처리

방안 1) Outbox Pattern

DB변경사항 저장과 메시지 발행을 하나의 트랜잭션으로 묶는 방법
메시지를 바로 카프카로 보내지 않고, DB에 "보낼 메시지"를 먼저 저장하는 방식
메시지 발행에 실패하면 비즈니스 로직도 롤백되면서 원자성을 확보할 수 있다.
1.
producer
발행자는 비즈니스로직과 동시에 별도 outbox 테이블에 “발행할 이벤트 내용”을 삽입한다
이 두 작업은 같은 DB 트랜잭션이므로, 같이 성공하거나 같이 실패한다. (원자성 보장)
2.
별도 프로세스
outbox 테이블을 주기적으로 polling하거나 CDC를 통해 변경을 감지한다
읽어온 메시지를 카프카로 실제 발행한다
발행이 완료되면 outbox테이블에서 해당 이벤트를 삭제하거나 상태를 업데이트 한다.
장점
DB와 메시지 브로커간 데이터 불일치 문제가 발생하지 않는다
카프카 장애 시에도 outbox테이블에 이벤트가 저장되어있기 때문에 다시 전송 가능하다.
단점
추가적인 데이터 저장 비용 발생
추가적인 이벤트 처리 로직 필요 (polling, CDC)
바로 카프카로 보내는 것보다 이벤트 전송에 지연 발생

방안 2) 별도 테이블을 이용한 재시도

토픽 발생을 시도 후에 발행 실패시 결제 데이터에 publishStatus = FAILED 라고 마킹
(혹은 새로운 fallback table에 저장)
이후에 스케줄러를 통해 발행되지 않은 이벤트에 대해 처리
Recovery Batch (스케줄러):
1분 마다 돌면서 조건을 만족하는 건들에 대해 카프카 메시지 발행을 시도한다
조건: 결제 완료(PAID) 상태이면서 최근 10분간(예시) 이벤트 발행 안 됨인 데이터를 조회