Skip to content

실행 환경: 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 상태 추가로, 결제 상태 추적이 어려운 한계점이 존재하는데, 이를 보완하기 위한 추가적인 개선이 이루어진다면, 결제 시스템의 완성도를 더욱 높일 수 있을 것으로 기대된다.

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

Last updated: