Skip to content

Payment Platform Project

11 posts with the tag “Payment Platform Project”

결제 복구 상태 모델 설계 — 장애 내성을 갖춘 상태 전이

실행 환경: Java 21, Spring Boot 3.4.4, MySQL 8.0

최근 비동기 결제 처리 아키텍처를 도입하면서, 기존 상태 모델이 새 아키텍처를 충분히 표현하지 못하는 문제가 드러났다.

  • 재시도 대상과 완전 실패 건의 구분이 모호하여 복구 대상을 정확히 추적의 어려움
  • 고정 간격 재시도로 인해 PG 장애 시 부하가 집중 위험 가능

이 글에서는 이러한 상태 모델의 구조적 한계를 분석하고, 장애 내성을 갖춘 상태 전이 체계를 재설계한 과정을 다룬다.

** 비동기 결제 아키텍처 자체의 도입 배경과 구현은 이전 글에서 다루므로, 여기서는 상태 모델과 복구 로직에 집중한다.

본론에 앞서, 이 글의 전제가 되는 비동기 결제 구조를 간략히 정리한다.

  1. 클라이언트의 결제 승인 요청(confirm)과 실제 PG(Payment Gateway, 결제 대행사) 승인 API 호출을 기술적으로 분리
  2. 서버는 PG 응답을 기다리지 않고 즉시 202 Accepted를 반환
  3. 실제 승인은 백그라운드에서 처리하며, 클라이언트는 승인 상태를 조회하여 최종 결과를 확인
sequenceDiagram
participant C as 클라이언트
participant S as 서버
participant PG as PG사
C ->> S: 결제 승인 요청
S -->> C: 202 Accepted
Note over S, PG: 백그라운드 처리
S ->> PG: PG 승인 요청
PG -->> S: 승인 결과
C ->> S: 승인 상태 조회
S -->> C: 최종 결과

이 구조에서 핵심이 되는 세 가지 개념이 있다.

  • PaymentEvent: 결제 1건의 생명주기를 추적하는 도메인 엔티티
    • READY → IN_PROGRESS → DONE/FAILED 등 상태 전이를 관리
  • PaymentOutbox: Outbox 패턴을 적용한 작업 큐 역할의 테이블
    • PENDING(대기) → IN_FLIGHT(처리 중) → DONE/FAILED(종결) 상태 전이를 관리
    • Outbox 패턴: 비동기로 처리할 작업을 같은 DB 트랜잭션(TX) 안에 기록하여, 서버가 중간에 죽더라도 처리 대상이 유실되지 않도록 보장하는 기법
  • Worker: Outbox 레코드를 읽어 실제 PG 승인을 수행하는 백그라운드 처리기
    • 즉시 처리기(TX 커밋 직후 즉시 처리)와 폴링 복구기(주기적 폴링으로 누락 건 재처리) 두 트랙으로 구성

위 비동기 구조에서 기존 상태 모델에 네 가지 구조적 한계가 있었다.

한계 1 - 불명확한 UNKNOWN 상태와 분산된 스케줄러

Section titled “한계 1 - 불명확한 UNKNOWN 상태와 분산된 스케줄러”

기존 상태 머신의 IN_PROGRESSUNKNOWN 두 상태 모두 재시도 대상이었고, 실패가 반복되면 FAILED로 전이되었다.

stateDiagram-v2
[*] --> READY: checkout 완료
READY --> IN_PROGRESS: PG 승인 요청 수신
READY --> EXPIRED: 만료 (30분)
IN_PROGRESS --> DONE: PG 승인 성공
IN_PROGRESS --> FAILED: 실패 (non-retryable 또는 재시도 소진)
IN_PROGRESS --> UNKNOWN: 재시도 가능 에러 발생
UNKNOWN --> DONE: 재시도 성공
UNKNOWN --> FAILED: 재시도 소진
UNKNOWN --> UNKNOWN: 재시도 계속

또한 여러 스케줄러가 돌아가고 있고, 이를 관리하는 복구 사이클이 분산되어 있었다.

한계 2 - 하드코딩된 재시도 정책 및 부하 집중 위험

Section titled “한계 2 - 하드코딩된 재시도 정책 및 부하 집중 위험”

재시도 한도가 PaymentEventPaymentOutbox 양쪽에 RETRYABLE_LIMIT = 5로 중복 하드코딩되어 있었다.

  • 고정된 폴링 간격마다 모든 건에 요청하면서 일시적 장애 시 PG에 부하 집중될 수 있음
  • PaymentOutboxnextRetryAt 개념이 없어 고정 간격(FIXED) 이외의 Backoff 전략을 지원할 수 없음
  • 한도를 변경하려면 두 클래스를 동시에 수정해야 하는 구조

한계 3 - 예외 기반 실패 분류의 이중 구조

Section titled “한계 3 - 예외 기반 실패 분류의 이중 구조”

도메인 결과값(PaymentConfirmResult)이 이미 실패 유형을 분류하고 있음에도, 예외 타입으로 변환하여 catch 블록에서 분기하는 이중 구조가 존재했다.

한계 4 - PG 상태 미조회와 무조건 재승인

Section titled “한계 4 - PG 상태 미조회와 무조건 재승인”

복구 사이클이 PG 상태를 조회하지 않고 무조건 승인 요청을 재발행하는 구조였다.

  • 멱등성 키(동일 요청을 여러 번 보내도 결과가 한 번만 적용되도록 보장하는 고유 키)로 중복 결제라는 심각한 문제는 방지
  • 재승인 요청 직전 상태를 알 수 없는 구조

위 네 가지 한계를 해결하기 위해, 상태 모델 재정의 → 재시도 정책 도메인 객체화 → 복구 결정 로직 집중 → 동시성 안전장치 순서로 재설계를 진행했다.

솔루션대응하는 한계
상태 모델 재정의한계 1 - 불명확한 UNKNOWN, 분산 스케줄러
RetryPolicy한계 2 - 하드코딩된 재시도 정책, 부하 집중
RecoveryDecision한계 3, 4 - 실패 분류 이중 구조, PG 미조회
원자적 선점 / 재고 복구 가드동시성 안전장치 (신규)

상태 모델 재정의 - RETRYING, QUARANTINED 도입과 UNKNOWN 제거

Section titled “상태 모델 재정의 - RETRYING, QUARANTINED 도입과 UNKNOWN 제거”

한계 1 해결: 불명확한 UNKNOWN 상태 제거, 분산 스케줄러 통합

기존 UNKNOWN 상태를 RETRYING으로 변경하여 “재시도 대기 중”이라는 의미를 명확히 부여하고, 한도 소진 시 복구 사이클에서 영구 이탈시키는 QUARANTINED(격리) 상태를 도입했다.

stateDiagram-v2
classDef ready fill: #D6EAF8, color: #1B4F72, stroke: #2E86C1
classDef inprogress fill: #FEF9E7, color: #7D6608, stroke: #F1C40F
classDef retrying fill: #FEF5E7, color: #7E5109, stroke: #F39C12
classDef done fill: #D5F5E3, color: #0E6251, stroke: #28B463
classDef failed fill: #FADBD8, color: #7B241C, stroke: #E74C3C
classDef quarantined fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2
[*] --> READY: checkout 완료
READY --> IN_PROGRESS: PG 승인 요청 수신
READY --> EXPIRED: 만료 (30분)
IN_PROGRESS --> DONE: PG 승인 성공
IN_PROGRESS --> FAILED: non-retryable 오류
IN_PROGRESS --> RETRYING: retryable 오류 (첫 실패)
IN_PROGRESS --> QUARANTINED: 판단 불가 + 한도 소진
RETRYING --> DONE: 재시도 성공
RETRYING --> FAILED: non-retryable 오류 또는 재시도 소진
RETRYING --> RETRYING: 재시도 또 실패 (한도 내)
RETRYING --> QUARANTINED: 판단 불가 + 한도 소진
class READY ready
class IN_PROGRESS inprogress
class RETRYING retrying
class DONE done
class FAILED,EXPIRED failed
class QUARANTINED quarantined
  • UNKNOWN → RETRYING: “알 수 없는 상태”가 아니라 “재시도 대기 중”이라는 의미를 명확히 부여
  • RETRYING → RETRYING: 한도 내에서 반복 실패 시 self-loop
  • RETRYING → DONE / FAILED: 재시도 성공 또는 소진 시 종결
  • IN_PROGRESS / RETRYING → QUARANTINED: 한도 소진 후에도 PG 상태를 판단할 수 없으면 격리 (관리자 개입 전까지 자동 복구 사이클에서 영구 이탈)
    • PG 측에서 이미 승인이 완료되었을 가능성 고려

이를 위해 기존 상태 전이 가드도 업데이트했다.

전이기존 허용 source변경 후 허용 source
승인 완료 전이IN_PROGRESS, DONEIN_PROGRESS, RETRYING, DONE
실패 처리 전이READY, IN_PROGRESSREADY, IN_PROGRESS, RETRYING
재시도 전환(신규)READY, IN_PROGRESS, RETRYING
격리(신규)READY, IN_PROGRESS, RETRYING

RetryPolicy 도메인 객체 추가 및 nextRetryAt 필드 추가로 재시도 시점 제어

Section titled “RetryPolicy 도메인 객체 추가 및 nextRetryAt 필드 추가로 재시도 시점 제어”

한계 2 해결: 하드코딩된 재시도 정책, 부하 집중 위험

중복 하드코딩된 재시도 한도를 RetryPolicy 도메인 객체로 분리했다.(설정은 application.yml로 외부화)

public record RetryPolicy(
int maxAttempts,
BackoffType backoffType,
long baseDelayMs,
long maxDelayMs
) {
public boolean isExhausted(int retryCount) {
return retryCount >= maxAttempts;
}
public Duration nextDelay(int retryCount) {
return switch (backoffType) {
case FIXED -> Duration.ofMillis(baseDelayMs);
case EXPONENTIAL -> Duration.ofMillis(
Math.min(baseDelayMs * (1L << retryCount), maxDelayMs)
);
};
}
}
  • maxAttempts: 최대 재시도 횟수
  • BackoffType: FIXED(고정 간격) 또는 EXPONENTIAL(지수 증가)
  • baseDelayMs / maxDelayMs: Backoff 계산의 기본값과 상한

추가적으로 PaymentOutboxnextRetryAt 필드를 도입하여, 재시도 시점을 동적으로 제어할 수 있도록 했다.

한계 3, 4 해결: 예외 기반 실패 분류 → 도메인 결과값 기반, PG 상태 선행 조회 도입

기존의 예외 타입을 통한 실패 분류 대신, 복구 결정 로직을 순수 도메인 값 객체에 집중시켰다.

Decision의미판단 기준후속 처리
COMPLETE_SUCCESSPG 승인 완료PG 조회 결과 = DONE로컬 DONE 전이
COMPLETE_FAILUREPG 종결 실패 (취소/중단/만료 등)PG 조회 결과 = CANCELED/ABORTED/EXPIRED 등보상 TX (재고 복원 + FAILED)
ATTEMPT_CONFIRMPG에 기록 없음PG 조회 결과 = NOT_FOUNDPG 승인 요청 후 결과 재평가
RETRY_LATERPG 조회 오류 또는 진행 중 (한도 내)PG 조회 실패(타임아웃/5xx) 또는 PG 처리 중 + 한도 미소진RETRYING + nextRetryAt 설정
QUARANTINE한도 소진 + 판단 불가한도 소진 + PG 상태 판단 불가(타임아웃/매핑 불가)QUARANTINED (자동 복구 이탈)
REJECT_REENTRY로컬이 이미 종결 상태로컬 PaymentEvent = DONE/FAILED/CANCELED 등Outbox만 멱등 종결
RecoveryDecision decision = RecoveryDecision.decide(
pgStatus, // PG 상태 조회 결과
localEventStatus, // 로컬 PaymentEvent 상태
retryCount, // 현재까지 재시도 횟수
retryPolicy // RetryPolicy 설정
);

Spring 의존 없이 순수하게 테스트할 수 있으며, 모든 복구 분기가 한 곳에 집중된다.

같은 건을 여러 Worker가 동시에 처리하는 것을 막기 위해, Outbox 상태를 PENDING(대기)에서 IN_FLIGHT(Worker가 선점하여 처리 중)로 원자적으로 전환하는 선점 메커니즘을 도입했다.

UPDATE payment_outbox
SET status = 'IN_FLIGHT',
in_flight_at = :now
WHERE order_id = :orderId
AND status = 'PENDING'
AND (next_retry_at IS NULL OR next_retry_at <= :now)
  • WHERE 절에 status = 'PENDING' 조건이 포함되어 있으므로, 두 Worker가 동시에 같은 건을 선점하려 해도 하나만 UPDATE에 성공
  • 실패한 Worker는 영향 행 수가 0이므로 즉시 포기하고, 성공한 Worker만 이후 복구 로직 진행

보상 트랜잭션은 재고를 복원하는 되돌릴 수 없는 작업이므로, 실행 전에 데이터가 정상 상태인지 이중 조건 가드로 확인한다.

  • 정상 흐름에서는 원자적 선점이 중복 진입을 원천 차단
  • 비정상 데이터(타임아웃 복구 후 상태 불일치 등)가 보상 TX까지 도달했을 때 재고를 함부로 건드리지 않기 위한 안전망
flowchart TD
classDef check fill: #FEF9E7, color: #7D6608, stroke: #F1C40F
classDef skip fill: #F5F5F5, color: #616161, stroke: #9E9E9E
classDef exec fill: #FADBD8, color: #7B241C, stroke: #E74C3C
A["보상 트랜잭션 진입"] --> C{"1. 대기열이 처리 중인가?\n(현재 사이클에서 선점한 건)"}:::check
C -- " NO " --> D["재고 복원 건너뜀\n대기열/결제 종결만 수행"]:::skip
C -- " YES " --> E{"2. 결제가 아직 미종결인가?\n(아직 실패/완료 아닌 건)"}:::check
E -- " NO " --> D
E -- " YES " --> F["재고 복원 수행\n+ 대기열 → 실패\n+ 결제 → 실패"]:::exec

TX 시작 시점에 Outbox와 PaymentEvent를 DB에서 다시 조회하여, 두 조건 모두 충족할 때만 재고 복원을 실행한다.

  • Outbox가 IN_FLIGHT가 아닌 경우: 다음 두 가지 경로로 이 상태에 도달
    • 다른 Worker가 복구를 먼저 완료하여 Outbox가 DONE 또는 FAILED로 전이된 경우
    • IN_FLIGHT 타임아웃 복구에 의해 Outbox가 PENDING으로 초기화된 뒤, 다른 Worker가 재선점하여 현재 Worker의 IN_FLIGHT가 무효화된 경우
    • 어느 쪽이든 현재 Worker의 선점이 유효하지 않으므로 재고 복원 skip
  • PaymentEvent가 이미 종결 상태(DONE/FAILED/CANCELED 등)인 경우: 다른 경로에서 보상 TX가 이미 커밋되었으므로 재고 복원을 skip

재시도 중 마지막 요청이 성공했을 수도 있으므로, 격리 직전에 PG 상태를 한 번 더 조회하여 최종 확인한다.

flowchart TD
classDef success fill: #D5F5E3, color: #0E6251, stroke: #28B463
classDef failure fill: #FADBD8, color: #7B241C, stroke: #E74C3C
classDef quarantine fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2
classDef action fill: #EBF5FB, color: #21618C, stroke: #3498DB
A["한도 소진 시점"] --> B["PG 상태 1회 최종 재확인"]:::action
B -->|" 승인 완료 "| C["성공 확정 (승격)"]:::success
B -->|" PG 종결 실패\n(취소/중단/만료 등) "| D["실패 확정 (재고 복원)"]:::failure
B -->|" PG 기록 없음/타임아웃/매핑 불가\n(판단 불가) "| F["격리 (관리자 개입 대기)"]:::quarantine

시나리오에 앞서, 복구 처리를 담당하는 세 구성 요소의 역할을 정리한다.

구성 요소트리거역할
즉시 처리기채널 수신 즉시PG 승인 요청 직후 Outbox에 발행된 건을 즉시 처리
폴링 복구기5초 주기 폴링IN_FLIGHT 타임아웃 복구 / PENDING 건 배치 조회 후 복구 처리 위임
만료 스케줄러5분 주기30분 이상 READY 상태인 만료 건을 EXPIRED로 전이

폴링 복구기는 매 틱마다 두 단계를 순서대로 수행한다.

  1. IN_FLIGHT 상태가 일정 시간(기본 5분) 이상 지속된 건을 PENDING으로 복구: Worker가 처리 중 비정상 종료하거나 응답이 느려진 경우를 대비한 안전장치
    • 5분은 PG 승인 API의 최대 응답 시간(통상 수십 초)에 충분한 여유를 두되, 복구 지연이 과도하게 길어지지 않도록 설정한 값으로 설정
  2. PENDING 건을 배치 조회하고, 각 건에 대해 PENDING → IN_FLIGHT 원자적 선점 후 복구 처리 시작

타임아웃 복구와 배치 조회를 별도 단계로 분리한 이유

Section titled “타임아웃 복구와 배치 조회를 별도 단계로 분리한 이유”

한 쿼리로 합치면 “정상 처리 중인 IN_FLIGHT”와 “죽은 IN_FLIGHT”를 구분할 수 없기 때문이다.

  • 먼저 시간 기준으로 죽은 건만 PENDING으로 확정
  • 이후 원자적 선점이 PENDING → IN_FLIGHT 단일 전이만 경쟁하도록 하여 선점 보장을 단순하게 유지

위 설계가 실제 장애 상황을 어떻게 방어하는지, 구체적인 시나리오로 확인한다.

1. PG 승인 완료인데 로컬이 모르는 경우

Section titled “1. PG 승인 완료인데 로컬이 모르는 경우”

서버가 PG 승인 응답을 받은 직후, DB에 반영하기 전에 죽는 상황이다.

sequenceDiagram
participant S as 서버
participant DB as DB
participant PG as PG사
S ->> PG: PG 승인 요청
PG -->> S: 승인 완료
Note over S: 서버 장애 발생 (DB 커밋 전)
Note over DB: 결제 = 진행 중, 대기열 = 처리 중
Note over S: 5분 후 타임아웃 복구 → 대기
S ->> PG: PG 상태 조회
PG -->> S: 승인 완료
S ->> DB: [TX] 결제 완료 + 대기열 종결

PG 상태를 먼저 조회함으로써 승인 재요청 없이 로컬에 바로 동기화하게 된다.

2. Worker 타임아웃으로 동시 처리 발생

Section titled “2. Worker 타임아웃으로 동시 처리 발생”

W1이 처리 중 느려져서 타임아웃이 발생하고, W2가 같은 건을 다시 선점하는 상황이다.

sequenceDiagram
participant W1 as 워커 1 (느림)
participant DB as DB
participant W2 as 워커 2
participant PG as PG사
W1 ->> DB: 원자적 선점 (대기 → 처리 중)
Note over W1: 처리 지연...
Note over DB: 타임아웃 복구
DB ->> DB: 대기열 → 대기 복원
W2 ->> DB: 원자적 재선점 (대기 → 처리 중)
W2 ->> PG: PG 상태 조회
PG -->> W2: 취소됨 (PG 종결 실패)
W2 ->> DB: [보상 TX] 재고 복원 + 결제 실패 + 대기열 실패
Note over W1: 뒤늦게 보상 TX 시도
W1 ->> DB: [보상 TX] 가드 검사
Note over DB: 대기열 = 실패 (처리 중 아님) → 가드 차단
W1 ->> DB: 재고 복원 건너뜀

W2가 먼저 보상 TX를 커밋한 뒤, W1이 뒤늦게 같은 건에 보상 TX를 시도했지만, 재고 복구 가드가 “Outbox가 IN_FLIGHT가 아님”을 감지하여 재고 이중 복원을 차단한다.

  • Worker가 IN_FLIGHT 타임아웃(5분)을 초과하는 현실적인 시나리오
    • Worker 스레드 혹은 프로세스가 비정상 종료(OOM, 예기치 않은 예외 등)되어 응답 자체를 반환하지 못하는 경우를 가정
    • IN_FLIGHT 타임아웃은 이처럼 Worker가 처리 도중 사라진 건을 복구하기 위한 안전장치

4번째 재시도까지 타임아웃이 반복되다가, 5번째(마지막) 시도 직전에 PG가 승인을 완료한 경우다.

sequenceDiagram
participant W as 워커
participant DB as DB
participant PG as PG사
Note over DB: 결제 = 재시도 중 (4회 실패)
Note over DB: 대기열 = 대기 (재시도 시점 도달)
W ->> DB: 원자적 선점
W ->> PG: PG 상태 조회
PG -->> W: 타임아웃 (5번째 실패)
Note over W: 한도 소진 → 격리 전 최종 확인
W ->> PG: PG 상태 1회 최종 재확인
PG -->> W: 승인 완료
W ->> DB: [TX] 결제 완료 + 대기열 종결

이 확인이 없었다면 5회 소진 시점에 QUARANTINED 또는 FAILED로 처리되어, 이미 승인된 결제의 재고가 복원될 수 있었지만, 마지막 getStatus 조회가 성공으로 승격시켜준다.


모든 개선을 반영한 PaymentEventStatus의 최종 상태 전이는 다음과 같다.

stateDiagram-v2
classDef ready fill: #D6EAF8, color: #1B4F72, stroke: #2E86C1
classDef inprogress fill: #FEF9E7, color: #7D6608, stroke: #F1C40F
classDef retrying fill: #FEF5E7, color: #7E5109, stroke: #F39C12
classDef done fill: #D5F5E3, color: #0E6251, stroke: #28B463
classDef failed fill: #FADBD8, color: #7B241C, stroke: #E74C3C
classDef quarantined fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2
[*] --> READY: checkout 완료
READY --> IN_PROGRESS: PG 승인 요청 수신
READY --> EXPIRED: 만료 (30분)
READY --> RETRYING: 복구 사이클 재진입
IN_PROGRESS --> DONE: PG 승인 성공
IN_PROGRESS --> FAILED: non-retryable 오류
IN_PROGRESS --> RETRYING: retryable 오류
IN_PROGRESS --> QUARANTINED: 한도 소진 + 판단 불가
RETRYING --> DONE: 재시도 성공
RETRYING --> FAILED: non-retryable 또는 한도 소진 + 확정 실패
RETRYING --> RETRYING: 한도 내 재시도
RETRYING --> QUARANTINED: 한도 소진 + 판단 불가
class READY ready
class IN_PROGRESS inprogress
class RETRYING retrying
class DONE done
class FAILED,EXPIRED failed
class QUARANTINED quarantined

복구 사이클의 최종 구조를 플로우차트로 정리하면 다음과 같다.

  • 원자적 선점 성공 → 로컬 종결 여부 확인 → PG 상태 선행 조회 순서로 진입
  • PG 조회 결과에 따라 즉시 종결(DONE/FAILED), 재시도(RETRY_LATER), 승인 요청(ATTEMPT_CONFIRM) 중 하나로 분기
  • 한도를 소진하면 격리 전 최종 확인을 거쳐 QUARANTINE 또는 종결로 분기
  • 보상 TX 실행 전에는 항상 재고 복구 가드가 이중 조건을 검증
flowchart TD
classDef success fill: #D5F5E3, color: #0E6251, stroke: #28B463
classDef retryable fill: #FEF5E7, color: #7E5109, stroke: #F39C12
classDef failure fill: #FADBD8, color: #7B241C, stroke: #E74C3C
classDef quarantine fill: #F3E5F5, color: #4A148C, stroke: #7B1FA2
classDef action fill: #EBF5FB, color: #21618C, stroke: #3498DB
classDef check fill: #FEF9E7, color: #7D6608, stroke: #F1C40F
classDef skip fill: #F5F5F5, color: #616161, stroke: #9E9E9E
A["복구 틱"] --> CL["원자적 선점\n대기 → 처리 중"]:::action
CL -->|" 선점 실패 "| SKIP["다른 워커가 처리 중 → 포기"]:::skip
CL -->|" 선점 성공 "| LC{"결제 종결 여부 검사\n완료/실패/취소 등"}:::check
LC -->|" 이미 종결\n(완료/실패/취소 등) "| REENTRY["재진입 거부\n대기열만 멱등 종결"]:::skip
LC -->|" 비종결\n(대기/진행 중/재시도 중) "| GS["PG 상태 선행 조회"]:::action
GS -->|" 승인 완료 "| SUCCESS["성공 확정"]:::success
GS -->|" 취소/중단/만료 등\nPG 종결 실패 "| FAILURE["실패 확정"]:::failure
GS -->|" PG 기록 없음 "| CONFIRM["PG 승인 요청"]:::action
GS -->|" 타임아웃/5xx/매핑 불가\n+ 한도 미소진 "| RETRY["재시도 대기"]:::retryable
SUCCESS --> TX_DONE["결제 완료\n대기열 종결"]:::success
FAILURE --> GUARD{"재고 복구 가드\n대기열 처리 중?\n결제 비종결?"}:::check
CONFIRM --> CF["PG 승인 요청"]:::action
RETRY --> TX_RETRY["결제 → 재시도 중\n재시도 시점 설정\n대기열 → 대기"]:::retryable
CF -->|" 승인 성공 "| TX_DONE
CF -->|" 재시도 가능 + 한도 미소진 "| TX_RETRY
CF -->|" 재시도 불가 "| GUARD
CF -->|" 재시도 가능 + 한도 소진 "| FINAL
TX_RETRY -->|" 한도 소진 "| FINAL
FINAL["격리 전 최종 확인\nPG 상태 1회 재확인"]:::action
FINAL -->|" 승인 완료 확인 "| TX_DONE
FINAL -->|" PG 종결 실패 "| GUARD
FINAL -->|" 판단 불가 "| QU["격리\n결제 → 격리 상태\n대기열 → 실패\n재고 복원 금지"]:::quarantine
GUARD -->|" 조건 충족 "| TX_COMP["보상 트랜잭션 실행\n재고 복원\n결제 → 실패\n대기열 → 실패"]:::failure
GUARD -->|" 조건 미충족 "| TX_SKIP["대기열 → 실패만\n재고 복원 건너뜀"]:::skip

상태 모델을 재정의하고 복구 결정 로직을 도메인 값 객체에 집중시킨 결과, 복구 사이클이 대응하는 전체 케이스를 다음과 같이 정리할 수 있다.

복구 사이클은 PG 상태 조회 결과를 기준으로 종결 / 재시도 / 격리 중 하나를 결정하여 처리한다.

상황EventOutbox재고
PG에서 승인이 정상 완료된 경우→ DONE→ DONE점유 유지
PG에서 결제가 취소/중단/만료된 경우→ FAILED→ FAILED복원
PG에 결제 기록 자체가 없는 경우승인 결과에 따라 결정승인 결과에 따라 결정점유 유지
PG 조회가 일시적으로 실패한 경우 (한도 내)→ RETRYING→ PENDING (nextRetryAt)점유 유지
PG에서 아직 처리 중인 경우 (한도 내)→ RETRYING→ PENDING (nextRetryAt)점유 유지
위 재시도 케이스에서 한도를 소진한 경우격리 전 최종 확인 결과에 따라 결정격리 전 최종 확인 결과에 따라 결정결과에 따라 결정
이미 로컬에서 종결 처리가 완료된 경우변경 없음→ DONE변경 없음

복구 사이클 뿐만 아니라, 동시성과 장애 상황에 대응하는 스케줄러와 선점 구조도 다음과 같이 정리할 수 있다.

상황EventOutbox재고
Worker가 느려져서 다른 Worker가 먼저 처리를 완료한 경우변경 없음변경 없음가드가 이중 복원 차단
Worker가 비정상 종료하여 처리가 중단된 경우변경 없음→ PENDING점유 유지
다른 Worker가 이미 선점한 경우변경 없음변경 없음변경 없음

비동기 결제 처리 플로우 구현 — Outbox 패턴부터 LinkedBlockingQueue Worker까지

실행 환경: Java 21, Spring Boot 3.4.4, MySQL 8.0, k6

지금까지 Payment Platform 시리즈에서 결제 시스템을 단계별로 개선해왔다.

이전 트랜잭션 범위 최소화에서 외부 API 호출을 트랜잭션 밖으로 분리했지만, confirm 단계의 HTTP 스레드 점유 문제는 여전히 남아 있었다.

  1. 외부 API 응답이 지연 시 그 시간만큼 Tomcat 스레드가 묶임
  2. 요청이 몰리면서 스레드 풀 고갈

이 글은 그 문제를 비동기로 풀어보려 한 시도, 그리고 그 과정에서 마주친 실패와 재설계를 기록한다.

  • Kafka 같은 외부 메시지 브로커나 WebFlux 기반 리액티브 전환 X
  • 애플리케이션 내부 구조만으로 해결하는 방법에 집중

본 포스팅에서 다루는 비동기 결제는 클라이언트의 결제 승인 요청(Confirm)과 실제 외부 PG사 승인 API 호출 프로세스를 기술적으로 분리하는 방식을 의미한다.

  • 핵심 메커니즘: 서버가 PG사의 승인 응답을 대기하지 않고 요청 접수 후 즉시 202 Accepted 응답을 반환
  • 처리 방식: 실제 승인 처리는 백그라운드 워커가 전담하여 수행
  • 도입 목적: 외부 API 지연이 내부 HTTP 스레드 고갈로 전파되는 현상을 차단하여 시스템 가용성 확보

결제 프로세스의 특정 구간을 비동기로 전환함으로써, 클라이언트는 빠른 응답을 받고 서버는 한정된 자원을 효율적으로 관리할 수 있게 된다.


비동기 플로우를 이해하려면 먼저 전체 결제 흐름을 파악해야 한다.

sequenceDiagram
participant Client as 클라이언트
participant Server as 서버
participant DB as DB
participant PG as PG사
Client ->> Server: 주문 생성 요청
Server ->> DB: 결제 대기 생성
Server -->> Client: 201 Created (주문 정보)
Client ->> PG: 결제 위젯 — 카드 정보 입력 및 인증
PG -->> Client: 인증 완료
Client ->> Server: 결제 승인 요청
Server ->> DB: 결제 진행 중 전환 + 처리 대기열 등록
Server ->> PG: PG 승인 요청
Note over Server: 이 단계가 핵심 개선 대상
PG -->> Server: 승인 결과 (완료)
Server ->> DB: 결제 완료 + 대기열 종결
Server -->> Client: 200 OK / 202 Accepted
  • 결제 플로우는 checkout → 인증 → confirm 세 단계
  • confirm 단계는 서버 로직 내에서 직접 호출하면서 승인 완료하는 구조

승인 API 응답 시간은 외부 서비스에 달려 있고, 네트워크 지연 상황이 발생한다면 동기 방식에서 이 지연은 HTTP 스레드가 그대로 묶이게 된다.


본문에서 진행하는 벤치마크는 더욱 입체적인 분석을 위해 k6 상에서 두 가지 시나리오를 동시(Concurrent)에 실행하도록 구성되었다.

1. 테스트 환경의 두 가지 시나리오

Section titled “1. 테스트 환경의 두 가지 시나리오”
  • Open System 시나리오 (TPS 측정용): 초당 요청 발생률(Arrival Rate)을 고정하여 시스템의 최대 처리 용량을 확인하는 방식
    • 특징: 이전 요청의 완료 여부와 상관없이 설정된 속도대로 새로운 요청을 지속적으로 생성
    • 동기 방식 측정: /confirm 요청 후 200 OK를 받을 때까지 대기
    • 비동기 방식 측정: /confirm 요청 후 202 Accepted 응답을 수신 시 (순수하게 서버의 HTTP 수용 능력을 측정)
  • Closed System 시나리오 (E2E Latency 측정용): 일정한 가상 사용자(VU)가 요청을 보내고 응답을 받은 뒤 다음 요청을 보내는 방식
    • 특징: 시스템 내의 동시 사용자 수를 일정하게 유지하며 개별 사용자의 체감 성능을 측정
    • 비동기 환경 테스트 시 이 시나리오에서는 결제가 단순 202 수신으로 끝내지 않음
    • 실제 상태가 DONE이 될 때까지 GET /payments/{orderId} API를 지속적으로 폴링(Polling)하며 대기 후 측정

2. 핵심 지표 정의 (TPS vs E2E Latency)

Section titled “2. 핵심 지표 정의 (TPS vs E2E Latency)”
  • TPS (Transactions Per Second): Open System 시나리오에서 성공적으로 처리한 초당 HTTP 응답 건 수
    • 비동기 환경에서는 서버가 클라이언트 요청을 거절하지 않고 인메모리 큐에 적재하여 ‘202 수락’ 처리를 완료한 초당 건수 의미
  • 결제 완료 시간 (E2E Latency): Closed System 시나리오에서 수집된 지표
    • 최초 /confirm부터 최종적으로 DONE 상태를 받아볼 때까지 폴링에 소모된 모든 대기 시간과 네트워크 오버헤드를 완전히 포함한 물리적(체감) 소요 시간

결제 승인(Confirm) 시 호출하는 PG API(Toss Payments)의 특성에 맞추어 두 가지 시나리오로 나누어 테스트했다.

  • 저지연(Low Latency): API 승인 응답이 100~300ms 내외로 즉시 반환되는 쾌적한 평상시 환경
  • 고지연(High Latency): 트래픽 스파이크 또는 외부 시스템(카드/VAN사) 장애로 승인 응답이 2,000~3,500ms로 극심하게 지연되는 환경

동기 confirm 플로우를 시각화하면 다음과 같다.

sequenceDiagram
participant Client as 클라이언트 (k6 VU)
participant Tomcat as Tomcat 스레드 풀
participant PG as PG API (2~3.5s 지연)
Client ->> Tomcat: 결제 승인 요청 (VU 1)
Tomcat ->> PG: PG 승인 요청 [스레드 점유 시작]
Client ->> Tomcat: 결제 승인 요청 (VU 2)
Tomcat ->> PG: PG 승인 요청 [스레드 점유]
Note over Tomcat: ...스레드 200개 모두 점유...
Client ->> Tomcat: 결제 승인 요청 (VU N)
Note over Tomcat: ⚠️ 스레드 고갈 — 요청 대기 또는 거절
PG -->> Tomcat: 응답 반환 (2~3.5초 후)
Tomcat -->> Client: 200 OK
  • Tomcat 기본 스레드 풀은 200개
  • Toss API 평균 응답이 2.75초 가정
    • 스레드 하나가 2.75초씩 묶임 → 동시 처리 가능한 요청 수가 스레드 수에 영향

100 req/s 목표에서 TPS는 53에 그치고, Confirm(결제 승인 요청) p95는 36초에 달했다.

케이스TPSAPI 응답 medAPI 응답 p95처리 불가 요청
sync-high (2~3.5s 지연)53.010,199ms36,079ms6,575
  1. 100 req/s가 들어오면 1초 동안 100개의 스레드가 Toss API 대기 상태 진입 후 각 스레드가 평균 2.75초를 점유
  2. 2~3초 뒤에는 동시에 200개 이상의 요청이 스레드를 점유하게 되어, Tomcat 스레드 풀(200개) 포화
  3. 이 시점 이후 도착하는 요청은 빈 스레드가 생길 때까지 대기하거나 거절

해결책은 confirm 요청이 들어오면 Toss API 호출 없이 즉시 202를 반환하고, 백그라운드에서 실제 승인을 처리하는 구조다.

  • @Async로 비동기 시 인메모리 이벤트 한계로 서버 재시작이나 처리 실패 시 이벤트가 유실될 위험 존재
  • 결제 데이터는 한 건도 빠져서는 안 되므로, DB에 PENDING 레코드를 남겨 안전망을 확보하는 Outbox 패턴 함께 도입
graph TB
%% 클래스 정의 (가독성 및 테마 대응 규칙 적용)
classDef main fill: #E1F5FF, stroke: #0078D4, color: #000
classDef pending fill: #FFF2CC, stroke: #D79B00, color: #000
classDef success fill: #E8F5E9, stroke: #2E7D32, color: #000
classDef external fill: #F5F5F5, stroke: #333, color: #000
classDef recovery fill: #FADAD8, stroke: #B85450, color: #000
classDef note fill: #FFFFFF, stroke: #333, color: #000
subgraph SyncPath ["결제 승인 핵심 경로 — 단일 TX"]
direction TB
A["결제 승인 요청"]:::external --> B["결제 진행 중 전환"]:::main
B --> C["처리 대기열 등록"]:::pending
C --> D["커밋 후 이벤트 발행"]:::main
D --> E["202 Accepted 즉시 반환"]:::note
end
subgraph Polling ["클라이언트 상태 폴링"]
direction TB
P1["결제 상태 조회"]:::external --> P2{"결제 상태 확인"}:::pending
P2 -- " 진행 중 " --> P3["일정 시간 대기"]:::note
P3 -.->|재조회| P1
P2 -- " 완료 " --> P4["최종 결제 완료 처리"]:::success
end
subgraph Background ["백그라운드 처리"]
direction TB
F["커밋 후 이벤트 수신"]:::main --> G["PG 승인 요청"]:::external
G --> H["결제 완료"]:::success
H --> I["대기열 종결"]:::success
%% 폴백 및 복구 로직
J["폴링 복구기 (주기 폴백)"]:::recovery --> K["미처리 건 배치 조회"]:::pending
K --> G
end
%% 컴포넌트 간 연결 로직
E -->|" 202 응답 후 폴링 시작 "| P1
D -.->|" 커밋 후 이벤트 전달 "| F
C -.->|" 서버 장애 시 미처리분 복구 "| J

Outbox 패턴은 두 가지 처리 경로를 만든다.

  • Fast Path: TX 커밋 직후 ApplicationEvent가 발행되어 즉시 처리
  • Safety Track: OutboxWorker가 주기적으로 PENDING 레코드를 폴링하여 누락 건 처리

OutboxWorker(폴링)가 안전망 역할을 맡기 때문에, Fast Path가 실패해도 결제 데이터는 유실되지 않고, PENDING 레코드가 DB에 남아 있는 한 최종 처리가 보장된다.


1차 구현 - @Async + 동시 실행 제한 없음

Section titled “1차 구현 - @Async + 동시 실행 제한 없음”

Fast Path의 첫 번째 구현은 @Async@TransactionalEventListener의 조합이었다.

// OutboxImmediateEventHandler.java (1차)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handle(PaymentConfirmEvent event) {
outboxProcessingService.process(event.getOrderId());
}
  • @TransactionalEventListener(AFTER_COMMIT)은 TX가 커밋된 이후에만 이벤트를 처리해, 미커밋 PENDING 레코드를 Worker가 조회하는 경쟁 조건을 방지
  • @Async는 처리를 별도 스레드로 위임해 HTTP 스레드 해제

첫 구현에는 동시 실행 제한(concurrencyLimit)은 설정하지 않고 진행했다.

제한 없이 벤치마크를 돌리자 정상 처리가 불가능한 상태에 빠졌다.

graph TD
%% 클래스 정의 (가독성 및 장애 단계 구분)
classDef main fill: #E1F5FF, stroke: #0078D4, color: #000
classDef pending fill: #FFF2CC, stroke: #D79B00, color: #000
classDef external fill: #F5F5F5, stroke: #333, color: #000
classDef recovery fill: #FADAD8, stroke: #B85450, color: #000
A["결제 승인 요청"]:::external --> B["비동기 핸들러 동시 실행"]:::main
B --> C["각 처리기: DB 커넥션 점유"]:::main
%% 장애 가속 구간
C --> D["DB 커넥션 풀 한계 도달"]:::pending
D --> E["신규 승인 요청도 DB 커넥션 대기"]:::pending
%% 최종 장애 상태
E --> F["에러율 80%, TPS ≈ 0<br/>(시스템 장애)"]:::recovery
%% 위험 흐름 강조
linkStyle 2,3,4 stroke: #FF3333, stroke-width: 2px

원인은 Backpressure(생산 속도가 소비 속도를 초과할 때 생산을 억제하는 메커니즘) 부재였다.

  1. 202를 빠르게 반환하는 만큼 ImmediateHandler 동시 실행 수가 폭발적으로 증가
  2. 각 핸들러가 Toss API 호출 중 DB 커넥션을 점유하면서 HikariCP 고갈
  3. 커넥션이 바닥나면서 confirm 요청 TX 자체도 커넥션을 확보하지 못해 연쇄 실패

HTTP 스레드가 빠르게 이벤트를 쏟아내는 반면 Toss API 호출이 느려 소비가 따라가지 못하면서, 동시 실행 수를 제한하지 않은 구조에서 공유 자원(DB 커넥션)이 순식간에 바닥난 것이다.


HikariCP 고갈을 막기 위해 SimpleAsyncTaskExecutor에 동시 실행 상한을 설정했다.

// AsyncConfig.java (2차)
@Bean
public AsyncTaskExecutor immediateHandlerExecutor(
@Value("${outbox.immediate.concurrency-limit:200}") int concurrencyLimit,
@Value("${outbox.immediate.virtual-threads:true}") boolean virtualThreadsEnabled) {
SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("outbox-immediate-");
executor.setVirtualThreads(true);
executor.setConcurrencyLimit(concurrencyLimit); // N = 10, 50, 100, 200, 250으로 실험
return executor;
}

N(동시 실행 상한)을 다양하게 바꿔가며 벤치마크를 돌렸을 때, 예상과 다른 결과가 나왔다.

케이스TPS처리 불가 요청
동시 제한 10 / 저지연8.111,022
동시 제한 100 / 저지연23.810,472
동시 제한 200 / 저지연26.710,096
동시 제한 200 / 고지연24.010,434

동시 제한을 걸었을 때 처리 불가 요청이 폭발적으로 늘어나면서, 비동기로 전환했는데 기대한 성능이 나오지 않는 상황이 발생했다.

원인 분석 - ConcurrencyThrottleSupport

Section titled “원인 분석 - ConcurrencyThrottleSupport”

SimpleAsyncTaskExecutorConcurrencyThrottleSupport를 상속하며, setConcurrencyLimit(N)을 설정하면 내부에서 다음 로직이 실행된다.

// Spring ConcurrencyThrottleSupport 내부 (Spring Framework 6.2.x)
private final Lock concurrencyLock = new ReentrantLock();
private final Condition concurrencyCondition = this.concurrencyLock.newCondition();
protected void beforeAccess() {
if (this.concurrencyLimit > 0) {
this.concurrencyLock.lock();
try {
while (this.concurrencyCount >= this.concurrencyLimit) {
this.concurrencyCondition.await(); // ← 호출 스레드가 여기서 블로킹
}
this.concurrencyCount++;
} finally {
this.concurrencyLock.unlock();
}
}
}

슬롯이 없으면 호출 스레드 자체를 await()으로 블로킹하여, 큐에 적재하고 즉시 반환하는 것이 아니라, 빈 슬롯이 생길 때까지 호출자를 직접 잠그는 방식으로 동작한다.

sequenceDiagram
participant HTTP as HTTP 스레드
participant AFTER_COMMIT as AFTER_COMMIT 훅
participant Executor as 비동기 실행기
participant Worker as 처리 슬롯 N개
HTTP ->> Hook: 커밋 후 이벤트 핸들러 실행
Hook ->> Executor: 작업 위임 요청
Note over Executor: 슬롯 N개 모두 점유 중
Executor ->> HTTP: 빈 슬롯 대기 — 블로킹!
Note over HTTP: HTTP 응답을 보내지 못한 채 대기
Worker -->> Executor: 슬롯 반환
Executor ->> HTTP: 대기 해제
HTTP -->> Hook: 완료
HTTP -->> HTTP: 202 응답 전송 (수백~수천 ms 지연)
  • executor.execute(task)를 호출한 쪽, 즉 AFTER_COMMIT 훅을 실행 중인 HTTP 플랫폼 스레드 블로킹
  • Tomcat의 HTTP 스레드(플랫폼 스레드, max=200)가 슬롯 대기에 묶이므로, Executor 내부에서 가상 스레드를 사용하더라도 HTTP 응답 지연 문제 발생

클라이언트 입장에서는 confirm 요청을 보낸 후 응답을 받지 못한 채 대기하게 되고, 요청 적체 → 처리 불가 요청 폭발로 이어진다.

구현문제
제한 없음 (1차)Backpressure 부재 → HikariCP 고갈 → 에러율 80%
setConcurrencyLimit(N) (2차)호출 스레드 직접 블로킹 → HTTP 응답 지연 → 처리 불가 요청 폭발

N(동시 처리 수)을 아무리 키워도 구조적 한계가 있었으며, 고지연 환경에서는 동시에 처리해야 할 요청 수가 N을 쉽게 넘어서고, N을 무한히 키우면 1차와 같은 자원 고갈이 재현된다.


최종 해결 - LinkedBlockingQueue + Worker 가상 스레드

Section titled “최종 해결 - LinkedBlockingQueue + Worker 가상 스레드”

문제는 작업을 위임하는 과정에서 HTTP 스레드가 자유롭지 못하다는 것이다.

  • 1차: 동시 실행 수를 제한하지 않아 DB 커넥션이 고갈
  • 2차: 동시 실행 수를 제한하자 슬롯 대기가 HTTP 스레드를 묶음

이 전제를 깨기 위해, AFTER_COMMIT 훅이 하는 일을 극도로 가볍게 만들어 큐에 넣는 방식으로 재설계하는 방향을 탐색했다.

API 요청을 받은 HTTP 톰캣 스레드는 이벤트를 큐에 넣은 후 즉시 결과를 반환하고, 별도 Worker 가상 스레드들이 큐에서 이벤트를 꺼내 처리한다.

Before: HTTP 스레드 → execute(task) → await() → [블로킹] → 응답 지연
After: HTTP 스레드 → queue.offer(orderId) → [즉시 반환] → 202 응답
Worker VT-1 → queue.take() → Toss API → markDone()
Worker VT-2 → queue.take() → Toss API → markDone()
Worker VT-N → queue.take() → (큐 Empty → 대기)
graph TB
%% 클래스 정의 (역할별 색상 구분 및 가독성 확보)
classDef http fill: #E1F5FF, stroke: #0078D4, color: #000
classDef queue fill: #FFF2CC, stroke: #D79B00, color: #000
classDef worker fill: #E8F5E9, stroke: #2E7D32, color: #000
classDef fallback fill: #FADAD8, stroke: #B85450, color: #000
classDef external fill: #F5F5F5, stroke: #333, color: #000
classDef response fill: #FFFFFF, stroke: #333, color: #000
subgraph SyncPath ["HTTP 경로 — 빠른 반환"]
direction TB
A["결제 승인 요청"]:::external --> B["결제 승인 처리 (단일 TX)"]:::http
B --> C["처리 대기열 등록"]:::queue
C --> D["커밋 후 이벤트 발행"]:::http
D --> E["커밋 후 이벤트 핸들러"]:::http
E --> F["처리 큐에 등록<br/>(비블로킹)"]:::http
F --> G["202 Accepted (즉시)"]:::response
end
subgraph Channel ["처리 큐 (인메모리 버퍼)"]
H["처리 큐"]:::queue
F --> H
end
subgraph WorkerPath ["워커 경로 — 비동기 처리 (가상 스레드)"]
direction TB
I["실시간 워커"]:::worker
I --> J["워커 1: 큐에서 수신"]:::worker
I --> K["워커 2: 큐에서 수신"]:::worker
I --> L["워커 N: 큐에서 수신"]:::worker
J --> M["결제 복구 처리"]:::worker
K --> M
L --> M
M --> N["PG 승인 요청 + 결제 완료 처리"]:::external
end
subgraph FallbackPath ["폴백 경로 (배치 복구)"]
O["폴링 복구기"]:::fallback
O --> P["미처리 건 배치 조회 → 복구 처리"]:::queue
P --> M
end
%% 컴포넌트 간 상호작용
G -->|" 202 응답 후 폴링 시작 "| CP1["결제 상태 조회"]:::external
H --> J
H --> K
H --> L
C -.->|" 큐 오버플로우 시 대기 유지 "| O
%% 연결선 스타일
linkStyle 13,14,15 stroke: #333, stroke-width: 2px
linkStyle 16 stroke: #FF3333, stroke-dasharray: 5 5
public class PaymentConfirmChannel {
private final LinkedBlockingQueue<String> queue;
public boolean offer(String orderId) {
return queue.offer(orderId); // 비차단: 큐 가득 차면 false 즉시 반환
}
// ...
}

offer()는 큐가 가득 찼을 때 false를 반환하면서, HTTP 스레드는 블로킹되지 않게 된다.

1차 / 2차에서 @Async + OutboxProcessingService.process() 호출이었던 핸들러가 channel.offer() 한 줄로 단순화됐다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(PaymentConfirmEvent event) {
boolean offered = channel.offer(event.getOrderId());
if (!offered) {
log.warn("PaymentConfirmChannel 오버플로우 — OutboxWorker가 처리 예정");
}
}

@Async가 제거되고, offer()는 비차단이므로 AFTER_COMMIT 훅 안에서 호출해도 HTTP 스레드가 블로킹되지 않는다.

@TransactionalEventListener(AFTER_COMMIT) 유지 이유

Section titled “@TransactionalEventListener(AFTER_COMMIT) 유지 이유”
  • TX 커밋 전에 Worker가 queue.take()로 PENDING 레코드를 조회하면 미커밋 상태의 레코드를 보게 되는 경쟁 조건 발생
  • AFTER_COMMIT을 보장함으로써 Worker가 조회하는 시점에 PENDING 레코드가 DB에 확실히 존재 보장

Worker의 핵심은 SmartLifecycle을 구현해 Spring 컨테이너의 start/stop 훅에 연동하고, 고정 수의 가상 스레드가 take() → process() 루프를 돌리게 된다.

@Component
@RequiredArgsConstructor
public class OutboxImmediateWorker implements SmartLifecycle {
private final PaymentConfirmChannel channel;
private final OutboxProcessingService outboxProcessingService;
@Value("${outbox.channel.worker-count:200}")
private int workerCount;
// ...
// start()에서 workerCount개의 가상 스레드 기동
// 각 스레드는 아래 루프를 실행
private void workerLoop() {
while (!Thread.currentThread().isInterrupted()) {
try {
String orderId = channel.take();
outboxProcessingService.process(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// ...
}
  • Spring 컨테이너가 종료될 때 stop() 훅이 호출되면, 진행 중인 작업을 마무리한 뒤 Worker 스레드를 interrupt로 정리하는 Graceful Shutdown 수행
  • 강제 종료 등 시나리오: 큐에 남아있던 이벤트는 DB에 PENDING 레코드로 이미 저장되어 있으므로 OutboxWorker 폴링이 재시작 후 이어서 처리
    • 재요청 되더라도 멱등키를 포함한 confirm 요청으로, 중복 결제 방지

가상 스레드는 아래 두 가지 구간 모두 캐리어 스레드(OS 스레드)를 반납하고 unmount된다.

  1. 큐가 비었을 때 queue.take() 대기
  2. Toss API 호출 시 HTTP I/O 대기

플랫폼 스레드였다면 300개의 Worker가 각각 OS 스레드를 점유하지만, 가상 스레드는 실제 작업 중인 소수만 OS 스레드를 사용한다.

sequenceDiagram
participant Queue as 처리 큐
participant VT as Worker 가상 스레드
participant OS as OS 스레드 (캐리어)
participant PG as PG API
Note over VT: 큐가 비어있는 상태
VT ->> Queue: 큐에서 대기
Queue -->> VT: 대기 (큐 비어있음)
VT -->> OS: 캐리어 반납 (unmount)
Note over OS: 다른 가상 스레드가 사용 가능
Note over Queue: 이벤트 도착
Queue ->> VT: 이벤트 도착 → 깨어남
OS ->> VT: 캐리어 할당 (mount)
VT ->> PG: PG 승인 요청 (I/O 대기)
VT -->> OS: 캐리어 반납 (unmount)
PG -->> VT: 응답 수신
OS ->> VT: 캐리어 재할당
VT ->> VT: 결제 완료 처리

Worker가 DB 작업(claimToInFlight, markDone)을 수행할 때 한 가지 문제점이 존재했다.

  • 문제: MySQL Connector/J 8.x는 JDBC 처리 경로에 synchronized 블록을 사용하여, 가상 스레드가 캐리어 스레드 고정(Pinning)
  • 영향: 가상 스레드의 확장성 이점이 DB 작업 구간에서 무력화
  • 해결: Spring Boot를 3.3.3에서 3.4.4로 업그레이드 → Connector/J 9.1.0이 자동 포함되며, synchronizedReentrantLock으로 교체되어 Pinning 해소

ConfirmImmediateWorker 하나로 처리하는 것이 아니라, OutboxWorker라는 폴백 경로를 두어, 이상 상황에서도 PENDING 레코드가 남아 있는 한 최종 처리가 보장되는 구조로 설계했다.

graph LR
%% 클래스 정의 (가독성 및 테마 대응 규칙 적용)
classDef entry fill: #F5F5F5, stroke: #333, color: #000
classDef data fill: #FFF2CC, stroke: #D79B00, color: #000
classDef fast fill: #E1F5FF, stroke: #0078D4, color: #000
classDef safety fill: #FADAD8, stroke: #B85450, color: #000
classDef success fill: #E8F5E9, stroke: #2E7D32, color: #000
A["결제 승인 요청"]:::entry --> B["DB: 대기열 등록"]:::data
B --> C{"큐 등록"}:::data
%% Fast Track: 실시간성 보장
C -- " 성공 " --> D["실시간 경로<br/>(워커 즉시 처리)"]:::fast
%% Safety Track: 안정성 보장 (Backpressure)
C -- " 실패 (큐 가득 참) " --> E["대기열 유지"]:::safety
E --> F["복구 경로<br/>(폴링 복구기 3초 주기)"]:::safety
%% 장애 복구 트리거
H["서버 재시작"]:::entry --> F
%% 최종 결과
D --> G["결제 완료"]:::success
F --> G
%% 연결선 스타일
linkStyle 2 stroke: #0078D4, stroke-width: 2px, color: #0078D4
linkStyle 3 stroke: #FF3333, stroke-width: 2px, color: #FF3333
  • OutboxImmediateWorker(Fast Track): 메모리 채널(LinkedBlockingQueue)에서 이벤트를 꺼내 즉시 처리하는 성능 가속기(정상 상황에서 대부분의 이벤트에 해당)
  • OutboxWorker(Safety Track): DB에 남아있는 PENDING 레코드를 3초 간격으로 폴링하여 처리하는 최후 방어선
    • 큐 오버플로우, 서버 재시작, Worker 처리 실패 등 이상 상황에서의 결제 데이터 유실을 방지

Fast Track 인메모리 큐의 서버 종료 시 소실된다는 단점을 Safety Track으로 결제 데이터의 최종 처리를 보장하는 이중 트랙 구조로 보완했다.


벤치마크 — 아키텍처 성능 검증 및 최적화

Section titled “벤치마크 — 아키텍처 성능 검증 및 최적화”

아홉 차례 이상의 실험을 통해 초기 아키텍처의 한계를 진단하고, 시스템 경합 상황을 거쳐 최종적인 성능 최적화 수치를 도출하는 과정을 4단계로 정리하면 다음과 같다.

전체 벤치마크 리포트

1단계: 초기 @Async 방식의 구조적 한계

Section titled “1단계: 초기 @Async 방식의 구조적 한계”

LinkedBlockingQueue 도입 전, @Async와 ConcurrencyThrottleSupport 조합의 한계를 확인했다.

  • 자원 고갈: 동시 실행 제한이 없을 경우 HikariCP 커넥션 풀이 즉각 고갈되어 에러율 80%
  • 응답 지연: 동시 실행 제한(N) 적용 시 호출 스레드 자체가 블로킹되어 HTTP 응답 시간이 Toss API 지연 시간과 동기화됨
  • 결론: 큐 적체 기능이 없는 단순 비동기 처리는 고부하 상황에서 안전장치 역할을 수행할 수 없음

2단계: LBQ + Worker 아키텍처 기본 성능 검증

Section titled “2단계: LBQ + Worker 아키텍처 기본 성능 검증”

LinkedBlockingQueue와 가상 스레드 Worker를 도입한 뒤, 백그라운드 프로세스가 없는 클린 환경에서 기준 성능을 측정했다.

지연 환경케이스TPSAPI 응답 med결제 완료 med처리 불가 요청
고지연 (2.0~3.5s)동기54.36,118ms3,221ms1,934
고지연 (2.0~3.5s)비동기71.27ms2,872ms0
저지연 (0.1~0.3s)동기107.3212ms213ms0
저지연 (0.1~0.3s)비동기84.18ms308ms0
  • 가용성 확보: 외부 지연 상황에서도 비동기 방식은 7ms 내외의 응답 속도를 유지하며 유실 0건 달성
  • 비동기 우위 확인: 고지연 환경에서 동기 방식 대비 우수한 처리량과 가용성 입증

3단계: 테스트 환경 변수 — 연속 부하 시 인프라 경합

Section titled “3단계: 테스트 환경 변수 — 연속 부하 시 인프라 경합”

부하 테스트 파라미터(MAX_VUS 1000, Capacity 5000) 상향 후 반복 측정을 통해 시스템 가변성을 확인했다.

지연 환경케이스TPSAPI 응답 med비고
고지연동기39.89,075ms성능 저하 발생
고지연비동기63.4153ms응답 시간 급등
저지연동기82.21,646ms지연 시간 폭증
저지연비동기74.31,091ms자원 경합 심화
  • 성능 변동성: 반복되는 테스트로 인한 Docker VM 자원 경합 및 CPU 온도 상승이 지표에 반영
  • 인프라 영향력: 애플리케이션 아키텍처뿐만 아니라 실행 환경의 물리적 컨디션이 임계 성능에 지대한 영향을 미침을 확인

4단계: 최종 최적화 및 임계 성능 도출

Section titled “4단계: 최종 최적화 및 임계 성능 도출”

반복 실험을 통해 도출한 최적 파라미터 조합을 적용하여 최종 성능을 확정했다.

지연 환경케이스TPSAPI 응답 med결제 완료 med결제 완료 p95처리 불가 요청
고지연 (2.0~3.5s)sync-high54.1/s6,157ms3,190ms9,343ms1,945
고지연 (2.0~3.5s)outbox-high79.8/s5.3ms2,820ms3,423ms0
저지연 (0.1~0.3s)sync-low106.4/s210ms211ms299ms0
저지연 (0.1~0.3s)outbox-low93.5/s6.3ms305ms321ms0

HikariCP 30, Worker 300, Batch Size 100 설정을 기준으로 진행했다.

  • 성능 혁상: 고지연 환경에서 동기 전략 대비 약 47.5%의 압도적인 TPS 향상 달성
  • 자원 효율성: 커넥션 풀을 30으로 최적화하여 컨텐션 비용을 줄이고 시스템 안정성을 강화
  • 고가용성: 기존 다수의 처리 불가 요청을 0건으로 유지

단순한 동기 confirm 플로우에서 안전한 비동기 Outbox 패턴으로 아키텍처를 진화시키며, 다음 세 가지 인사이트를 얻을 수 있었다.

1. 비동기 아키텍처에서의 Backpressure 중요성

Section titled “1. 비동기 아키텍처에서의 Backpressure 중요성”
  • 동시 실행 제한 없이 @Async 적용 시 HikariCP 고갈(에러율 80%) 발생 및 제한 시 HTTP 스레드 블로킹(처리 불가 요청 10,000건 이상) 유발
  • LinkedBlockingQueue 용량 상한과 Worker 수를 통해 생산 및 소비 속도의 균형을 맞추는 구조 필요

2. 인메모리 채널 기반 메시지 발행의 위험성

Section titled “2. 인메모리 채널 기반 메시지 발행의 위험성”
  • 서버 재시작, 큐 오버플로우, Worker 처리 실패 등 메시지 유실 가능성이라는 단점 존재
  • OutboxImmediateWorker(Fast Track)가 성능 / OutboxWorker(Safety Track)가 안전을 담당하는 이중 트랙 구조로 보완

3. 비동기 결제 패턴의 핵심 가치 - 시스템 가용성 확보

Section titled “3. 비동기 결제 패턴의 핵심 가치 - 시스템 가용성 확보”

외부 API 지연이 스레드 고갈로 전파되는 것을 격리하는 역할이며, 클린 환경 최종 검증에서 이를 확인했다.

핵심 지표동기 방식비동기(Outbox)
고지연 처리 불가 요청1,945건 유실0건 유실 (-100%)
고지연 결제 응답성 (Confirm med)6,157ms (마비)7ms
고지연 전체 처리량 (TPS)54.179.8 (+47%)
상시 저지연 처리 속도213ms (우위)308ms
  • 아키텍처 트레이드오프: 저지연 환경에서 Sync가 유리한 것은 자연스러운 결과
  • 안전장치로서의 가치: Outbox 패턴은 외부 API 장애 시 시스템이 무너지지 않도록 지탱하는 가치를 데이터로 입증

Checkout API 멱등성 보장 — Caffeine 캐시와 TOCTOU 경쟁 조건 해결

실행 환경: Java 21, Spring Boot 3.4.x, MySQL 8.0

결제 플랫폼의 POST /checkout API는 호출마다 새 orderId를 발급하고 PaymentEvent를 생성한다. UI 중복 클릭, 클라이언트 네트워크 재시도, 버그로 인한 반복 호출이 발생하면 각 요청이 독립적인 PaymentEvent를 생성해 DB에 READY 상태의 유효하지 않은 주문이 누적된다.

프로젝트는 단일 서버 벤치마크 환경이므로 별도 인프라 없이 구현 가능한 Caffeine을 선택했다.

방식장점단점
클라이언트 멱등성 키Stripe 등 표준 패턴, 의미 명확클라이언트 협력 필요
DB Unique Constraint추가 인프라 불필요, 트랜잭션 정합성 보장예외 흐름 제어, TTL 관리 필요
Redis SETNX EX원자적, TTL 자동, 다중 서버 지원Redis 인프라 필요
Caffeine 인메모리 캐시인프라 불필요, 구현 단순, 원자적 API 지원서버 재시작 시 소실, 단일 서버만 가능

단, 추후 Redis로 교체할 수 있도록 IdempotencyStore를 outbound port 인터페이스로 추상화해 구현체를 분리했다.

키 전략은 두 가지 방식을 모두 지원하도록 설계했다.

  • 클라이언트가 Idempotency-Key 헤더를 제공하면 해당 값을 우선 사용
  • 헤더가 없으면 서버가 SHA-256(userId + sortedProductIds + quantities)로 body hash를 자동 파생
    • productId 기준 정렬을 적용한 이유: 상품 순서가 달라도 동일 요청으로 판단

HTTP 응답은 최초 생성 시 201 Created, 중복 요청 시 200 OK로 구분해 클라이언트가 신규/기존 여부를 판단할 수 있도록 했다.

포트 인터페이스를 먼저 정의하고 구현체를 infrastructure 레이어에 두는 포트-어댑터 패턴을 적용했다.

public interface IdempotencyStore {
Optional<CheckoutResult> getIfPresent(String key);
void put(String key, CheckoutResult result);
}
  • 서비스는 캐시 적중 여부를 확인 후 없으면 생성, 있으면 isDuplicate=true를 붙여 반환하는 흐름으로 구현
  • 단위 테스트용 FakeIdempotencyStoreHashMap 기반으로 구현

코드 리뷰에서 발견된 문제 — TOCTOU 경쟁 조건

Section titled “코드 리뷰에서 발견된 문제 — TOCTOU 경쟁 조건”

구현 후 코드 리뷰를 진행하면서 근본적인 문제가 발견되었다.

sequenceDiagram
participant A as Thread A
participant B as Thread B
participant Cache
A ->> Cache: get("key") → miss
B ->> Cache: get("key") → miss
A ->> A: create() → PaymentEvent#1
B ->> B: create() → PaymentEvent#2
A ->> Cache: put("key", result#1)
B ->> Cache: put("key", result#2)
Note over Cache: ⚠️ 중복 생성 발생
  • getIfPresentput 사이에 원자성이 보장되지 않음
  • 두 스레드가 동시에 miss를 확인하면 둘 다 PaymentEvent를 생성하는 TOCTOU(Time-Of-Check-Time-Of-Use) 경쟁 조건이 발생

단일 스레드로 실행되는 단위 테스트는 HashMap 기반 Fake로 문제없이 통과하면서, 테스트가 구현 결함을 검증하고 못하고 있었다.

2차 개선 — getOrCreate 원자적 패턴으로 재설계

Section titled “2차 개선 — getOrCreate 원자적 패턴으로 재설계”

TOCTOU를 해결하는 세 가지 방식을 검토했고, 이 중 getOrCreate 방식을 선택했다.

옵션장점단점
getOrCreate(key, supplier)포트 계약이 원자성 표현, 구현체 교체 용이동일 키 동시 요청에서 후발 스레드 블록
DB Unique Constraint영구 저장, 분산 환경 안전스키마 변경 필요, 예외 흐름 제어
Fail-fast (409)블로킹 없음클라이언트 retry 복잡도 증가, UX 저하

getOrCreate의 단점은 동일 키 동시 요청에서 후발 스레드가 블로킹 되지만, 큰 리스크가 없다고 판단했다.

  • 블로킹은 동일 키를 가진 요청 사이에만 발생하여 다른 키를 가진 요청은 영향을 받지 않음
  • 클라이언트에서 두 번의 요청(따닥)이 연속해서 오고, 실패 처리를 하게 되면 아예 재요청해야하는 것보다 이미 생성된 값을 주는 것이 낫다고 판단
sequenceDiagram
participant A as Thread A
participant B as Thread B
participant Cache as Caffeine Cache
A ->> Cache: get("key", loader)
Cache -->> A: (lock acquired, loader 실행 중)
A ->> A: create() → PaymentEvent#1
B ->> Cache: get("key", loader)
Cache -->> B: (동일 키 → lock wait)
A ->> Cache: 결과 저장 후 lock 해제
Cache -->> B: hit → PaymentEvent#1 반환 (loader 미실행)
Note over Cache: ✅ 중복 생성 없음

단순히 구현체만 바꾸는 것이 아니라 포트 인터페이스 시그니처도 변경했다.

IdempotencyResult<CheckoutResult> getOrCreate(String key, Supplier<CheckoutResult> creator);
  • 기존: getIfPresent + put 구조에서 포트는 두 메서드를 노출하고, 원자성을 어떻게 보장할지 호출자에게 책임 발생
  • 개선: getOrCreate로 변경하면 포트 계약 자체가 원자적 수행 표현하여, 호출자는 원자성 구현 방식을 알 필요 없이 구현체만 교체 가능

동시성 E2E 통합 테스트 — PaymentCheckoutConcurrencyIntegrationTest

Section titled “동시성 E2E 통합 테스트 — PaymentCheckoutConcurrencyIntegrationTest”

단위 테스트만으로는 실제 경쟁 조건을 재현하기 어렵다고 판단하여, MySQL + MockMvc 환경의 멀티 스레드 테스트를 진행했다.

시나리오검증 내용
동일 키, N개 스레드 동시 요청payment_event 1개만 생성, 모든 요청 2xx
동일 키, 순차 2회 요청첫 번째 201 Created, 두 번째 200 OK, DB 레코드 1개
서로 다른 키, N개 동시 요청payment_event N개 독립 생성, 모두 201 Created

getIfPresent + put 구조는 TOCTOU 경쟁 조건을 내포하는 것에서, getOrCreate 단일 원자적 메서드로 포트 계약을 재설계해 문제를 해결했다.

구현체원자성 방법적합 환경
IdempotencyStoreImplCache.get(key, loader) — loader 1회 실행 보장단일 JVM
RedisIdempotencyStore (미래)SETNX 선예약 → 실행 → 결과 저장다중 인스턴스
FakeIdempotencyStore (테스트)ConcurrentHashMap.computeIfAbsent테스트 환경
  • 서버 재시작 시 Caffeine 캐시 휘발 문제 존재
  • 프로덕션 전환 시 다음 중 하나 고려
    • DB Unique Constraint: idempotency_key 유니크 제약으로 최후 제약 조건 추가
    • Redis 구현체: SETNX 기반 분산 원자적 처리로 포트 교체

전략 패턴을 통한 결제 게이트웨이 추상화 및 확장성 확보

실행 환경: Java 21, Spring Boot 3.4.4

초기 구현에서는 특정 PG(Toss Payments)에 강하게 결합된 구조로, 결제 시스템은 비즈니스 요구에 따라 PG를 유연하게 전환할 수 없는 구조였다.

  • 도메인/애플리케이션 레이어에 Toss-specific 타입 직접 사용 (TossPaymentInfo, TossPaymentDetails)
  • PG 변경 시 핵심 비즈니스 로직 수정 필요로 인한 높은 결합도
  • 여러 PG 동시 지원 불가능으로 비즈니스 확장성 제약

이러한 문제를 해결하기 위해 전략 패턴과 포트-어댑터 패턴을 결합하여 PG 독립적인 아키텍처를 구축하고, 실제로 NicePay를 두 번째 PG로 추가하여 멀티 PG 운영을 달성했다.

목표달성 방법
도메인 모델 PG 독립화 (PG 변경 시 비즈니스 로직 무수정)포트-어댑터 패턴으로 경계 분리
새로운 PG 추가 시 기존 코드 무영향 (OCP)전략 패턴으로 구현체 캡슐화
PG마다 다른 멱등성 처리를 상위 레이어에 노출하지 않음구현체 내부에서 에러 감지 및 보상

핵심은 추상화를 통한 의존성 역전으로, 도메인 레이어는 구체적인 PG 구현체가 아닌 추상화된 인터페이스에만 의존하도록 설계했다.

애플리케이션 레이어는 구체적인 구현이 아닌 Port 인터페이스에만 의존하며, 실제 PG 통신 로직은 Infrastructure 레이어의 Strategy 구현체에서 처리된다.

  • 현재 Toss와 NicePay 두 전략 구현
  • 결제건마다 gatewayType으로 올바른 PG를 라우팅
graph TB
subgraph "Application Layer"
Service[OutboxAsyncConfirmService]
UseCase[PaymentCommandUseCase]
Port[PaymentGatewayPort<br/>Interface]
end
subgraph "Infrastructure Layer"
Adapter[InternalPaymentGatewayAdapter<br/>Port 구현체]
Factory[PaymentGatewayFactory<br/>전략 선택]
Strategy[PaymentGatewayStrategy<br/>Interface]
subgraph "Strategy Implementations"
Toss[TossPaymentGatewayStrategy]
Nicepay[NicepayPaymentGatewayStrategy]
end
end
subgraph "External Systems"
TossAPI[Toss Payments API]
NicepayAPI[NicePay API]
end
Service -->|사용| UseCase
UseCase -->|의존| Port
Port -.->|구현| Adapter
Adapter -->|위임| Factory
Factory -->|선택| Strategy
Strategy -.->|구현| Toss
Strategy -.->|구현| Nicepay
Toss -->|호출| TossAPI
Nicepay -->|호출| NicepayAPI
style Port fill: #e1f5ff, color: #000
style Strategy fill: #e1f5ff, color: #000
style Adapter fill: #fff4e1, color: #000
style Factory fill: #fff4e1, color: #000
style Toss fill: #e8f5e9, color: #000
style Nicepay fill: #e8f5e9, color: #000

1. PaymentGatewayPort(포트 인터페이스)

Section titled “1. PaymentGatewayPort(포트 인터페이스)”

애플리케이션 레이어가 외부 PG와 통신하기 위한 추상 인터페이스로, 구체적인 PG 구현체가 이를 구현하는 의존성 역전 구조를 형성한다.

PaymentGatewayPort.java
public interface PaymentGatewayPort {
PaymentStatusResult getStatus(String paymentKey, PaymentGatewayType gatewayType);
PaymentStatusResult getStatusByOrderId(String orderId, PaymentGatewayType gatewayType);
PaymentConfirmResult confirm(PaymentConfirmRequest request);
PaymentCancelResult cancel(PaymentCancelRequest request);
}
  • 모든 메서드는 PG-독립적인 DTO(PaymentStatusResult, PaymentConfirmRequest) 사용 — 특정 PG에 종속되지 않는 구조
  • 예외도 벤더 중립(PaymentGatewayRetryableException, PaymentGatewayNonRetryableException)으로 통일
  • getStatus, getStatusByOrderIdgatewayType 파라미터를 받아 결제건에 기록된 PG로 조회
  • PG별 데이터 변환은 Infrastructure 레이어에서 처리

2. InternalPaymentGatewayAdapter(어댑터 구현)

Section titled “2. InternalPaymentGatewayAdapter(어댑터 구현)”

Port를 구현하고 전략 패턴으로 위임하는 중재 역할을 수행하며, 실제 PG 통신 로직은 Strategy 구현체에 위임한다.

InternalPaymentGatewayAdapter.java
@Component
@RequiredArgsConstructor
public class InternalPaymentGatewayAdapter implements PaymentGatewayPort {
private final PaymentGatewayFactory factory;
private final PaymentGatewayProperties properties;
@Override
public PaymentConfirmResult confirm(PaymentConfirmRequest request) {
PaymentGatewayStrategy strategy = factory.getStrategy(request.gatewayType());
return strategy.confirm(request);
}
// confirm/cancel은 요청의 gatewayType으로 전략 선택
private PaymentGatewayType resolveGatewayType(PaymentGatewayType gatewayType) {
return gatewayType != null ? gatewayType : properties.getType();
}
}
  • confirm/cancel: 요청 DTO에 포함된 gatewayType으로 전략 선택
  • getStatus/getStatusByOrderId: PaymentEvent에 기록된 gatewayType 사용

3. PaymentGatewayStrategy(전략 인터페이스)

Section titled “3. PaymentGatewayStrategy(전략 인터페이스)”

PG별 구현체가 구현해야 하는 공통 인터페이스를 정의하여, 모든 PG가 제공해야 하는 표준 작업(결제 승인, 취소, 조회)을 명시한다.

PaymentGatewayStrategy.java
public interface PaymentGatewayStrategy {
boolean supports(PaymentGatewayType type);
PaymentConfirmResult confirm(PaymentConfirmRequest request);
PaymentCancelResult cancel(PaymentCancelRequest request);
PaymentStatusResult getStatus(String paymentKey);
PaymentStatusResult getStatusByOrderId(String orderId);
}

4. PaymentGatewayFactory(전략 선택 팩토리)

Section titled “4. PaymentGatewayFactory(전략 선택 팩토리)”

설정 기반으로 적절한 전략을 선택하고 반환하는 역할로, Spring의 의존성 주입을 활용하여 모든 Strategy 구현체를 자동으로 수집하고, 런타임에 설정값에 따라 적절한 구현체를 선택한다.

PaymentGatewayFactory.java
@Component
@RequiredArgsConstructor
public class PaymentGatewayFactory {
private final List<PaymentGatewayStrategy> strategies;
public PaymentGatewayStrategy getStrategy(PaymentGatewayType type) {
return strategies.stream()
.filter(strategy -> strategy.supports(type))
.findFirst()
.orElseThrow(() -> UnsupportedPaymentGatewayException.of(type));
}
}
  • Spring 자동 주입: 모든 PaymentGatewayStrategy 구현체가 자동으로 List에 주입
  • 예외 처리: 지원하지 않는 PG 타입이 설정되면 명확한 예외(UnsupportedPaymentGatewayException)를 발생

구현체 내부에서의 멱등성 추상화

Section titled “구현체 내부에서의 멱등성 추상화”

PG마다 다른 멱등성 보장 방식을 구현체 내부에서 캡슐화하여, Application 레이어에는 동일한 PaymentConfirmResult만 노출하게 구현했다.

결제 승인은 네트워크 장애, 타임아웃 등으로 중복 요청이 발생할 수 있는데, PG마다 멱등성 보장 방식이 다르다.

PG멱등성 보장 방식중복 요청 시 동작
TossIdempotency-Key 헤더 전송PG가 같은 요청으로 인식, 정상 응답 반환
NicePay멱등성 키 미지원에러코드 2201 반환 (중복 승인 거절)

이러한 처리 방식의 차이를 Application 레이어에 노출하면 PG별 분기 로직이 비즈니스 레이어에 침투하게 된다.

해결 - 구현체 내부에서 에러를 감지하고 보상 처리

Section titled “해결 - 구현체 내부에서 에러를 감지하고 보상 처리”

NicePay 전략 구현체는 중복 승인 에러(2201)를 내부에서 catch하고, 조회 API로 보상 처리한 뒤 정상 결과를 반환했다.

// NicepayPaymentGatewayStrategy.java (발췌)
private PaymentConfirmResult executeConfirmPayment(
NicepayConfirmRequest confirmRequest,
PaymentConfirmRequest request
) throws PaymentGatewayRetryableException, PaymentGatewayNonRetryableException {
try {
NicepayPaymentResponse response =
nicepayGatewayInternalReceiver.confirmPayment(confirmRequest);
return convertToPaymentConfirmResult(response, request);
} catch (PaymentGatewayApiException e) {
if (NICEPAY_ERROR_CODE_DUPLICATE_APPROVAL.equals(e.getCode())) {
// 2201 중복 승인 → 조회 API로 보상 처리
return handleDuplicateApprovalCompensation(request);
}
return classifyAndThrowConfirmException(e);
}
}

이 구조에서 Application 레이어의 PaymentCommandUseCase는 PG가 Toss인지 NicePay인지, 멱등성 키를 헤더로 보내는지 보상 조회로 처리하는지 전혀 알지 못한다.

flowchart LR
subgraph "Application Layer"
UC[PaymentCommandUseCase]
end
subgraph "Infrastructure Layer"
subgraph "Toss 전략"
T1["confirm 요청\n+ Idempotency-Key 헤더"]
T2["PG가 중복 인식\n→ 정상 응답"]
end
subgraph "NicePay 전략"
N1["confirm 요청"]
N2{"2201\n중복 승인?"}
N3["tid로 PG 상태 조회"]
end
end
UC -->|" confirm(request) "| T1
UC -->|" confirm(request) "| N1
T1 --> T2
T2 -->|" PaymentConfirmResult\n(SUCCESS) "| UC
N1 --> N2
N2 -->|예| N3
N2 -->|아니오| N5["에러코드 분류\n→ 예외"]
N3 -->|" PaymentConfirmResult\n(SUCCESS) "| UC

두 경우 모두 동일한 PaymentConfirmResult(SUCCESS, ...) 를 받으면서, 로직을 수행할 수 있게 된다.

전략 패턴과 포트-어댑터 패턴을 결합하여 PG 독립적인 아키텍처를 구축하고, 실제로 NicePay를 두 번째 PG로 추가함으로써 설계의 확장성을 검증했다.

  1. PG 독립성 확보: 도메인/애플리케이션 레이어에서 PG-specific 타입 완전 제거
  2. 확장 가능한 구조: PaymentGatewayStrategy 구현 + @Component 등록만으로 새 PG 추가 가능, Factory 코드 수정 불필요
  3. 멱등성 차이 캡슐화: PG마다 다른 멱등성 보장 방식(헤더 vs 보상 조회)을 구현체 내부에서 처리하여 상위 레이어에 동일한 결과 타입만 노출

보상 트랜잭션 실패 상황 극복 가능한 결제 플로우 설계

실행 환경: Java 21, Spring Boot 3.3.3

[!CAUTION] 이 글은 작성 시점의 구현을 기준으로 하며, 이후 보상 트랜잭션 실행 전 이중 조건 가드(Outbox IN_FLIGHT + Event 비종결 검사)가 추가되었고, 격리 전 최종 확인 단계가 도입되었다. 현재 설계는 결제 복구 상태 전이 설계를 참고한다.

결제 로직은 내부 DB 자원(재고 차감)과 외부 API 자원(PG사 결제 요청)이라는 서로 다른 리소스를 하나의 비즈니스 프로세스로 처리한다.

기존에는 로직 수행 중 실패에 대해 보상 트랜잭션 방식을 채택했다.

  • 재고 차감 먼저 수행
  • 외부 결제가 실패 시 차감된 재고 복구

문제는 ‘차감된 재고를 복구하는 보상 트랜잭션’ 자체가 실패할 수 있다는 점이다.

  1. PG사 결제 실패: 잔액 부족 등으로 인해 외부 결제 승인 API가 실패 응답 반환
  2. 보상 로직 가동: 시스템은 차감했던 재고를 복구하기 위해 DB 업데이트 시도
  3. 이차 장애 발생: 이 시점에 DB 데드락, 네트워크 타임아웃, 혹은 인스턴스 셧다운이 발생하여 복구 쿼리 유실

이러한 이중 장애가 발생하면 시스템은 아래와 같이 비즈니스 정합성이 깨지는 데이터 불일치 상태에 빠진다.

graph TD
subgraph Problem ["보상 트랜잭션 실패 상황"]
A[PG 결제 실패] -- " 1. 보상 트랜잭션 트리거 " --> B{재고 복구 시도}
B -- " 2. DB 장애 / 서버 다운 " --> C((실패))
end
C -.-> D{{"데이터 불일치 발생"}}
D --- E["결제 상태: <b>실패</b> (고객 미결제)"]
D --- F["재고 상태: <b>차감됨</b> (복구 누락)"]
style D fill: #ffcccc, stroke: #ff0000, stroke-width: 2px, color: #000
style C fill: #000, color: #fff

결국, 단순한 보상 트랜잭션 로직만으로는 인프라 장애 시 발생하는 데이터 정합성 문제를 해결할 수 없어, 이를 극복하기 위해 시스템이 스스로 상태를 추적하고 회복할 수 있는 구조가 필요했다.

이 실패 처리의 실패(Double Fault) 문제를 해결하기 위해, 다음과 같은 설계 원칙을 수립했다.

  1. 상태 추적성 (Traceability): 모든 결제 과정을 ‘작업’으로 정의하고 상태를 별도 테이블에 기록하여, 장애 발생 시에도 중단된 지점을 명확히 식별
  2. 최종적 일관성 (Eventual Consistency): 장애가 발생하더라도, 시스템 스스로 데이터 정합성 회복

문제 해결을 위해 다음 세 가지 해결 방법을 검토했다.

  1. Two-Phase Commit (2PC)
    • 모든 리소스(재고 DB, PG사)가 커밋에 동의해야 하는 강력한 일관성 모델
    • 한계: 외부 PG사가 2PC를 지원하지 않으며, 전체 시스템의 성능 저하와 블로킹(Blocking)을 유발
  2. 메시지 큐와 Saga Pattern
    • 재고 차감, 결제 요청, 결과 처리를 별도의 트랜잭션으로 분리하고 메시지 큐(Kafka, RabbitMQ)로 연결하는 방식
    • 한계: 결합도는 낮지만 Kafka/RabbitMQ 도입이 필요해 현 규모에서는 오버엔지니어링으로 판단하여 제외
  3. 선택: 작업 테이블 + Scheduler
    • ‘작업 테이블(payment_process)‘을 DB 내의 ‘메시지 큐(Outbox)‘처럼 활용하는 방식
    • 장점:
      • 단순성: 외부 인프라 의존성 없이, Spring 스케줄러와 DB만으로 ‘최종적 일관성’을 구현 가능
      • 트랜잭션 보장: 데이터 처리를 DB 트랜잭션 내에서 처리하여, 복잡한 트랜잭션 관리 불필요

아키텍처 - 3단계 트랜잭션 분리와 작업 테이블

Section titled “아키텍처 - 3단계 트랜잭션 분리와 작업 테이블”

기존 결제 로직을 3단계로 분리하고, 모든 과정을 PaymentProcess 작업 테이블에 기록하도록 설계했다.

작업 테이블 (payment_process) 스키마

Section titled “작업 테이블 (payment_process) 스키마”
컬럼명타입제약조건설명
idBigIntPK, Auto-Increment작업 고유 ID
order_idStringNot Null주문 고유 ID
statusEnum / VarcharNot Null작업 상태 (PROCESSING, COMPLETED, FAILED)
created_atTimestampNot Null생성 시각
updated_atTimestampNot Null수정 시각

이 테이블을 기반으로 결제 흐름은 다음과 같이 분리된다.

  • 1단계 (Tx 1): 재고 차감 + 작업 생성 (PROCESSING)
  • 2단계 (Non-Tx): 외부 PG사 결제 API 호출
  • 3단계 (Tx 2): 결제 결과 반영 + 작업 상태 변경 (COMPLETED / FAILED)

이 구조의 핵심은 장애 발생 시 PROCESSING 상태가 DB에 남는다는 것이다.

graph TD
%% 클래스 정의 (가독성 및 테마 대응)
classDef standard fill: #F5F5F5, stroke: #333, color: #000
classDef process fill: #E1F5FF, stroke: #0078D4, color: #000
classDef decision fill: #FFF2CC, stroke: #D79B00, color: #000
classDef success fill: #E8F5E9, stroke: #2E7D32, color: #000
classDef fail fill: #F8CECC, stroke: #B85450, color: #000
subgraph sg1 ["1단계: 작업 생성 및 재고 차감 (Transaction 1)"]
A["요청 시작"]:::standard --> B{"Tx START"}:::decision
B --> C["1. payment_process 테이블에<br/>'PROCESSING' 상태로 작업(Job) INSERT"]:::process
C --> D["2. stock 테이블 재고 차감"]:::process
D --> E{"Tx COMMIT"}:::decision
end
E -- " 성공 " --> F["2단계: 외부 PG사 결제 처리"]:::standard
F --> G{결제 성공?}:::decision
subgraph sg3 ["3단계: 작업 완료 (Transaction 2)"]
G -- " Yes " --> H_Success["Tx START"]:::decision
H_Success --> I_Success["payment_process 상태 'COMPLETED'로 UPDATE"]:::success
I_Success --> J_Success["orders 테이블 상태 '결제 완료'로 변경"]:::success
J_Success --> K_Success["Tx COMMIT"]:::decision
K_Success --> L_End["종료"]:::standard
G -- " No " --> H_Fail["Tx START"]:::decision
H_Fail --> I_Fail["payment_process 상태 'FAILED'로 UPDATE"]:::fail
I_Fail --> J_Fail["[보상 트랜잭션]<br/>차감했던 재고 복구"]:::fail
J_Fail --> K_Fail["Tx COMMIT"]:::decision
K_Fail --> L_End
end
subgraph sg_recovery ["장애 복구 로직"]
Z["복구 로직 시작"]:::standard --> Y["payment_process 테이블에서<br/>'PROCESSING' 상태인 작업 조회"]:::process
Y --> X{"PG사에 실제 결제 성공 여부 조회 or 승인 요청"}:::decision
%% 복구 로직 연동
X -- " 결제 성공 상태 " --> H_Success
X -- " 결제 실패 상태 " --> H_Fail
end
%% 스타일 보정 (연결선 텍스트 강조)
linkStyle 4,14,15 stroke: #333, stroke-width: 2px, color: #000

보상 트랜잭션 실패 흐름 시나리오

Section titled “보상 트랜잭션 실패 흐름 시나리오”
sequenceDiagram
participant Client as 사용자
participant Service as PaymentCoordinator
participant DB as 데이터베이스 (JPA)
participant Toss as Toss PG API
Client ->> Service: 1. 결제 승인 요청
%% --- 1단계 (Tx 1) ---
Service ->> DB: 2. [Tx 1 시작]
Service ->> DB: 3. 재고(Stock) 차감
Service ->> DB: 4. 작업(PaymentProcess) 생성 (상태: PROCESSING)
Service ->> DB: 5. [Tx 1 커밋]
%% --- 2단계 (Non-Tx) ---
Service ->> Toss: 6. 외부 결제 승인 API 호출
Toss -->> Service: 7. 결제 실패 응답 (예: 한도 초과)
%% --- 3단계 (Tx 2) - 보상 트랜잭션 ---
Service ->> DB: 8. [Tx 2 (보상) 시작]
Service ->> DB: 9. 재고(Stock) 복구 시도
Service ->> DB: 10. 작업(PaymentProcess) 상태 'FAILED' 변경 시도
critical DB 장애 발생
Service ->> DB: 11. [Tx 2 (보상) 롤백] (예: DB 데드락)
end
Service -->> Client: 12. 결제 실패 응답
Note right of DB: [데이터 불일치 상태!]<br/>- 재고: 차감됨 (롤백 실패)<br/>- 작업 상태: PROCESSING (롤백됨)

11번 단계가 핵심인데, 보상 트랜잭션이 롤백되면서, 재고는 차감되었지만 작업 상태는 PROCESSING으로 남는 데이터 불일치 상태가 된다.

스케줄러를 통한 최종적 일관성 확보

Section titled “스케줄러를 통한 최종적 일관성 확보”

문제를 해결하기 위해 PaymentRecoveryUseCase라는 스케줄러를 구현했다.

  • 동작: PROCESSING 상태로 일정 시간 이상 방치된 작업을 조회
  • 로직: 조회된 작업의 orderId로 Toss API에 실제 결제 상태를 다시 조회
sequenceDiagram
participant Scheduler as PaymentRecoveryUseCase
participant DB as 데이터베이스 (JPA)
participant Toss as Toss PG API
participant Service as PaymentCoordinator
Scheduler ->> Scheduler: 1. 스케줄러 실행
Scheduler ->> DB: 2. 'PROCESSING' 상태 작업 조회
DB -->> Scheduler: 3. [Job (orderId-123)] 반환
Scheduler ->> Toss: 4. Toss API로 결제 상태 재확인 (orderId-123)
Toss -->> Scheduler: 5. 최종 상태 응답: "CANCELED" (실패)
Scheduler ->> Service: 6. '결제 실패 보상 로직' 재실행
%% --- 3단계 (Tx 2) - 보상 트랜잭션 "재시도" ---
Service ->> DB: 7. [Tx 2 (보상) 시작]
Service ->> DB: 8. 재고(Stock) 복구
Service ->> DB: 9. 작업(PaymentProcess) 상태 'FAILED' 변경
Service ->> DB: 10. [Tx 2 (보상) 커밋]
Note right of DB: [데이터 정합성 복구 완료!]<br/>- 재고: 정상 복구<br/>- 작업 상태: FAILED
  • 실패 처리의 실패(Double Fault) 해결: 데이터 불일치 시나리오에 대한 자동 복구 메커니즘 구축
  • 최종적 일관성 확보: 스케줄러와 작업 테이블로 장애 발생 시에도 시스템이 스스로 정합성 회복

결제 이력 추적 및 핵심 지표 모니터링 시스템 구현

실행 환경: Java 21, Spring Boot 3.3.3

결제 복구 시스템을 구축하면서 상태 종류와 재시도 메커니즘이 늘어났고, 체계적인 상태 추적이 필요해졌다.

  • 구조화되지 않은 로그 데이터로 인해 집계 및 분석이 어려움
  • 트랜잭션 단위로 상태 변경의 정합성을 보장할 수 없음
  • 수동 로그 해석에 따른 운영 효율성 저하
  • 이상 데이터 탐지 및 모니터링 한계

구조화된 상태 변경 이력을 별도 테이블에 저장해 결제 이벤트를 시간순으로 추적할 수 있는 시스템이 필요했다.
단순 이력 추적을 넘어 외부 API 호출 시간, 특정 상태에 비정상적으로 머무는 결제 건수 등을 파악하는 실시간 모니터링 체계도 함께 추가했다.

  • 완전성: 모든 상태 전환이 누락 없이 기록
  • 추적 가능성: 단일 결제 건의 모든 상태 전환을 시간순으로 연결·조회 가능
  • 관심사 분리: 비즈니스 로직에 침투하지 않고 선언적으로 이력 추적 및 모니터링 기능 추가
  • 운영 투명성: 외부 API 호출 성능 / 비정상 상태(5회 이상 재시도) 등 운영 지표 실시간 모니터링 기능 제공

두 가지 핵심 목표(이력 추적, 헬스 모니터링)를 달성하기 위해 다양한 기술적 대안을 검토했다.

결제 이력 추적(AOP + Spring Event 기반 설계)

Section titled “결제 이력 추적(AOP + Spring Event 기반 설계)”

결제 이력 추적 기능 구현을 위해 AOP + Spring Event을 사용하고, 커밋 직전(BEFORE_COMMIT)에 이력 저장을 수행하는 방안을 채택했다.

  • AOP (Aspect-Oriented Programming)
    • 선정 이유: 상태 변경 기록은 전형적인 횡단 관심사 -> 서비스 코드 변경 최소화 가능
    • 장점: 어노테이션 기반 선언적 프로그래밍으로 적용 누락 최소화
      • 코드 내 직접 호출 대비 결합도 낮음
  • Spring ApplicationEvent
    • 선정 이유: 프레임워크 내장 이벤트 시스템 활용 -> 외부 라이브러리 불필요
    • 장점: 이벤트 발행(서비스)과 처리(저장) 분리 -> 결합도 낮춤
      • 메시지 큐 대비 인프라 비용 추가 없이 구현 가능
  • TransactionPhase.BEFORE_COMMIT
    • 선정 이유: 커밋 직전에 실행하여 비즈니스 상태와 이력 간 정합성 확보
    • 장점: 로직이 성공한 경우에만 기록
      • AFTER_COMMIT은 커밋 후 저장 실패하더라도, 비즈니스 로직이 이미 커밋된 상태가 되어 불일치 발생하는 문제 발생
      • 히스토리 테이블의 변경 이력 == 실제 비즈니스 상태 변경을 보장
  • AOP
    • 선정 이유: 기존 이력 추적 AOP와 동일한 메커니즘 활용 가능
    • 장점: 메서드 실행 시간(API 호출 시간) 측정 및 결제 상태 변경 감지 용이
  • Scheduler DB 스캔
    • 선정 이유: 5회 이상 재시도 상태 등 장기 체류 결제 건 탐지 용이
    • 장점: 주기적으로 스캔하고, 이를 메트릭으로 노출하여 Grafana 대시보드에서 실시간 모니터링 가능

구현 세부 사항 1 - 결제 이력 추적

Section titled “구현 세부 사항 1 - 결제 이력 추적”
sequenceDiagram
participant C as Caller
box AOP Proxy Layer
participant A as History Aspect
end
box Core Business Layer
participant S as Service Method
end
participant E as Event Publisher
participant L as @TransactionalEventListener
participant DB as Database
C ->> A: 메서드 호출 (@PublishPaymentHistory)
activate A
Note over A: [Before] 현재 DB 상태 스냅샷 캡처
A ->> S: joinPoint.proceed() (실제 로직 위임)
activate S
S ->> DB: Payment 상태 변경 (UPDATE)
S -->> A: 처리 결과 반환
deactivate S
Note over A: [After] 결과 분석 및 이벤트 발행 결정
A ->> E: PaymentHistoryEvent 발행
Note over E, DB: Transaction: BEFORE_COMMIT Phase
E ->> L: 이벤트 전달
L ->> DB: PaymentHistory 저장 (INSERT)
A -->> C: 최종 결과 응답
deactivate A
Note over DB: Transaction Commit (Final)

비즈니스 서비스 메서드에 @PublishPaymentHistory 어노테이션을 선언하여, 해당 메서드 실행 시 자동으로 상태 변경 이력을 발행하도록 설정했다.

@Transactional
@PublishPaymentHistory(action = "changed")
public PaymentEvent markPaymentAsFail(
PaymentEvent paymentEvent,
@Reason String failureReason // 이력에 기록할 사유
) {
// 순수한 비즈니스 로직 수행
paymentEvent.fail(failureReason);
return paymentEventRepository.saveOrUpdate(paymentEvent);
}
  • @PublishPaymentHistory 어노테이션으로 이력 추적 대상 메서드 지정 및 action 속성으로 상태 변경 유형 전달
  • @Reason 어노테이션으로 이력 기록에 포함할 변경 사유 전달

AOP Aspect에서는 메서드 실행 전의 상태를 저장하고, 변경 이력을 담은 이벤트를 발행한다.

@Around("@annotation(publishHistory)")
public Object publishHistoryEvent(ProceedingJoinPoint joinPoint, PublishPaymentHistory publishHistory)
throws Throwable {
// 1. 실행 전 상태 캡처
Optional<PaymentEvent> beforeEventOpt = findPaymentEvent(joinPoint.getArgs());
PaymentEventStatus beforeStatus = beforeEventOpt.map(PaymentEvent::getStatus).orElse(null);
// 2. 이력에 기록할 사유 추출
String reason = findReasonParameter(joinPoint);
try {
// 3. 비즈니스 로직 실행
Object result = joinPoint.proceed();
// 4. 실행 결과 기반으로 상태 변경 이벤트 발행
processResultAndPublishEvent(beforeStatus, result, reason, publishHistory);
return result;
} catch (Exception e) {
log.error("Error occurred while processing payment: {}", e.getMessage(), e);
throw e;
}
}
  • findPaymentEvent 메서드로 현재 상태를 조회하여 이전 상태 저장
  • findReasonParameter로 이력에 포함할 변경 사유 추출
  • 비즈니스 로직 실행 후 action에 따라 적절한 이벤트 발행

3. 이벤트 리스너 - BEFORE_COMMIT 전략

Section titled “3. 이벤트 리스너 - BEFORE_COMMIT 전략”

Spring의 @TransactionalEventListener를 이용해, 트랜잭션 커밋 직전인 TransactionPhase.BEFORE_COMMIT 단계에서 이력 저장 로직을 실행한다.

@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void handlePaymentHistoryEvent(PaymentHistoryEvent event) {
paymentHistoryService.recordPaymentHistory(event);
}
  • BEFORE_COMMIT 단계는 트랜잭션이 성공적으로 커밋되기 직전에 실행되어, 비즈니스 상태 변경과 이력 기록이 동일 트랜잭션 내에서 처리되도록 보장
  • 상태 변경과 이력 저장 간 불일치 문제를 방지하며, 데이터 정합성과 원자성을 확보

외부 PG사인 Toss API 호출 시, AOP를 활용하여 호출 시간 및 성공/실패 여부를 메트릭으로 기록하도록 구현했다.

@Around("@annotation(tossApiMetric)")
public Object recordTossApiMetric(
ProceedingJoinPoint joinPoint,
TossApiMetric tossApiMetric
) throws Throwable {
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
// 시간 측정
long duration = System.currentTimeMillis() - startTime;
// 성공 메트릭 기록
handleSuccess(joinPoint, tossApiMetric, duration);
return result;
} catch (Exception e) {
// ...
}
}
private void handleSuccess(ProceedingJoinPoint joinPoint, TossApiMetric tossApiMetric, long duration) {
switch (tossApiMetric.value()) {
case SUCCESS:
// 측정된 시간과 함께 메트릭 기록
tossApiMetrics.recordTossApiCall(tossApiMetric.operation(), duration, true);
break;
// ...
}
}

2. 스케줄러 기반 이상 징후 감지 (Gauge)

Section titled “2. 스케줄러 기반 이상 징후 감지 (Gauge)”

상태가 오랫동안 변경되지 않은 결제 건수, 알 수 없는 상태 건수, 최대 재시도 횟수에 도달한 건수 등은 주기적으로 DB에서 조회하여 Gauge 메트릭으로 기록하는 스케줄러를 구현했다.

@Scheduled(fixedDelayString = "${metrics.payment.health.polling-interval-seconds:10}000")
public void updateHealthGauges() {
LocalDateTime now = localDateTimeProvider.now();
// Stuck in progress
LocalDateTime stuckThreshold = now.minusMinutes(stuckInProgressMinutes);
long stuckInProgress = paymentEventRepository
.countByStatusAndExecutedAtBefore(PaymentEventStatus.IN_PROGRESS, stuckThreshold);
healthGauges.get("stuck_in_progress").set(stuckInProgress);
// Unknown status
long unknownStatus = paymentEventRepository.countByStatus()
.getOrDefault(PaymentEventStatus.UNKNOWN, 0L);
healthGauges.get("unknown_status").set(unknownStatus);
// Max retry reached
long maxRetryReached = paymentEventRepository
.countByRetryCountGreaterThanEqual(maxRetryCount);
healthGauges.get("max_retry_reached").set(maxRetryReached);
log.debug("Health gauges updated - stuckInProgress={}, unknownStatus={}, maxRetryReached={}",
stuckInProgress, unknownStatus, maxRetryReached);
}

주기적인 DB 스캔으로 간단하게 구현했으나, 운영 환경에서 다음과 같은 잠재적 위험이 존재한다.

  • 트래픽이 많은 운영 환경에서 잦은 SELECT는 DB 부하 발생
  • 헬스 체크 쿼리가 복잡해지거나 인덱스를 제대로 활용하지 못할 경우, 메인 비즈니스 로직의 트랜잭션 성능까지 영향 받을 수 있음

시스템 확장 시에는 다음과 같은 해결 방안을 고려해볼 수 있을 것으로 보인다.

  1. 읽기 전용 복제(Read Replica) DB 활용: 모니터링 및 헬스 체크 쿼리를 읽기 전용 DB(Replica)로 분리하여, 다른 비즈니스 트랜잭션에 미치는 영향 최소화
  2. 쿼리 최적화: 복합 인덱스를 적용하여 Full Table Scan 방지
  3. 이벤트 기반 헬스 체크: DB 스캔 대신, 지연 큐(e.g., Redis, Kafka Streams)를 활용해 ‘5분 뒤 헬스 체크’ 이벤트를 발행하는 방식으로 DB 의존성 제거
graph TD
subgraph sg1 [API Request Flow]
A[API 요청 수신] --> B{Trace ID 할당};
end
subgraph sg2 [비즈니스 로직]
B --> C[결제 상태 변경 등<br/>비즈니스 로직 수행];
end
subgraph sg3 [AOP: 이력 저장 관심사 분리]
C -- TransactionalEventListener --> D[PaymentStateChanged 이벤트 발생];
D --> E{결제 상태 변경 이력 생성};
E --> F[DB 트랜잭션 커밋 직전 이력 저장];
end
subgraph sg4 [AOP: 실시간 성능 측정 Timer]
C -- AOP Pointcut --> M[외부 API 호출<br/>e.g. TossPayments 시간 측정];
M --> N[수집];
end
subgraph sg5 [구조화된 로그]
C --> L2[Trace ID + Order ID 기반<br/>로그 기록];
end
subgraph sg6 [데이터 저장 DB]
F --> G[(DB: PaymentEvent,<br/>PaymentHistory)];
end
subgraph sg7 [헬스 모니터링 Scheduled Flow]
H[Scheduled] -- 주기적 DB 스캔 --> G;
H --> I{이상 징후 감지<br/>e.g., 5번 이상 시도한 결제};
I --> J[수집];
end

AOP, Spring Event, 스케줄러를 조합해 추적 가능하고 투명한 결제 시스템 기반을 구축했다.

  • 복잡한 결제 상태 변경의 체계적 관리: 다양한 상태와 재시도 로직이 혼재하는 환경에서도 모든 상태 변경 내역을 누락 없이 기록해 장애 분석 및 추적 가능
  • 비즈니스 로직 순수성 유지: 이력 추적 기능을 AOP 선언적 방식으로 분리해 기존 서비스 코드에 침투하지 않고 기능 추가
  • 실시간 운영 투명성 확보: Toss API 호출 시간을 실시간 측정하고 스케줄러로 핵심 결제 상태 지표 모니터링

Payment Custom Metric

Payment Event 히스토리 리스트 조회

Payment Event 상세 조회

Logger 성능 저하 방지와 구조화된 로깅 설계

로그를 단순히 println()으로 찍는 건 성능 이슈와 레벨을 구분할 수 없기 때문에, 개발 초기 테스트 외엔 적절하지 않다.

  • 로그 레벨 구분이 없음 (debug, info, warn 등 불가능)
  • 시간, 클래스, 스레드 정보가 출력되지 않음
  • 로그 파일 관리 및 저장 불가
  • 멀티스레드 환경에서 병목 발생 (System.out은 synchronized I/O)

로그는 문제를 추적하고 흐름을 분석하기 위해 많은 양의 데이터를 남기기 때문에, 구조화된 출력과 불필요한 연산 제어가 반드시 필요하다.

로그를 남길 때는 "Hello" + "World"와 같은 문자열 결합 방식이 아닌, "Hello {}"와 같은 포맷팅 방식을 권장하는데, 이는 성능에 영향을 미치기 때문이다.

private static void concatLog(Order order) {
logger.trace("OrderId=" + order);
}

이 방식은 로그 레벨이 trace가 아니라면 출력도 안 되지만, order.toString() 호출과 문자열 결합은 그대로 발생하여, 출력은 안 되지만 연산은 수행되는 비효율이 생긴다.

// 1. 레벨을 직접 확인하는 방식
private static void checkConditionLog(Order order) {
if (logger.isTraceEnabled()) {
logger.trace("OrderId= " + order);
}
}
// 2. 파라미터 포맷팅 방식
private static void paramLog(Order order) {
logger.trace("OrderId={}", order);
}

이를 피하기 위해 두 가지 방식을 사용할 수 있다.

  1. 로그 레벨을 직접 체크하는 방식
  2. 파라미터 포맷팅 방식 ({} 사용)

1번 방식은 조건을 직접 해야하는 조건문을 작성해야 하므로 코드가 복잡해지고 가독성이 떨어져, 보통 2번 방식인 파라미터 포맷팅 방식을 사용한다.

로깅 방식에 따른 성능 차이를 측정하기 위해, 멀티스레드 환경에서 다음과 같이 테스트를 작성하여 세 가지 방식에 대해 테스트를 진행했다.

  1. 문자열 결합 방식 (+ 사용)
  2. String.format() 방식
  3. 파라미터 포맷팅 방식 ({} 사용)
LoggerTest.java
class LoggerTest extends IntegrationTest {
private static final Logger logger = LoggerFactory.getLogger(LoggerTest.class);
private static final int THREAD_COUNT = 10;
private static final int TASK_COUNT = 100000;
private static void logWithConcat(TestData testData) {
logger.trace("[TEST] | TEST | testData=" + testData);
}
private static void logWithFormat(TestData testData) {
logger.trace(String.format("[TEST] | TEST | testData=%s", testData));
}
private static void logWithParam(TestData testData) {
logger.trace("[TEST] | TEST | orderId={}", testData);
}
@Test
void testLogSpeed() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
TestData testData = new TestData(100);
// 948ms
runTest(executor, "String concatenation (+)", () -> logWithConcat(testData));
// 886ms
runTest(executor, "String.format()", () -> logWithFormat(testData));
// 19ms
runTest(executor, "Parameterized logging ({})", () -> logWithParam(testData));
executor.shutdown();
}
private void runTest(ExecutorService executor, String label, Runnable task) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
long start = System.currentTimeMillis();
for (int i = 0; i < THREAD_COUNT; i++) {
executor.submit(() -> {
for (int j = 0; j < TASK_COUNT; j++) {
task.run();
}
latch.countDown();
});
}
latch.await();
long end = System.currentTimeMillis();
System.out.println(label + ": " + (end - start) + "ms");
}
static class TestData {
private final List<String> data;
public TestData(int size) {
this.data = new ArrayList<>();
for (int i = 0; i < size; i++) {
data.add(UUID.randomUUID().toString());
}
}
public String getTestDataString() {
return data.stream().sorted().collect(Collectors.joining(","));
}
@Override
public String toString() {
return this.getTestDataString();
}
}
}
방식소요 시간
문자열 결합 (+)948ms
String.format() 사용886ms
파라미터 포맷팅 ({})19ms
  • +, String.format() 연산자 방식은 로그 레벨에 관계없이 toString()이 호출되고 문자열 결합이 발생
  • 파라미터 포맷팅({}) 방식은 로그 레벨이 활성화된 경우에만 포맷팅이 수행되어, toString() 호출이 발생하지 않음

파라미터 포맷팅 방식이 가장 빠르며, 문자열 결합/포맷은 로그 레벨이 비활성화되어 있어도 toString() 호출과 문자열 결합이 발생해 성능이 저하된다.

{} 포맷팅을 사용하면 문자열 결합 비용은 줄일 수 있지만, 메서드 호출 자체는 여전히 발생한다는 점에서 성능 이슈는 완전히 사라지지 않는다.

private static String getExpensiveDetail(Order order) {
logger.trace("OrderId={}, detail={}", order.getId(), order.getExpensiveDetail());
}

위 코드처럼 로그 레벨과 무관하게 getExpensiveDetail()이 호출된다면, 불필요한 연산이 수행되어 성능 저하가 발생할 수 있다.

private static void logWithSupplier(Order order) {
logger.trace("OrderId={}, detail={}", () -> order.getId(), () -> order.getExpensiveDetail());
}

Log4j2는 이를 해결하기 위해 Supplier 기반 지연 평가를 지원하지만, Slf4j는 해당 기능을 제공하지 않기 때문에 isTraceEnabled() 같은 조건문을 명시적으로 사용하여 방지할 수 있다.

메서드 호출 성능 테스트(SlF4J 환경)

Section titled “메서드 호출 성능 테스트(SlF4J 환경)”

위와 동일한 테스트 환경에서 메서드 호출을 포함한 로그 성능을 측정했다.

class LoggerTest extends IntegrationTest {
private static void logConcatWithMethodCall(TestData testData) {
logger.trace("[TEST] | TEST | testData=" + testData.getTestDataString());
}
private static void logFormatWithMethodCall(TestData testData) {
logger.trace(String.format("[TEST] | TEST | testData=%s", testData.getTestDataString()));
}
private static void logParamWithMethodCall(TestData testData) {
logger.trace("[TEST] | TEST | orderId={}", testData.getTestDataString());
}
private static void logParamWithCheck(TestData testData) {
if (logger.isTraceEnabled()) {
logger.trace("[TEST] | TEST | testData={}", testData.getTestDataString());
}
}
@Test
void testLogSpeedWithMethodCall() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);
TestData testData = new TestData(100);
// 1042ms
runTest(executor, "String concat + method call",
() -> logConcatWithMethodCall(testData));
// 1092ms
runTest(executor, "String.format + method call",
() -> logFormatWithMethodCall(testData));
// 947ms
runTest(executor, "Param-style logging + method call",
() -> logParamWithMethodCall(testData));
// 14ms
runTest(executor, "Level check logging + method call",
() -> logParamWithCheck(testData));
executor.shutdown();
}
}
방식소요 시간
문자열 결합 + 메서드 호출1042ms
String.format + 메서드 호출1092ms
파라미터 포맷팅 + 메서드 호출947ms
로그 레벨 체크 후 포맷팅 + 메서드 호출14ms
  • 파라미터 포맷팅 방식은 로그 레벨과 관계없이 메서드 호출이 발생하여 성능 저하가 발생
  • isTraceEnabled() 조건을 사용한 경우, 로그 레벨이 비활성화되어 있으면 해당 연산 자체가 수행되지 않아 성능 저하가 거의 없음

실전 대응 - 구조화된 포맷 + 연산 최소화

Section titled “실전 대응 - 구조화된 포맷 + 연산 최소화”

로그 포맷을 강제하고 싶었고, 동시에 로그 레벨이 꺼져 있을 때 불필요한 연산이 발생하지 않도록 막고 싶어, 직접 LogFmt 유틸 클래스를 만들어 사용했다.

  • 로그의 도메인과 이벤트명을 명확하게 구분
  • 로그 메시지는 Supplier<String>으로 감싸, 로그 레벨이 비활성화되었을 땐 메시지 연산 자체가 발생하지 않도록 처리
  • Slf4j 환경이므로 isInfoEnabled() 등의 레벨 체크 사용
public void example() {
LogFmt.info(
logger,
LogDomain.PAYMENT,
EventType.PAYMENT_STATUS_TO_IN_PROGRESS,
() -> String.format("orderId=%s", orderId)
);
}
2025-08-03 12:56:56.129 ... - [3ce5fab6] [PAYMENT] | PAYMENT_STATUS_TO_IN_PROGRESS | orderId=55996af6-e5b5-47e5-ac3c-44508ee6fd6b
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LogFmt {
private static final String INFO_LOG_FORMAT = "[{}] | {} | {}";
private static final String INFO_LOG_FORMAT_NO_MESSAGE = "[{}] | {}";
// ...
public static void info(Logger logger, LogDomain logDomain, EventType event) {
if (logger.isInfoEnabled()) {
logger.info(INFO_LOG_FORMAT_NO_MESSAGE, logDomain.name(), event.name());
}
}
public static void info(Logger logger, LogDomain logDomain, EventType event, Supplier<String> messageSupplier) {
if (logger.isInfoEnabled()) {
logger.info(INFO_LOG_FORMAT, logDomain.name(), event.name(), messageSupplier.get());
}
}
// ...
}

불필요한 연산을 막고 도메인과 이벤트 타입을 명확히 구분해, 성능을 해치지 않으면서 흐름을 추적할 수 있는 구조화된 로그를 설계했다.

로그

결제 상태 전환 관리와 재시도 로직을 통한 결제 복구 시스템 구축

실행 환경: Java 21, Spring Boot 3.3.3

[!CAUTION] 이 글은 작성 시점의 구현을 기준으로 하며, 이후 상태 모델과 복구 로직이 전면 재설계되었다. UNKNOWN 상태는 RETRYING으로 대체되었고, QUARANTINED 상태가 추가되었다. 예외 기반 분기는 RecoveryDecision 값 객체로, 하드코딩된 재시도 한도는 RetryPolicy 도메인 객체로 전환되었으며, getStatus 선행 조회 패턴이 도입되었다. 현재 설계는 결제 복구 상태 전이 설계를 참고한다.

최초 구상했던 결제 로직은 결제 정보 검증을 통해 안전한 결제 연동 시스템을 목표로 했지만, 예상치 못한 에러에 대한 처리 로직이 미흡했다.

이를 해결하기 위해 대규모 시스템 설계 기초 — 결제 시스템 파트의 재시도 방식을 참고해 시스템을 재설계하여, 이를 적용해보았다.

기존 결제 시스템 구현에서 발생할 수 있던 문제점은 다음과 같았다.

  1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재
    • 기존 코드에선 모든 에러에 대해 결제 실패로 처리하고 있었음
    • 그 중 재시도를 통해 해결될 수 있는 에러도 실패로 간주
  2. API 지연으로 인한 결제 처리 오류
    • 토스 측에 결제 승인 요청을 보낸 후 응답이 지연되어 타임아웃 발생 가능성 존재
    • 서버 측에서는 지연으로 인해 실패 처리되었으나, 실제로는 토스 측에서 승인 완료하여 금액만 빠져나갈 수 있음
  3. 결제 승인 요청 중 서버 중단
    • 결제 승인 요청이 토스 결제 서비스까지 전달되어 실제 결제가 완료되었으나, 서버가 중단되면서 결과를 반환하지 못함
    • 이로 인해 결제는 이미 이루어져 금액이 빠져나갔음에도 불구하고, 시스템에서는 결제가 완료되지 않은 상태로 남아있을 수 있음
  1. 재시도 가능/불가능 에러 구분
  2. 재시도 로직 구현
  3. 결제 상태 전환 관리
  4. 멱등키 사용

1. 재시도 가능/불가능 에러 구분

Section titled “1. 재시도 가능/불가능 에러 구분”

기존 코드에서는 어떤 에러가 발생하든지 동일하게 실패 처리했지만, 에러를 재시도 가능과 불가능 두 유형으로 구분했다.

  • 재시도 가능한 에러: 일정 시간 후 재 시도를 통해 해결될 수도 있는 에러
    • 일시적인 네트워크 문제 / 외부 서비스 장애 / API 타임아웃 등
  • 재시도 불가능한 에러: 재시도가 불가능한 근본적인 문제
    • 잘못된 요청, 상품 재고 부족 등

재시도 가능한 에러에 대해 일정 횟수까지 재시도하는 스케줄러를 구현해 결제 성공으로 연결했다.

  • 재시도 가능한 특정 결제 상태인 결제 요청에 대해 결제 재시도 요청
  • 재시도가 너무 많아져 시스템에 부하가 걸리는 것을 방지하기 위해 재시도 횟수 제한

기존 프로젝트에서는 시작 -> 진행 -> 완료/실패 -> 결제 취소로 순차적으로만 관리되었으나, 재시도 처리를 위한 디테일한 상태 관리가 필요했다.

stateDiagram-v2
direction TB
%% 상태 정의
state DONE
state FAILED
state UNKNOWN
state CANCELED
%% 전환 규칙
[*] --> READY
READY --> IN_PROGRESS: 결제 승인 요청 시작
IN_PROGRESS --> DONE: 결제 성공
IN_PROGRESS --> FAILED: 결제 실패
IN_PROGRESS --> UNKNOWN: 재시도 가능 에러 발생
UNKNOWN --> DONE: 재시도
UNKNOWN --> FAILED: 재시도
UNKNOWN --> UNKNOWN: 재시도(일정 횟수 까지만)
DONE --> CANCELED: 결제 취소
%% 스타일 정의 및 적용 (color:#000 추가로 가독성 확보)
classDef blue fill: #E1F0FF, stroke: #7EA6E0, color:#000
classDef red fill: #FADAD8, stroke:#B85450, color: #000
classDef orange fill:#FFE5CC, stroke: #D79B00, color: #000
classDef yellow fill: #FFF2CC, stroke: #D6B656,color: #000
class DONE blue
class FAILED red
class UNKNOWN orange
class CANCELED yellow
  • READY: 결제 최초 생성 상태
  • IN_PROGRESS: 결제 승인 요청 처리 시작한 상태
  • DONE: 결제가 성공적으로 완료된 상태
  • FAILED: 결제 실패한 상태
  • CANCEL: 결제가 취소된 상태
  • UNKNOWN: 에러가 발생하였으나, 재시도를 통해 해결 가능성이 있는 상태

UNKNOWN 상태를 도입해 모든 에러를 즉시 실패 처리하지 않고, 재시도를 통해 결제를 복구할 수 있도록 했다.

알 수 없는 상태에 대한 결제 재시도로 인해 동일한 결제가 여러 번 이루어질 수 있어, 멱등키를 이용하여 중복 결제를 방지하여 정확히 한 번만 결제 금액이 빠져나가도록 했다.

  • 멱등키 생성: 각 결제 요청 시 고유한 멱등키를 DB에 저장하고, 같은 결제에 대해 다시 요청하면 해당 키로 중복 결제 방지
  • 재시도 시 동일 결과 보장: 이미 토스 측에 성공한 결제더라도, 같은 키를 통해 중복 결제 방지

기존 프로젝트에서 발생하던 문제들을 다음과 같이 해결할 수 있었다.

1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재

Section titled “1. 결제 승인 중 발생한 에러에 대한 명확한 처리 부재”

모든 에러를 동일하게 처리하여 결제가 실패로 처리하던 부분을 재시도 가능한 에러와 불가능한 에러로 명확하게 구분하여 각각에 맞는 처리를 적용할 수 있게 되었다.

  • 재시도 가능 에러: 재시도 로직을 통해 성공으로 처리
  • 재시도 불가능한 에러: 근본적인 문제로 인한 에러는 즉시 결제 실패로 처리하여, 사용자에게 정확한 정보 전달

API 응답 지연 시에는 결제를 UNKNOWN 상태로 변경하여, 재시도를 통해 최종적으로 결제 성공으로 이어질 수 있도록 처리할 수 있게 되었다.

  • 응답 지연 시 재시도 처리: 토스 측 응답 지연으로 결제가 실패 처리되는 경우, 즉시 실패로 간주하지 않고 재시도할 수 있도록 함
  • 중복 결제 방지: 이미 성공했으나 토스측에 결과만 못 받은 경우에도, 멱등키를 사용하여 중복해서 결제되지 않도록 함

3. 결제 승인 요청 중 서버가 중단되는 경우

Section titled “3. 결제 승인 요청 중 서버가 중단되는 경우”

서버 중단으로 인해 결제가 제대로 처리되지 못하거나 중복 결제가 발생할 수 있었으나, 재시도를 통해 올바른 결제 처리를 보장할 수 있게 되었다.

  • 서버 중단 시 복구: IN_PROGRESS 상태로 남아있는 결제들에 대해 결제 요청을 다시 시도
  • 중복 결제 방지: (토스 측에 요청을 보냈으나 반환되기 전에 서버가 중단 된 경우)성공한 결제에 대해 중복 결제 방지를 위해 멱등키 사용

1. 재시도 가능/불가능 에러 구분 + 3. 결제 상태 전환 관리

Section titled “1. 재시도 가능/불가능 에러 구분 + 3. 결제 상태 전환 관리”

결제 승인 과정에서 발생하는 에러 유형에 따라 시스템이 다르게 처리한다.

flowchart TD
%% 노드 정의
START([결제 승인 요청])
STOCK[재고 감소 처리]
STEP1{결제 시작 처리 성공}
STEP2{결제 검증 성공}
STEP3{토스 결제 승인 요청 성공}
STEP4{재시도 가능 에러 여부}
SUCCESS[성공]
DB[(DB)]
%% 흐름 연결
START --> STOCK
STOCK -- " 재고 감소 " --> DB
STOCK --> STEP1
STEP1 -- " Yes " --> STEP2
STEP1 -- " No " --> FAIL_PATH
STEP2 -- " Yes " --> STEP3
STEP2 -- " No " --> FAIL_PATH
STEP3 -- " Yes " --> SUCCESS
STEP3 -- " No " --> STEP4
STEP4 -- " No " --> FAIL_PATH
STEP4 -- " Yes " --> UNKNOWN_STATUS
SUCCESS -- " DONE " --> DB
UNKNOWN_STATUS["UNKNOWN"] -- " UNKNOWN " --> DB
FAIL_PATH["FAILED + 재고 복구"] --> DB
%% 스타일링 (글자색 #000 고정 및 고대비 설정)
classDef standard fill: #F5F5F5, stroke: #333, color: #000
classDef db fill: #E5E5E5, stroke: #666, color: #000
classDef white fill: #FFFFFF, stroke: #333, color: #000
style STEP1 fill: #E1F0E1, stroke: #82B366, color: #000
style STEP2 fill: #DAE8FC, stroke: #6C8EBF, color: #000
style STEP3 fill: #FFE6CC, stroke: #D79B00, color: #000
style STEP4 fill: #FFF2CC, stroke: #D6B656, color: #000
%% 경로 텍스트 가독성 확보 (다크모드에서도 선명한 채도 사용)
style FAIL_PATH fill: none, stroke: none, color: #FF3333
style UNKNOWN_STATUS fill: none, stroke: none, color: #D44040

토스 에러 코드를 Enum으로 정의하고 성공/재시도 가능 여부 판단 메서드를 구현했으며, 추가적으로 타임아웃 관련 에러 코드를 추가해 재시도 가능한 에러로 포함했다.

TossPaymentErrorCode.java
public enum TossPaymentErrorCode {
ALREADY_PROCESSED_PAYMENT(400, "이미 처리된 결제 입니다."),
// ...
UNKNOWN_PAYMENT_ERROR(500, "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."),
UNKNOWN(500, "알 수 없는 에러입니다."),
NETWORK_ERROR(500, "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); // 타임아웃 처리를 위한 별도로 추가한 에러
//...
public boolean isSuccess() {
return this == ALREADY_PROCESSED_PAYMENT;
}
public boolean isRetryableError() {
return switch (this) {
case PROVIDER_ERROR, FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING,
FAILED_INTERNAL_SYSTEM_PROCESSING, UNKNOWN_PAYMENT_ERROR,
UNKNOWN, NETWORK_ERROR -> true;
default -> false;
};
}
public boolean isFailure() {
return !isSuccess() && !isRetryableError();
}
}

결제 결과를 성공/재시도 가능/실패로 판단하며, 호출부는 결과에 따라 성공 객체 또는 Checked Exception을 반환하도록 하였다.

PaymentProcessorUseCase.java
public class PaymentProcessorUseCase {
// ...
public TossPaymentInfo confirmPaymentWithGateway(PaymentConfirmCommand paymentConfirmCommand)
throws PaymentTossRetryableException, PaymentTossNonRetryableException {
// ... 토스 측에 결제 승인 요청 및 반환
PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// 상태에 따라 처리
return switch (paymentConfirmResultStatus) {
// 1. 성공
case PaymentConfirmResultStatus.SUCCESS -> tossPaymentInfo;
// 2. 재시도 가능한 에러 - Checked Exception
case PaymentConfirmResultStatus.RETRYABLE_FAILURE ->
throw PaymentTossRetryableException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
// 3. 재시도 불가능한 에러 - Checked Exception
case PaymentConfirmResultStatus.NON_RETRYABLE_FAILURE ->
throw PaymentTossNonRetryableException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
};
}
// ...
}

Checked Exception으로 호출부가 에러 처리를 강제받으며, 에러 유형별로 다음과 같이 처리했다.

예외 상황재시도 가능결제 상태 처리재고 처리
1. 재고 차감 중 오류X실패 처리복구 불필요
2. 이미 처리된 결제에 대한 중복 요청X실패 처리재고 복구
3. 외부 결제사의 일시적인 오류O알 수 없음 상태로 변경재고 유지
4. 외부 결제사의 복구 불가능 오류X실패 처리재고 복구
5. 결제 검증 또는 기타 시스템 오류X실패 처리재고 복구
PaymentConfirmServiceImpl.java
public class PaymentConfirmServiceImpl implements PaymentConfirmService {
// ...
@Override
public PaymentConfirmResult confirm(PaymentConfirmCommand paymentConfirmCommand) {
// 1. 결제 이벤트 조회
PaymentEvent paymentEvent = paymentLoadUseCase.getPaymentEventByOrderId(
paymentConfirmCommand.getOrderId()
);
try {
// 2. 재고 차감 - 재고 부족 에러 발생 가능
orderedProductUseCase.decreaseStockForOrders(paymentEvent.getPaymentOrderList());
} catch (PaymentOrderedProductStockException e) { // 에러 1: 재고 부족 에러 발생
handleStockFailure(paymentEvent); // FAIL 처리(재고 중 일어난 에러로, 감소가 일어나지 않았으므로 복구 필요 없음)
throw PaymentTossConfirmException.of(PaymentErrorCode.ORDERED_PRODUCT_STOCK_NOT_ENOUGH);
}
try {
// 3. 결제 시작 처리 - 결제 시작 상태 검증 중 에러 발생 가능
paymentProcessorUseCase.executePayment(paymentEvent, paymentConfirmCommand.getPaymentKey());
// 4. 결제 검증 - 결제 검증 중 에러 발생 가능
// 5. 토스 결제 승인 요청 - 토스 측 재시도 가능/불가능한 에러 발생 가능
// 6. 결제 완료 처리
PaymentEvent completedPayment = processPayment(paymentEvent, paymentConfirmCommand);
// 7. 결제 완료 결과 반환
// ...
} catch (PaymentStatusException e) { // 에러 2: 이미 요청이 진행 된 건에 대한 요청
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(paymentEvent); // UNKNOWN 상태로 변경 + 재고 그대로 유지
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
} catch (PaymentTossNonRetryableException e) { // 에러 4: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
} catch (Exception e) { // 에러5: 결제 검증 중 에러 등 기타 에러 발생
handleUnknownException(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
}
}
// ...
}

재시도 가능으로 구분 된 에러에 대해, 재시도 로직을 구현하여 두 가지 상태에 대해 결제 성공으로 이어질 수 있도록 구현하였다.

  1. UNKNOWN: 재시도 가능 에러 또는 타임아웃으로 결과를 받지 못한 상태
  2. IN_PROGRESS: 결제 승인을 시작했지만 결과를 받지 못한 상태
// PaymentRecoverServiceImpl
public class PaymentRecoverServiceImpl implements PaymentRecoverService {
// 1. 재시도 가능한 결제 이벤트 조회 후 아래 로직 수행
private void processRetryablePaymentEvent(PaymentEvent retryablePaymentEvent) {
try {
// 2. 재시도 가능 여부 횟수 검증 - 재시도 불가능 에러 발생 가능
if (!retryablePaymentEvent.isRetryable(localDateTimeProvider.now())) {
throw PaymentRetryableValidateException.of(PaymentErrorCode.RETRYABLE_VALIDATION_ERROR);
}
// 3. 재시도 횟수 증가
paymentProcessorUseCase.increaseRetryCount(retryablePaymentEvent);
// ...
// 4. 토스 결제 승인 요청 재시도 후 완료 처리 - 토스 측 재시도 가능/불가능한 에러 발생 가능
TossPaymentInfo tossPaymentInfo = paymentProcessorUseCase.confirmPaymentWithGateway(
paymentConfirmCommand
);
paymentProcessorUseCase.markPaymentAsDone(
retryablePaymentEvent,
tossPaymentInfo.getPaymentDetails().getApprovedAt()
);
} catch (PaymentRetryableValidateException // 에러 1: 재시도 불가능한 상태 에러 발생
| PaymentTossNonRetryableException e) { // 에러 2: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(retryablePaymentEvent); // FAIL 처리 + 재고 복구
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(retryablePaymentEvent); // UNKNOWN 상태로 변경
}
}
// ...
}

재시도 검증 후, 기존 결제 승인 요청과 같이 토스 측에 재시도 요청을 보내도록 처리하였으며, 결과에 따라 다음과 같이 처리한다.

flowchart TD
%% 노드 정의
START([복구 로직 시작])
DB[(DB)]
FETCH[재시도 가능 결제 조회]
CHECK_COUNT{재시도 횟수 검증}
INC_COUNT[재시도 횟수 증가]
REQUEST[결제 승인 요청]
CHECK_SUCCESS{성공 여부}
CHECK_RETRY{재시도 가능 여부}
DONE[Done 처리]:::green
FAILED[Failed 처리]:::red
UNKNOWN[Unknown 처리]:::yellow
%% 흐름 연결
START --> FETCH
DB -. " 일정 시간 지난 IN_PROGRESS\nUNKNOWN 상태 조회 " .-> FETCH
FETCH --> CHECK_COUNT
CHECK_COUNT -- " No " --> FAILED
CHECK_COUNT -- " Yes " --> INC_COUNT
INC_COUNT --> REQUEST
REQUEST --> CHECK_SUCCESS
CHECK_SUCCESS -- " Yes " --> DONE
CHECK_SUCCESS -- " No " --> CHECK_RETRY
CHECK_RETRY -- " No " --> FAILED
CHECK_RETRY -- " Yes " --> UNKNOWN
UNKNOWN --> DB
%% 스타일 정의 (모든 상태 노드에 color:#000 적용)
classDef green fill: #D5E8D4, stroke: #82B366, color: #000
classDef red fill: #F8CECC, stroke: #B85450, color: #000
classDef yellow fill: #FFF2CC, stroke: #D6B656, color: #000
style DB fill: #F5F5F5, stroke: #666, color: #000
style CHECK_COUNT fill: #FFE6CC, stroke: #D79B00, color: #000
style CHECK_SUCCESS fill: #FFE6CC, stroke: #D79B00, color: #000
style CHECK_RETRY fill: #FFE6CC, stroke: #D79B00, color: #000
  • 성공: 재시도 성공 후 결제 완료 처리(이미 재고가 차감되어 있으므로, 재고 감소나 복구는 필요 없음)
  • 토스 측 재시도 불가능한 에러: 해당 결제에 대해 실패 처리 + 재고 복구
  • 토스 측 재시도 가능 에러: UNKNOWN 상태로 변경
  • 재시도 횟수 초과: 해당 결제에 대해 실패 처리 + 재고 복구

재시도 횟수를 초과하면 UNKNOWN 상태를 유지하지 않고 결제 실패로 처리해 무한 재시도를 방지했다.

서버 장애나 네트워크 문제로 동일 결제가 중복 시도될 수 있어, 멱등키를 도입해 중복 결제를 방지했다.

  • 이번 프로젝트에서는 결제 이벤트 생성 시 만들어지는 고유한 orderId를 멱등키로 사용
  • UUID를 사용하여 고유한 키임을 보장

멱등키는 토스 결제 서버 요청 시 HTTP 헤더에 포함해 전송하며, 동일 요청이 중복 처리되지 않도록 보장한다.

항목기존 시스템 문제점개선된 시스템 해결 방안
에러 처리모든 에러를 동일하게 결제 실패로 처리재시도 가능/불가능 에러로 구분하여 각각에 맞는 처리 도입
API 응답 지연응답 지연 시 결제 실패로 처리UNKNOWN 상태로 관리하고 재시도를 통해 최종적으로 성공 처리
결제 도중 서버 중단서버 중단으로 결제 완료 여부를 알 수 없고, 중복 결제 발생 가능성 존재멱등키를 사용하여 중복 결제 방지 및 재시도 로직을 통한 결제 완료 처리

UNKNOWN 상태 추가로, 결제 상태 추적이 어려운 한계점이 존재하는데, 이를 보완하기 위한 추가적인 개선이 이루어진다면, 결제 시스템의 완성도를 더욱 높일 수 있을 것으로 기대된다.

  • 정확한 상태 변경 이력 추적 부족 -> 결제 이력 저장으로 해결
    • 결제 상태가 변경될 때마다 상태 변경 이력을 추적하고, 상태 변경에 따른 추가적인 로그를 남기지 않아 추후 에러에 대한 추적이 어려울 수 있음
  • 원장 및 지갑 관리 시스템 부재
    • 책에서 제시한 것처럼 원장/지갑 관리 기능이 포함되지 않아 결제 내역과 잔액 간의 정확한 일치 여부를 추적할 수 없음

외부 의존성 제어를 통한 결제 프로세스 다양한 시나리오 검증

실행 환경: Java 21, Spring Boot 3.3.3, JUnit 5

결제 시스템은 서비스 신뢰성과 직결되므로, 다양한 시나리오를 검증하는 테스트 코드가 필수적이다.

  • 단위 테스트: 개별 도메인 및 서비스 로직을 대상으로 세부 로직 검증
  • 통합 테스트: 실제 결제 흐름을 따라 다양한 예외 상황에 대응하는 테스트 작성

단위 테스트와 통합 테스트로 구분해 작성했으며, 토스 결제 시스템의 외부 의존성 제어가 핵심 과제였다.

참고: 토스 실제 결제 승인은 클라이언트 SDK를 통해 결제 요청을 먼저 수행해야 하며, 그 후에 결제 승인을 진행해야 한다.

결제 승인 과정에서 발생할 수 있는 다양한 시나리오에 대응할 수 있고, 신뢰성 높은 결제 프로세스를 보장하기 위해 여러 테스트 전략을 적용해보았다.

테스트를 통해 보장하고자 한 주요 목표는 다음과 같다.

  1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보
  2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성
  3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증

1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보

Section titled “1. 결제 승인 과정 중 발생할 수 있는 다양한 시나리오 검증 및 도메인/서비스 로직 신뢰성 확보”

결제 승인 과정은 복잡한 절차적 흐름과 다양한 예외 상황을 포함하므로, 파라미터화된 테스트로 여러 시나리오를 한번에 검증했다.

  • 단일 케이스를 넘어 여러 파라미터를 조합한 테스트를 통해 다양한 시나리오 및 상태값 검증

  • 서비스 로직에서 발생 가능한 에러에 대한 처리 확인

  • 단순 성공 케이스 뿐만 아니라, 예외 상황이나 여러 상태 값에 대한 다양한 케이스를 고려하여 테스트 코드 작성

  • PaymentEventTest.java

class PaymentEventTest {
@ParameterizedTest
@CsvSource({
"1, validPaymentKey, 15000, order123, INVALID_TOTAL_AMOUNT",
"2, invalidPaymentKey, 15000, order123, INVALID_PAYMENT_KEY",
"1, validPaymentKey, 14000, wrongOrderId, INVALID_ORDER_ID"
})
@DisplayName("다양한 조건에서 검증 실패 시 PaymentValidException 예외가 발생한다.")
void validate_InvalidCases(
Long userId,
String paymentKey,
int amount,
String orderId
) {
PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy(
() -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo))
.isInstanceOf(PaymentValidException.class);
}
@ParameterizedTest
@EnumSource(value = TossPaymentStatus.class, names = {
"CANCELED", "EXPIRED", "PARTIAL_CANCELED", "ABORTED"
})
@DisplayName("결제 상태가 유효하지 않은 경우 PaymentStatusException 예외가 발생한다.")
void validate_InvalidPaymentStatus(TossPaymentStatus tossPaymentStatus) {
PaymentEvent paymentEvent = defaultPaymentEvent();
// ...
assertThatThrownBy(
() -> paymentEvent.validateCompletionStatus(paymentConfirmCommand, paymentInfo))
.isInstanceOf(PaymentStatusException.class);
}
}

서비스 로직에서 Toss API 통신 중 발생할 수 있는 모든 에러 코드에 대한 처리도 함께 테스트했다.

class PaymentGatewayServiceImplErrorCaseTest extends IntegrationTest {
// ...
@ParameterizedTest(name = "{index}: Test with TossPaymentErrorCode={0}")
@EnumSource(TossPaymentErrorCode.class)
@DisplayName("TossPaymentErrorCode에 따라 Header를 설정하고 결제 확인 결과를 검증한다.")
void confirmPayment_withTossPaymentErrorCode(TossPaymentErrorCode errorCode) {
// given
String uuid = new SystemUUIDProvider().generateUUID();
ReflectionTestUtils.invokeMethod(httpOperator, "addHeader", "TossPayments-Test-Code", errorCode.name());
// ...
// when
TossPaymentInfo tossPaymentInfo = paymentGatewayService.confirmPayment(tossConfirmCommand, uuid);
PaymentConfirmResultStatus paymentConfirmResultStatus = tossPaymentInfo.getPaymentConfirmResultStatus();
// then
Assertions.assertThat(paymentConfirmResultStatus)
.isEqualTo(getExpectedResultStatus(errorCode));
}
// ...
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new AdditionalHeaderHttpOperator();
}
}
}

헤더에 특정 에러 코드를 추가하면 토스 측이 에러를 반환하는 테스트 API를 활용해 에러 상황을 시뮬레이션하여, 예외 상황에 대한 처리와 에러별 재요청 가능/불가능 여부를 검증했다.

2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성

Section titled “2. 인프라스트럭처 영역은 페이크 또는 목 객체를 활용하여 효율적인 테스트 환경 구성”

토스 API 결제 승인은 클라이언트 SDK 선행 요청이 필요해 통합 테스트 진행이 어려웠는데, 페이크 객체로 외부 의존성을 제거해 이 문제를 해결했다.

public class FakeTossHttpOperator implements HttpOperator {
// ...
// Reflection을 통해 호출되는 메서드
@SuppressWarnings("unused")
public void setDelayRange(int minDelayMillis, int maxDelayMillis) {
this.minDelayMillis = minDelayMillis;
this.maxDelayMillis = maxDelayMillis;
}
@SuppressWarnings("unused")
public void addErrorInPostRequest(String code, String message) {
this.code = code;
this.message = message;
this.isErrorInPostRequest = true;
}
@SuppressWarnings("unused")
public void clearErrorInPostRequest() {
this.isErrorInPostRequest = false;
}
@SuppressWarnings("java:S2925")
private void simulateNetworkDelay() {
long delay = minDelayMillis + (long) (Math.random() * (maxDelayMillis - minDelayMillis));
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void throwError() {
// ...
}
// ...
@Override
public <T, E> E requestPost(String url, Map<String, String> httpHeaderMap, T body, Class<E> responseType) {
// 네트워크 지연 시뮬레이션
simulateNetworkDelay();
// 에러 발생 시뮬레이션
if (isErrorInPostRequest) {
throwError();
}
// ...
// 실제 API 호출 없이 응답 반환
return responseType.cast(tossPaymentApiResponse);
}
}

위 구현체는 기존 실제 요청을 보내는 구현체의 인터페이스인 HttpOperator를 구현했지만 실제 네트워크 요청 대신, 결제 승인을 위한 객체로 만들었다.

  1. 실제 API 호출 X: 외부 API 호출 없이 승인 결과 반환
  2. 네트워크 지연 시뮬레이션: 실제 환경과 유사하게 네트워크 지연 시간 설정 가능
  3. 에러 발생 시뮬레이션: 특정한 오류 코드와 메시지를 지정한 에러 발생 가능

페이크 객체는 @TestConfiguration으로 테스트 환경에서만 사용되도록 설정하고, Reflection으로 테스트 코드에서 호출한다.

class PaymentControllerTest extends IntegrationTest {
@Test
@DisplayName("Payment Confirm 요청이 성공하면 결제가 승인되고 DONE / SUCCESS 상태로 변경되면서 재고가 감소한다.")
void confirmPayment_Success() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "clearErrorInPostRequest"); // 에러 없음 => 성공
// ...
}
@Test
@DisplayName("Payment Confirm 요청 중 재시도 가능 오류가 발생하면 결제는 실패하고 UNKNOWN / UNKNOWN 상태로 변경되면서 재고는 감소된 상태로 유지된다.")
void confirmPayment_Failure_RetryableError() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest",
TossPaymentErrorCode.PROVIDER_ERROR.name(), // 재시도 가능한 오류 코드
TossPaymentErrorCode.PROVIDER_ERROR.getDescription()
);
// ...
}
@Test
@DisplayName("Payment Confirm 요청 중 재시도 불가능 오류가 발생하면 결제는 실패하고 FAILED / FAIL 상태로 변경되면서 재고는 다시 복구된다.")
void confirmPayment_Failure_NonRetryableError() throws Exception {
// ...
ReflectionTestUtils.invokeMethod(httpOperator, "addErrorInPostRequest",
TossPaymentErrorCode.INVALID_STOPPED_CARD.name(), // 재시도 불가능한 오류 코드
TossPaymentErrorCode.INVALID_STOPPED_CARD.getDescription()
);
// ...
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

페이크 객체를 활용해 외부 API 의존성과 절차적 선행 조건을 제거했고, 다양한 결제 시나리오를 간결하게 테스트했다.

3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증

Section titled “3. 멀티 스레드 테스트로 기본적인 동시성 이슈 검증 및 타임아웃 시나리오에 대한 검증”

여러 사용자가 동일한 상품을 동시에 주문하거나 외부 API 타임아웃이 발생할 수 있기 때문에 이러한 상황에 대한 검증이 필요하다.

동시성 문제는 여러 요청이 동시에 동일한 리소스를 수정할 때 발생하며, 멀티 스레드 테스트를 통해 상황을 시뮬레이션하여 주문 수량과 재고가 정확히 관리되는지 확인했다.

@Tag("TooLongIntegrationTest")
class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest
@CsvSource({
"1000, 1000, 1000, 0, 0", // 재고와 주문 수량이 일치
"1000, 999, 999, 0, 1", // 주문 수량이 재고보다 적음
"1000, 1001, 1000, 1, 0", // 주문 수량이 재고보다 많음
"1000, 1050, 1000, 50, 0", // 재고 초과 주문 (50개 초과 주문 실패)
"1200, 1000, 1000, 0, 200", // 재고가 1200개, 주문 수량은 1000개 (200개 남음)
})
@DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.")
void concurrentConfirmPayment_withStock(
int stock,
int orderCount,
int expectedSuccess,
int expectedFail,
int expectedStock
) {
// ...
executeConcurrentActions(orderIndex -> {
try {
// ...
MvcResult mvcResult = mockMvc.perform(
post("/api/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(confirmRequest))
).andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}, orderCount);
// then
Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain();
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

멀티스레드 수행 중엔 FakeTossHttpOperator로 외부 API 호출 없이 테스트를 구성했다.(실제 테스트 키로 부하 테스트를 수행하면 토스 측에서 일정 시간 동안 요청을 제한한다.)

외부 네트워크 지연으로 인한 타임아웃 문제를 검증하기 위해 결제 승인 요청에 대해 일정 지연을 설정하여 해당 상황에서도 모든 결제 요청이 적절하게 처리되는지 검증하는 테스트를 작성했다.

@Tag("TooLongIntegrationTest")
class PaymentConfirmConcurrentTest extends IntegrationTest {
@ParameterizedTest
@CsvSource({
"500, 1000",
"1000, 3000",
"3000, 7000",
})
@DisplayName("멀티스레드로 Payment Confirm 요청 시 결제 승인 처리와 상태가 동시성에 맞게 처리된다.")
void confirmPayment_withTimeout(
int minDelayMills,
int maxDelayMills
) {
// ...
// 네트워크 지연 설정
ReflectionTestUtils.invokeMethod(httpOperator, "setDelayRange", minDelayMills, maxDelayMills);
executeConcurrentActions(orderIndex -> {
try {
// ...
MvcResult mvcResult = mockMvc.perform(
post("/api/v1/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(confirmRequest))
).andReturn();
if (mvcResult.getResponse().getStatus() == 200) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}, orderCount);
// then
Product updatedProduct = jpaProductRepository.findById(1L).orElseThrow().toDomain();
assertThat(successCount.get()).isEqualTo(expectedSuccess);
assertThat(failCount.get()).isEqualTo(expectedFail);
assertThat(updatedProduct.getStock()).isEqualTo(expectedStock);
}
@TestConfiguration
static class TestConfig {
@Bean
public HttpOperator httpOperator() {
return new FakeTossHttpOperator();
}
}
}

결제 시스템은 안정성이 최우선이기 때문에, 발생 가능한 다양한 시나리오와 문제를 테스트하는 것이 다른 도메인보다 더 중요하다고 생각한다.

jacoco test coverage

단위 테스트와 통합 테스트로 로직과 전체 흐름을 검증했고, 멀티 스레드 테스트로 동시성 및 타임아웃 처리까지 확인했다.

  1. 결제 승인 과정 검증: 파라미터화된 테스트로 다양한 상황별 예외 처리를 검증
  2. 효율적인 테스트 환경: FakeTossHttpOperator로 외부 API 호출을 대체해 유연한 테스트 환경 구성
  3. 동시성·타임아웃 처리: 멀티 스레드 테스트로 실서비스 환경에서 발생할 수 있는 문제 사전 검증

단, 이번 테스트는 기본적인 동시성·타임아웃 수준의 검증이며, 정밀한 부하 테스트는 전용 툴을 통해 추가로 진행해야 한다.

트랜잭션 범위 최소화를 통한 성능 및 안정성 향상

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33
[!CAUTION] 이 글은 복구 로직 적용 전의 내용을 담고 있으며, 현재 적용된 내용은 마지막 섹션에 기술

MySQL 관련 공부를 하던 중, 트랜잭션 범위의 중요성에 대해 학습하게 되었고, 이를 개선하기 위해 트랜잭션 범위 최소화 작업을 진행하였다.

  • 커넥션 풀 부족
    • 커넥션 풀은 데이터베이스와 연결된 커넥션을 미리 생성해두고, 요청이 들어올 때마다 커넥션을 빌려주고 반납받는 방식으로 동작한다.
    • 커넥션 풀에는 최대 커넥션 수가 정해져 있으며, 이를 초과하게 되면 대기열에 들어가게 된다.
    • 트랜잭션 범위가 넓어지면 커넥션을 오래 사용하게 되고, 이로 인해 커넥션 풀의 커넥션 수가 부족해지게 되어 대기열에 들어가게 된다.
    • 이로 인해 다른 요청들이 대기하게 되고, 이는 서비스의 응답 시간을 느리게 하게 된다.
  • 잠금 대기
    • 만약 트랜잭션에서 레코드의 잠금을 거는 경우, 넓어진 트랜잭션 범위만큼 점유하는 시간이 길어지게 된다.
    • 해당 레코드를 다른 트랜잭션에서 사용하려고 할 때, 다른 트랜잭션들이 락을 대기하게 되고, 이는 데드락이 발생할 확률을 높이게 된다.

최근에 읽었던 Real MySQL 8.0 서적에서도 트랜잭션 범위를 최소화하라고 권장하고 있으며, 특히 네트워크 작업이 있는 요청은 반드시 트랜잭션에서 배제해야 한다고 말하고 있다.

이전에 개발한 결제 연동 시스템에서 하나의 트랜잭션에서 네트워크 요청을 포함한 많은 작업을 수행하고 있었다.(전체적인 플로우는 링크 참고)

OrderService.java
public class OrderService {
// ...
@Transactional // 해당 메서드 전체에 걸친 트랜잭션 범위
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(X-LOCK)
OrderInfo orderInfo =
this.getOrderInfoByOrderPessimisticLock(orderConfirmRequest.getOrderId());
// 2. 재고 감소
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
// 3. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

전체를 감싸고 있는 트랜잭션 범위

  1. 하나의 트랜잭션 범위에 포함된 모든 로직
    • 실질적으로 데이터베이스의 트랜잭션이 필요한 부분은 초록색으로 표시된 부분 뿐이지만 전체 로직을 트랜잭션 범위로 감싸고 있음
  2. 불필요한 테이블의 레코드까지 X-LOCK을 통해 조회
    • 재고 감소 동시성 처리를 위해 필요한 테이블은 Product 레코드 뿐이지만, 불필요하게 OrderInfoUser의 레코드까지 잠금 설정
    • 여기서 얻은 LOCK을 로직이 끝나는 시점까지 유지하게 되어 다른 트랜잭션들이 대기하게 되어 데드락 발생 확률 상승
  3. 외부 API 요청을 트랜잭션 범위 내에서 수행
    • 외부 API 호출은 네트워크 작업을 수반하여 시간적 범위 증가
    • 트랜잭션을 유지하는 시간이 길어지게 되어 다른 트랜잭션들의 대기 시간 증가하여 전체 서비스 응답 시간 증가
    • 최악의 경우 외부 API에서 응답이 늦게 오거나 Time Out 되면 기하급수적으로 서비스 응답 시간 증가

외부 API 응답이 늦은 경우 발생하는 문제

Section titled “외부 API 응답이 늦은 경우 발생하는 문제”

가장 문제가 될 수 있는 부분은 외부 API 응답이 늦게 오거나 Time Out 되는 경우인데, 결제 승인 요청 API 응답이 늦게 오는 경우를 시뮬레이션하여 확인해보았다.

MockController.java
// Mock Server를 띄워 승인 요청에 대한 응답 수신 시 3초 대기하도록 설정
@RestController
@RequiredArgsConstructor
public class MockController {
// ...
@PostMapping("/confirm")
public TossPaymentResponse confirmPayment(@RequestBody TossConfirmRequest tossConfirmRequest) {
// time out
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// ...
}
}

동시에 10개의 주문 승인 요청을 보내는 동시성 테스트를 수행한 결과는 다음과 같다.

Lock 해제를 대기하는 나머지 트랜잭션

Section titled “Lock 해제를 대기하는 나머지 트랜잭션”

Lock 해제를 대기하는 나머지 트랜잭션들

Lock을 가진 트랜잭션이 네트워크 지연을 기다리는 동안 다른 스레드들도 Lock 해제를 대기하는 것을 확인할 수 있다.

동시에 10개의 주문 승인 요청 테스트 결과

Section titled “동시에 10개의 주문 승인 요청 테스트 결과”

동시에 10개의 주문 승인 요청 테스트 결과

3초씩 대기하게 되어 10개 밖에 안 되는 요청이 30초가 넘게 걸리게 되었으며, 10개보다 조금 더 많은 개수인 20개로만 설정했을 땐 Time Out으로 마지막 몇 개의 요청은 실패했다.

트랜잭션 범위 내에 있는 외부 API 요청 배제

Section titled “트랜잭션 범위 내에 있는 외부 API 요청 배제”

문제를 해결하기 위해 네트워크에 영향을 받는 외부 API 요청을 트랜잭션 범위 밖으로 배제하고, Database에 대한 작업만 트랜잭션 범위 내에서 수행하도록 변경하였다.

OrderService.java
public class OrderService {
// ...
// @Transactional 어노테이션 제거하여 트랜잭션 X
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. 락 없이 단순 조회
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 3. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo); // @Transactional이 없으므로 명시적으로 저장
// WARNING!! 2번 이후에 에러 발생 시, 2번에서 발생한 재고 감소 건이 롤백되지 않음
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

외부 API 요청을 트랜잭션 범위 밖으로 배제

하지만 2번에서 재고 감소 이후 에러가 발생 시 재고 감소 건이 롤백되지 않게 되어 결국 재고만 감소된 상태로 남게 되면서, 데이터 불일치가 발생하게 된다.

로직 순서 변경을 통한 위험 범위 최소화

Section titled “로직 순서 변경을 통한 위험 범위 최소화”

위 문제를 최소화하기 위해 로직 순서를 변경해 에러 발생 위험 범위를 줄였다.

public class OrderService {
// ...
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 3. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 4. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 5. TossPayment 측 결제 승인(외부 API)
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo);
// WARNING!! 4번 이후에 에러 발생 시, 2번에서 발생한 재고 감소 건이 롤백되지 않음
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

아래와 같은 근거로 TossPayment 측 결제 정보 조회결제 정보 검증재고 감소 및 Database Commit 수행 전으로 변경하였다.

  • TossPayment 측 결제 정보 조회: 주문 승인 전 검증을 위한 외부 API 요청으로, 재고 감소 및 데이터베이스 커밋 이전에 수행해도 무방
  • 결제 정보 검증: 주문 승인 전 검증을 위한 로직이므로, 재고 감소 및 데이터베이스 커밋 이전에 수행해도 무방

에러 발생 범위 최소화

하지만 여전히 에러 발생 시 재고 감소 건이 롤백되지 않는 근본적인 문제점은 해결되지 않았다.

보상 트랜잭션 명시적 추가를 통한 롤백

Section titled “보상 트랜잭션 명시적 추가를 통한 롤백”

결제 승인 전에 재고 감소를 먼저 수행 시킨 뒤 재고를 다시 증가시키는 보상 트랜잭션을 명시적으로 추가하였고, 보상 트랜잭션을 재고로 선정한 이유는 다음과 같다.

  • 재고 증감 작업은 서버에서 직접 제어가 가능하므로, 핸들링하는데 있어 비교적 더 안전
  • 결제 승인 요청을 먼저 수행하는 경우, 승인에 대한 보상 트랜잭션으로 결제 취소라는 외부 API 요청을 수행 필요
    • 결국 외부 API를 통한 작업이기 때문에, 네트워크 의존성이 더 커지면서 위험 요소가 증가
    • 돈이 실제로 빠져나가고(승인 API) 다시 채워지게 되어(취소 API) 사용자 경험 저하
OrderService.java
public class OrderService {
// ...
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
OrderInfo orderInfo = this.getOrderInfoByOrderId(orderConfirmRequest.getOrderId());
// 2. TossPayment 측 결제 정보 조회(외부 API)
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 3. 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 4. 재고 감소 및 Database Commit
productService.reduceStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
// 5. TossPayment 측 결제 승인(외부 API) + 6. 주문 확정 상태 변경
OrderInfo confirmedOrderInfo = this.confirmPaymentAndOrderInfoWithStockRollback(
orderConfirmRequest,
orderInfo
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// 이후 로직 하나의 메서드로 추출
private OrderInfo confirmPaymentAndOrderInfoWithStockRollback(
OrderConfirmRequest orderConfirmRequest,
OrderInfo orderInfo
) {
try {
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
orderInfoRepository.save(confirmedOrderInfo);
return confirmedOrderInfo;
} catch (Exception e) { // 에러 발생 시 롤백 수행
// 재고 증가 및 Database Commit
productService.increaseStockWithCommit(
orderInfo.getProduct().getId(),
orderInfo.getQuantity()
);
throw e;
}
}
// ...
}

4번 재고 감소 이후 에러가 발생할 수 있는 나머지 로직을 하나의 메서드로 추출하고, 해당 메서드 내에서 에러 발생 시 재고 처리하도록 변경하였다.

보상 트랜잭션을 통한 롤백 수행

로직 순서 변경과 보상 트랜잭션 추가로 다음 성과를 달성했다.

네트워크 지연 설정 X, 트랜잭션 범위 최소화 전/후 수행시간 비교(11s -> 6s)

Section titled “네트워크 지연 설정 X, 트랜잭션 범위 최소화 전/후 수행시간 비교(11s -> 6s)”

트랜잭션 범위 최소화 전(네트워크 지연 설정 X) - 11s 트랜잭션 범위 최소화 후(네트워크 지연 설정 X) - 6s

잠금 범위 축소로 성능이 향상됐으며,

네트워크 지연 설정 3,000ms, 트랜잭션 범위 최소화 후 1,000개 요청 성공

Section titled “네트워크 지연 설정 3,000ms, 트랜잭션 범위 최소화 후 1,000개 요청 성공”

트랜잭션 범위 최소화 후(네트워크 지연 설정 3,000ms) - 1m 40s

API 지연으로 잠금 대기가 늘어나는 장애를 방지해 1,000개의 요청을 모두 성공하며, 이전에 20개 요청에서 타임아웃이 발생했던 것과 비교하면 안정성이 크게 향상됐다.

에러 처리 및 복구 로직 적용에 따른 트랜잭션 범위 변화

Section titled “에러 처리 및 복구 로직 적용에 따른 트랜잭션 범위 변화”

결제 상태의 추가와 결제 복구 로직 추가로 인해 트랜잭션 범위 정책과 복구 로직도 함께 변경되었다.

에러 종류에 맞는 복구 로직 수행

  • 결제 시작 처리 먼저 수행 후 재고 감소를 수행
  • 결제 검증 및 외부 API 요청을 수행하는 로직은 동일하게 유지
  • 시작 처리를 먼저 데이터베이스에 반영하기 때문에 그 이후에 발생하는 예외 상황들에 대해 맞는 복구 로직 각각 작성
    • 재시도 가능과 불가능한 에러로 구분
    • 재시도 가능한 에러는 UNKNOWN 상태로 변경하고, 재고는 그대로 유지
    • 재시도 불가능한 에러는 FAIL 상태로 변경하고, 재고를 복구
PaymentConfirmServiceImpl.java
public class PaymentConfirmServiceImpl implements PaymentConfirmService {
// ...
@Override
public PaymentConfirmResult confirm(PaymentConfirmCommand paymentConfirmCommand) {
// 1. 결제 이벤트 조회
PaymentEvent paymentEvent = paymentLoadUseCase.getPaymentEventByOrderId(
paymentConfirmCommand.getOrderId()
);
try {
// 2. 재고 차감 - 재고 부족 에러 발생 가능
orderedProductUseCase.decreaseStockForOrders(paymentEvent.getPaymentOrderList());
} catch (PaymentOrderedProductStockException e) { // 에러 1: 재고 부족 에러 발생
handleStockFailure(paymentEvent); // FAIL 처리(재고 중 일어난 에러로, 감소가 일어나지 않았으므로 복구 필요 없음)
throw PaymentTossConfirmException.of(PaymentErrorCode.ORDERED_PRODUCT_STOCK_NOT_ENOUGH);
}
try {
// 3. 결제 시작 처리 - 결제 시작 상태 검증 중 에러 발생 가능
paymentProcessorUseCase.executePayment(paymentEvent, paymentConfirmCommand.getPaymentKey());
// 4. 결제 검증 - 결제 검증 중 에러 발생 가능
// 5. 토스 결제 승인 요청 - 토스 측 재시도 가능/불가능한 에러 발생 가능
// 6. 결제 완료 처리
PaymentEvent completedPayment = processPayment(paymentEvent, paymentConfirmCommand);
// 7. 결제 완료 결과 반환
// ...
} catch (PaymentStatusException e) { // 에러 2: 이미 요청이 진행 된 건에 대한 요청
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
} catch (PaymentTossRetryableException e) { // 에러 3: 토스 측 재시도 가능한 에러 발생
handleRetryableFailure(paymentEvent); // UNKNOWN 상태로 변경 + 재고 그대로 유지
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_RETRYABLE_ERROR);
} catch (PaymentTossNonRetryableException e) { // 에러 4: 토스 측 재시도 불가능한 에러 발생
handleNonRetryableFailure(paymentEvent); // FAIL 처리 + 재고 복구
throw PaymentTossConfirmException.of(PaymentErrorCode.TOSS_NON_RETRYABLE_ERROR);
} catch (Exception e) { // 에러5: 결제 검증 중 에러 등 기타 에러 발생
handleUnknownException(paymentEvent); // FAIL 처리 + 재고 복구
throw e;
}
}
// ...
}

결제 정보 검증을 통한 안전한 결제 연동 시스템 구현

실행 환경: Java 17, Spring Boot 3.1.5
서버 위주의 포스팅이기 때문에 클라이언트 코드는 간략하게 작성
프론트엔드는 토스페이먼츠에서 제공해준 샘플 프로젝트 리액트 일부 수정 사용
[!CAUTION] 해당 문서는 2023년 11월 기준의 코드로 작성되었으며, 구현 코드 상방 부분 변경

KG이니시스, 다날 페이먼트, 토스 페이먼츠 등의 결제 서비스를 제공하는 업체들이 존재하는데, 그 중 토스페이먼츠를 이용하여 결제 시스템을 구현해보았다.

  • 토스페이먼츠에서 제공한 클라이언트 코드 사용
  • 서버를 두어 검증 단계를 추가한 결제 시스템 구현

문서가 잘 되어 있어 해당 문서를 참고하면 되며, 핵심 용어를 간단하게 요약하면 아래와 같다.

  • 결제 위젯: 토스페이먼츠에서 제공해주는 결제 위젯 SDK로, 결제 요청을 위한 정보를 받아 결제 요청 전송
  • Client Key: 결제 위젯을 사용하기 위해 필요한 키로(토스 페이먼츠에서 제공)
  • Secret Key: 결제 승인 및 조회를 위해 필요한 키로(토스 페이먼츠에서 제공)
    • 공개하면 안되는 비밀키이므로 서버에서만 사용
  • 결제 후 리다이렉트: 결제가 완료되면 결제 정보와 함께 리다이렉트를 해주는데, 이때 결제 정보는 URL 파라미터로 전달
    • 결제 인증만 완료된 상태(완전히 결제가 완료된 상태 X)
  • 결제 승인: 인증된 결제를 최종 승인

출처(https://docs.tosspayments.com/guides/learn/payment-flow)

결제 흐름 페이지에도 나와있듯이 결제 흐름은 요청 - 인증 - 승인 단계로 나눌 수 있다.

기본 결제 흐름을 기반으로, 안전한 결제를 위해 서버는 검증자 역할을 수행한다.

sequenceDiagram
autonumber
participant C as Client
participant S as Server
participant T as PG
Note over C, T: 결제 시퀀스 흐름
C ->> S: 주문 번호 생성 요청
S ->> S: 구매 요청 검증 및 DB 저장
S -->> C: 주문 번호 반환
C ->> T: 결제 요청
T ->> T: 카드사 결제 인증
T -->> C: 성공 리다이렉트
C ->> S: 결제 승인 요청
S ->> T: 결제 정보 조회
T -->> S: 결제 정보 반환
S ->> S: 결제 / 주문 정보 양방향 검증
S ->> T: 결제 승인
T -->> S: 승인 성공 반환
S ->> S: DB 업데이트
S -->> C: 성공 내역 반환
C ->> C: 결제완료

1. 주문 번호 생성 요청 - (클라이언트)

Section titled “1. 주문 번호 생성 요청 - (클라이언트)”

실제 결제를 하기 위해선 주문 번호가 필요한데, 생성 정책을 다음과 같이 변경했다.

  • Before: 클라이언트 측에서 직접 주문 번호 생성
  • After: 서버측에서 생성하여 반환

결제 선택 클라이언트 화면

위 화면의 결제하기 버튼을 누르면 실제 결제 요청 전에 서버에 주문 번호를 생성하는 요청을 먼저 보낸다.

2. 구매 요청 검증 및 DB 저장 + 3. 주문 번호 반환 - (서버)

Section titled “2. 구매 요청 검증 및 DB 저장 + 3. 주문 번호 반환 - (서버)”

서버에서는 주문 번호 반환 뿐만 아니라 구매 상품에 대한 검증과 DB에 저장하는 작업을 수행하게 된다.

OrderService.java
public class OrderService {
// ...
@Transactional
public OrderCreateResponse createOrder(OrderCreateRequest orderCreateRequest) {
OrderProduct orderProduct = orderCreateRequest.getOrderProduct();
OrderInfo createdOrder = orderInfoRepository.save(
// 1. 생성자 호출
orderCreateRequest.toEntity(
userService.getById(orderCreateRequest.getUserId()),
productService.getById(orderProduct.getProductId())
));
return new OrderCreateResponse(createdOrder); // 4. Order ID를 포함한 생성된 주문 정보 반환
}
// ...
}
OrderInfo.java
public class OrderInfo extends BaseTime {
// ...
// 2. 생성 및 검증
@Builder
protected OrderInfo(/* ... */) {
// ...
this.validateProductInfo(totalAmount, quantity); // 3. 생성 완료 전 상품 정보 검증
}
private void validateProductInfo(BigDecimal totalAmount, Integer quantity) {
this.product.validateStock(quantity); // 상품 재고 검증
// 상품 가격 * 수량 == 결제 금액 검증
BigDecimal totalPrice = this.product.getPrice().multiply(BigDecimal.valueOf(quantity));
if (totalAmount.compareTo(totalPrice) != 0) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_TOTAL_AMOUNT);
}
}
// ...
}

우선 상품 금액과 결제 금액이 일치하는지 검증하고, DB에 저장한다.(여기서 저장한 정보는 이후 승인 요청에서 검증을 위해 사용된다.)

4. 결제 요청 + 5. 결제 인증 + 6. 성공 리다이렉트 - (클라이언트)

Section titled “4. 결제 요청 + 5. 결제 인증 + 6. 성공 리다이렉트 - (클라이언트)”

주문 번호를 성공적으로 받게 되면 클라이언트에서 결제 정보를 통해 요청을 하게 되고 아래 화면에서 결제 완료 버튼을 누르면 결제 인증이 진행된다.

결제 승인 보내기 전 클라이언트 화면

결제 인증까지 완료된다면 paymentKey, orderId, amount 정보를 포함하여 성공 페이지로 리다이렉트 되는데, 각 서버에서 검증 과정을 거치도록 한다.

7. 결제 승인 요청 - (클라이언트)

Section titled “7. 결제 승인 요청 - (클라이언트)”

토스에서 제공해준 클라이언트 코드에서는 성공 페이지로 리다이렉트 되면 결제 승인 요청을 클라이언트에서 직접하고 있었으나, 서버에 요청을 거치도록 하였다.

8. 결제 정보 조회 + 9. 결제/주문 정보 검증 + 10. 결제 승인 + 11. DB 업데이트 + 12. 성공내역 반환 - (서버)

Section titled “8. 결제 정보 조회 + 9. 결제/주문 정보 검증 + 10. 결제 승인 + 11. DB 업데이트 + 12. 성공내역 반환 - (서버)”

결제 승인 요청을 받은 서버는 클라이언트에서 받은 승인 요청 정보와 결제 요청 및 승인을 통해 저장된 토스페이먼츠 결제 정보를 검증하게 된다.

OrderService.java
public class OrderService {
// ...
@Transactional
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. Order ID 저장 때 생성된 주문 정보 조회(+ Pesimistic Lock으로 재고 차감 동시성 제어)
OrderInfo orderInfo = this.getOrderInfoByOrderPessimisticLock(
orderConfirmRequest.getOrderId()
);
// 2. 재고 충분한지 확인 + 재고 차감
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
// 3. 결제 정보 조회
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
// 4. 저장된 정보 + 클라이언트 요청 정보 + 토스에 저장된 결제 정보 검증
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 5. 결제 승인
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 6. OrderInfo 업데이트
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
// 7. 성공내역 반환
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}

OrderInfo와 재고를 차감할 Product에 Lock을 걸어 재고 차감 동시성 문제를 방지하였고, 검증 및 데이터 변경은 OrderInfo 엔티티에서 수행했다.

OrderInfo.java
public class OrderInfo extends BaseTime {
// ...
// (위 코드의 4번에서 호출)저장된 정보 + 클라이언트 요청 정보 + 토스에 저장된 결제 정보 검증
public void validateInProgressOrder(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 주문 상태가 IN_PROGRESS가 아니라면 결제 승인 요청을 할 수 없음
if (!paymentInfo.getStatus().equals(OrderStatus.IN_PROGRESS.getStatusName())) {
throw OrderInfoException.of(OrderInfoErrorMessage.NOT_IN_PROGRESS_ORDER);
}
this.validateOrderInfo(paymentInfo, orderConfirmRequest);
}
// (위 코드의 6번에서 호출)OrderInfo 업데이트
public OrderInfo confirmOrder(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 승인 요청 후 결제 정보가 DONE이 아니라면 결제 승인이 완료되지 않음
if (!paymentInfo.getStatus().equals(OrderStatus.DONE.getStatusName())) {
throw OrderInfoException.of(OrderInfoErrorMessage.NOT_DONE_PAYMENT);
}
this.validateOrderInfo(paymentInfo, orderConfirmRequest);
updateOrderPaymentInfo(paymentInfo); // 결제 정보 업데이트
return this;
}
// 검증 로직, validateInProgressOrder/confirmOrder에서 호출
private void validateOrderInfo(TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest) {
// 저장된 order id == 클라이언트 요청 order id
// 저장된 user id == 클라이언트 요청 user id
// 클라이언트 요청 payment key == 토스에 저장된 payment key
// 클라이언트 요청 amount == 토스에 저장된 total amount == 상품 가격 * 수
// 코드 생략
}
// ...
}

validateOrderInfo를 결제 승인 요청 전/후로 두 번 호출하였는데, 그 목적은 다음과 같다.

  • 요청 전: 아직 검증되지 않은 결제 정보가 불필요하게 결제 승인 요청 되는 것을 방지하기 위해 검증을 수행
  • 요청 후: 승인 요청 후 올바르게 결제 정보를 승인하였는지 다시 한 번 검증

검증 자체는 비용이 크지 않으므로, 불필요한 결제 승인 요청을 방지하기 위해 검증 로직을 승인 전후로 두 번 호출하는 것이 더 안전하다고 판단했다.

결제가 무사히 완료되면 결제 완료 페이지로 이동하게 되면서 결과를 확인할 수 있다.

결제 성공 클라이언트 화면

또한 토스 페이먼츠의 테스트 결제내역에서도 동일한 주문번호가 남아있어 정상적으로 결제가 완료되었음을 확인할 수 있다.

토스 결제 내역 화면

구현을 완료하고 나니 외부 API 연동 구조에서 발생 가능한 여러 문제들이 발견되었고, 추후 이러한 문제점을 개선 방향으로 잡으면 좋을 것 같다.

OrderService.java
public class OrderService {
// ...
@Transactional // 외부 API 2회 요청이 있는 트랜잭션 범위
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
OrderInfo orderInfo = this.getOrderInfoByOrderPessimisticLock(
orderConfirmRequest.getOrderId()
);
productService.reduceStock(orderInfo.getProduct().getId(), orderInfo.getQuantity());
TossPaymentResponse paymentInfo = paymentService.getPaymentInfoByOrderId(
orderConfirmRequest.getOrderId()
);
orderInfo.validateInProgressOrder(paymentInfo, orderConfirmRequest);
// 결제 승인
TossPaymentResponse confirmPaymentResponse =
paymentService.confirmPayment(
TossConfirmRequest.createByOrderConfirmRequest(orderConfirmRequest)
);
// 요청이 지연 되는 경우..
if (true)
throw new Exception("test"); // 만약 승인 이후 오류 발생하면?
OrderInfo confirmedOrderInfo = orderInfo.confirmOrder(
confirmPaymentResponse,
orderConfirmRequest
);
return new OrderConfirmResponse(confirmedOrderInfo);
}
// ...
}
  • 결제 승인 이후 오류 발생 시, 서버의 데이터베이스에는 전부 롤백이 되지만 토스에는 결제 승인된 채로 남음
  • 내부에서 오류가 발생할 확률은 낮지만, 갑작스러운 서버 장애 시 문제 발생 가능
  • 결제 승인 단계에서 통신 중 응답이 지연 케이스
  • 우리 서버의 타임 아웃 설정 값이 5초 + 토스 API 6초 지연 시 타임 아웃 발생
  • 토스사에서는 결제 승인 완료 상태 / 우리 서버에서는 결제 승인이 완료되지 않은 것으로 처리되는 데이터 불일치 상황 발생
  • 외부 API 요청이 2회이 존재하는 넓은 트랜잭션 범위 설정
  • API 타임 아웃이 발생하게 되면 락을 획득한 상태에서 계속 대기하면서, 요청이 많아질 경우 성능 저하 발생