Skip to content

Payment Platform Project

8 posts with the tag “Payment Platform Project”

전략 패턴을 통한 PG 독립성 확보 및 확장 가능한 결제 시스템 설계

실행 환경: Java 21, Spring Boot 3.3.3

결제 시스템은 다양한 PG(Payment Gateway) 업체와 연동되어야 하며, 비즈니스 요구사항에 따라 PG를 유연하게 전환할 수 있어야 한다. 그러나 초기 구현에서는 특정 PG(Toss Payments)에 강하게 결합된 구조로 인해 다음과 같은 근본적인 문제들이 존재했다.

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

이러한 문제를 해결하기 위해 전략 패턴(Strategy Pattern)과 포트-어댑터 패턴(Port-Adapter Pattern)을 결합하여 PG 독립적인 아키텍처를 구축했다.
핵심은 추상화를 통한 의존성 역전으로, 도메인 레이어는 구체적인 PG 구현체가 아닌 추상화된 인터페이스에만 의존하도록 설계했다.

  • 도메인 모델 PG 독립화: PG 변경 시 핵심 비즈니스 로직 무수정
  • 런타임 전략 교체 가능(설정 기반): 코드 재배포 없이 PG 전환
  • 새로운 PG 추가 시 기존 코드 무영향(OCP 준수): 확장에는 열려있고 수정에는 닫힌 구조

위 목표를 달성하기 위해 다음 디자인 패턴을 적용했다.

  • 전략 패턴: PG별 결제 처리 로직을 독립적인 전략으로 캡슐화
  • 포트-어댑터 패턴: 외부 시스템(PG API)과의 경계를 명확히 분리
  • 팩토리 패턴: 설정 기반 전략 객체 생성 및 선택 자동화

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

graph TB
subgraph "Application Layer"
Service[PaymentConfirmServiceImpl]
UseCase[PaymentProcessorUseCase]
Port[PaymentGatewayPort<br/>Interface]
end
subgraph "Infrastructure Layer"
Adapter[PaymentGatewayAdapter<br/>Port 구현체]
Factory[PaymentGatewayFactory<br/>전략 선택]
Strategy[PaymentGatewayStrategy<br/>Interface]
subgraph "Strategy Implementations"
Toss[TossPaymentGatewayStrategy]
Future[Other PG Strategy<br/>... 확장 가능]
end
end
subgraph "External Systems"
TossAPI[Toss Payments API]
end
Service -->|사용| UseCase
UseCase -->|의존| Port
Port -.->|구현| Adapter
Adapter -->|위임| Factory
Factory -->|선택| Strategy
Strategy -.->|구현| Toss
Strategy -.->|확장 가능| Future
Toss -->|호출| TossAPI
style Port fill: #e1f5ff
style Strategy fill: #e1f5ff
style Adapter fill: #fff4e1
style Factory fill: #fff4e1
style Toss fill: #e8f5e9
style Future fill: #f5f5f5, stroke-dasharray: 5 5

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

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

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

public interface PaymentGatewayPort {
PaymentStatusResult getStatus(String paymentKey);
PaymentStatusResult getStatusByOrderId(String orderId);
PaymentConfirmResult confirm(PaymentConfirmRequest request);
PaymentCancelResult cancel(PaymentCancelRequest request);
}
  • PG 독립적인 DTO 모델 사용 (PaymentStatusResult, PaymentConfirmRequest)하여 특정 PG에 종속되지 않도록 설계
  • 모든 메서드는 특정 PG사 종속적이지 않은 DTO 사용
  • PG별 데이터 변환은 Infrastructure 레이어에서 처리

2. InternalPaymentGatewayAdapter(어댑터 구현)

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

Port를 구현하고 전략 패턴으로 위임하는 역할을 한다. 이 어댑터는 애플리케이션 레이어와 인프라스트럭처 레이어 사이의 중재자 역할을 수행하며, 실제 PG 통신 로직은 Strategy 구현체에 위임한다.

@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(properties.getType());
return strategy.confirm(request);
}
// 나머지 메서드도 동일한 패턴
}

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

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

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

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 구현체를 자동으로 수집하고, 런타임에 설정값에 따라 적절한 구현체를 선택한다.

@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)를 발생

5. TossPaymentGatewayStrategy(구체적 전략 구현)

Section titled “5. TossPaymentGatewayStrategy(구체적 전략 구현)”

Toss Payments API와의 실제 통신 로직을 수행하며, 구현체는 특정 PG사에 종속적인 세부 사항을 캡슐화한다.

@Component
@RequiredArgsConstructor
public class TossPaymentGatewayStrategy implements PaymentGatewayStrategy {
private final PaymentGatewayInternalReceiver paymentGatewayInternalReceiver;
@Override
public boolean supports(PaymentGatewayType type) {
return type == PaymentGatewayType.TOSS;
}
@Override
public PaymentConfirmResult confirm(PaymentConfirmRequest request) {
// 1. 도메인 모델 → Toss-specific 모델 변환
TossConfirmGatewayCommand tossCommand = TossConfirmGatewayCommand.builder()
.orderId(request.orderId())
.paymentKey(request.paymentKey())
.amount(request.amount())
.idempotencyKey(generateIdempotencyKey(request.orderId()))
.build();
// 2. Toss API 호출
PaymentGatewayInfo info = PaymentInfrastructureMapper.toPaymentGatewayInfo(
paymentGatewayInternalReceiver.confirmPayment(
PaymentInfrastructureMapper.toTossConfirmRequest(tossCommand)
)
);
// 3. Toss 응답 → 도메인 모델 변환
return convertToPaymentConfirmResult(info, request);
}
private PaymentConfirmResult convertToPaymentConfirmResult(
PaymentGatewayInfo info, PaymentConfirmRequest request) {
// Toss 상태 코드 → 도메인 상태 매핑
PaymentConfirmResultStatus status = determineConfirmResultStatus(info, failure);
return new PaymentConfirmResult(status, info.getPaymentKey(), ...);
}
}

해당 메서드는 계층 간 명확한 책임 분리를 위해 다음 3단계로 구성된다.

  1. 도메인 모델 변환: PaymentConfirmRequest (PG-독립 모델) → TossConfirmGatewayCommand (Toss-specific 모델)
    • 멱등성 키(Idempotency Key) 생성을 통한 중복 결제 방지
    • Toss API 요구사항에 맞는 데이터 형식 변환
  2. PG API 호출: PaymentGatewayInternalReceiver를 통한 실제 HTTP 통신
    • 네트워크 통신 및 인증 처리
    • 타임아웃, 재시도 등의 복원력 패턴 적용
  3. 응답 변환 및 매핑: Toss 응답 → 도메인 모델 (PaymentConfirmResult)
    • Toss의 상태 코드를 도메인 상태(PaymentConfirmResultStatus)로 정규화
    • PG-specific한 에러 정보를 도메인 예외로 변환

이번 아키텍처 리팩토링을 통해 기술적 개선과 함께 비즈니스 유연성을 확보할 수 있었다.

  1. PG 독립성 확보: 도메인/애플리케이션 레이어에서 PG-specific 타입 완전 제거
  2. 확장 가능한 구조: 기존 코드 수정 없이 새로운 PG 추가 가능

이러한 아키텍처는 결제 시스템의 핵심인 안정성확장성을 동시에 달성하며, 향후 비즈니스 요구사항 변경에 따른 다양한 요구사항에 유연하게 대응할 수 있는 기반을 제공할 수 있다.

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

실행 환경: Java 21, Spring Boot 3.3.3

결제 로직은 재고 차감(DB)‘과 외부 PG 결제(API)라는 두 개의 분리된 작업을 수행하고 있다. 이 과정에서 1) 재고 차감은 성공했지만, 2) PG 결제가 실패하는 경우가 발생할 수 있는데, 차감했던 재고를 다시 되돌리는 보상 트랜잭션(Compensation Transaction)을 통해 해결하고 있었다.

하지만 이 보상 트랜잭션이 실패할 수 있다는 문제가 남아있다.

  • 상황: PG사 결제 실패
  • 시스템: 재고를 롤백하기 위한 보상 트랜잭션 실행
  • 장애: 이 보상 트랜잭션이 DB 데드락, 커넥션 풀 고갈, 서버 다운 등으로 실패

이 경우, 시스템은 다음과 같은 심각한 데이터 불일치 상태에 빠진다.

  • 결제 상태: 실패
  • 재고 상태: 차감됨 (재고 롤백 실패)
  • 최종 결과: 고객은 구매에 실패했지만, 재고는 판매된 것으로 처리되어 유령 재고 발생

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

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

이 문제를 해결하기 위해 여러 해결 방법을 고려할 수 있었다.

  1. Two-Phase Commit (2PC)
    • 모든 리소스(재고 DB, PG사)가 커밋에 동의해야 하는 강력한 일관성 모델
    • 한계: 외부 PG사가 2PC를 지원하지 않으며, 전체 시스템의 성능 저하와 블로킹(Blocking)을 유발
  2. 메시지 큐와 Saga Pattern
    • 재고 차감, 결제 요청, 결과 처리를 별도의 트랜잭션으로 분리하고 메시지 큐(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
subgraph sg1 ["1단계: 작업 생성 및 재고 차감 (Transaction 1)"]
A["요청 시작"] --> B{"Tx START"};
B --> C["1. payment_process 테이블에<br/>'PROCESSING' 상태로 작업(Job) INSERT"];
C --> D["2. stock 테이블 재고 차감"];
D --> E{"Tx COMMIT"};
end
E -- 성공 --> F["2단계: 외부 PG사 결제 처리"];
F --> G{결제 성공?};
subgraph sg3 ["3단계: 작업 완료 (Transaction 2)"]
G -- Yes --> H_Success["Tx START"];
H_Success --> I_Success["payment_process 상태 'COMPLETED'로 UPDATE"];
I_Success --> J_Success["orders 테이블 상태 '결제 완료'로 변경"];
J_Success --> K_Success["Tx COMMIT"];
K_Success --> L_End["종료"];
G -- No --> H_Fail["Tx START"];
H_Fail --> I_Fail["payment_process 상태 'FAILED'로 UPDATE"];
I_Fail --> J_Fail["[보상 트랜잭션]<br/>차감했던 재고 복구"];
J_Fail --> K_Fail["Tx COMMIT"];
K_Fail --> L_End;
end
subgraph sg_recovery ["장애 복구 로직"]
Z["복구 로직 시작"] --> Y["payment_process 테이블에서<br/>'PROCESSING' 상태인 작업 조회"];
Y --> X{"PG사에 실제 결제 성공 여부 조회 or 승인 요청"};
%% 복구 로직이 중단되었던 3단계 로직을 다시 실행
X -- " 결제 성공 상태 " --> H_Success;
X -- " 결제 실패 상태 " --> H_Fail;
end
%% Style definitions using IDs
style sg1 fill: #f9f9f9, stroke: #333, stroke-width: 2px
style F fill: #fff, stroke: #333, stroke-width: 2px, stroke-dasharray: 5 5
style sg3 fill: #f9f9f9, stroke: #333, stroke-width: 2px
style sg_recovery fill: #e6f7ff, stroke: #0056b3, stroke-width: 2px, stroke-dasharray: 2 2

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

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)

    • 선정 이유: 상태 변경 기록은 전형적인 횡단 관심사 -> 서비스 코드 변경 최소화 가능
    • 장점: 어노테이션 기반 선언적 프로그래밍으로 적용 누락 최소화
      • Repository 직접 호출 대비 결합도 낮음
  • Spring ApplicationEvent

    • 선정 이유: 프레임워크 내장 이벤트 시스템 활용 -> 외부 라이브러리 불필요
    • 장점: 이벤트 발행(서비스)과 처리(저장) 분리 -> 결합도 낮춤
      • 메시지 큐 대비 인프라 비용 추가 없이 구현 가능
  • TransactionPhase.BEFORE_COMMIT

    • 선정 이유: 커밋 직전에 실행하여 비즈니스 상태와 이력 간 정합성 확보
    • 장점: 로직이 성공한 경우에만 기록
      • AFTER_COMMIT은 커밋 후 저장 실패하더라도, 비즈니스 로직이 이미 커밋된 상태가 되어 불일치 발생하는 문제 발생
      • 히스토리 테이블의 변경 이력 == 실제 비즈니스 상태 변경을 보장
  • AOP
    • 선정 이유: 기존 이력 추적 AOP와 동일한 메커니즘 활용 가능
    • 장점: 메서드 실행 시간(API 호출 시간) 측정 및 결제 상태 변경 감지 용이
  • Scheduler DB 스캔
    • 선정 이유: 5회 이상 재시도 상태 등 장기 체류 결제 건 탐지 용이
    • 장점: 주기적으로 스캔하고, 이를 메트릭으로 노출하여 Grafana 대시보드에서 실시간 모니터링 가능

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

Section titled “구현 세부 사항 1 - 결제 이력 추적”
Service Method (@PublishPaymentHistory)
AOP Aspect
ApplicationEvent 발행
@TransactionalEventListener (BEFORE_COMMIT)
PaymentHistory 저장
Payment 상태 변경

비즈니스 서비스 메서드에 @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. 로그 레벨을 직접 체크하는 방식
  2. 파라미터 포맷팅 방식 ({} 사용)
// 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번 방식인 파라미터 포맷팅 방식을 사용한다.

로그를 남기는 방식에 따라 성능 차이가 얼마나 나는지 테스트를 진행해보기 위해, 다음과 같이 멀티스레드 환경에서 로그를 남기는 테스트를 작성했다.(info 레벨 기준)

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 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();
}
}
}

방식은 3가지로 나누어 테스트를 진행했다.

  1. 문자열 결합 방식 (+ 사용)
  2. String.format() 방식
  3. 파라미터 포맷팅 방식 ({} 사용)
class LoggerTest extends IntegrationTest {
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();
}
}
방식소요 시간
문자열 결합 (+)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

최초 구상했던 결제 로직은 결제 정보 검증을 통해 안전한 결제 연동 시스템을 목표로 하였지만, 예상치 못한 에러에 대한 처리 로직이 미흡했다.
그로 인해 특수한 상황에서 사용자가 신뢰성을 느끼기 어려울 것으로 판단되어 결제 시스템에 대한 설계 및 구현을 다시 진행하게 되었다.

문제를 해결하기 위한 방법을 찾던 중 가상 면접 사례로 배우는 대규모 시스템 설계 기초 중 결제 시스템 파트를 통해 결제 시스템에 대한 도메인 지식과 안전한 결제 처리를 위한 해결 방법을 찾을 수 있었고, 이를 바탕으로 더 나은 결제 시스템을 설계하고자 했다.

책에서는 결제 서비스/결제 실행 서비스/원장 서비스/지갑 서비스로 분리된 구조를 제시했는데, 현 프로젝트에서는 결제 서비스와 결제 실행 서비스를 하나로 통합하여 구현하였다.
또한, 이번 개선 작업에서는 결제 서비스와 그 안에서 발생하는 문제들에 대해서 다뤘으며, 원장 서비스와 지갑 서비스에 대한 부분과 시스템에 주는 이점은 다음 단계에서 다루고자 한다.

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

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

이러한 문제들을 해결하기 위해 크게 아래 네 가지 방법들을 도입하였다.

  1. 재시도 가능/불가능 에러 구분
  2. 재시도 로직 구현
  3. 결제 상태 전환 관리
  4. 멱등키 사용

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

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

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

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

위에서 구분 된 재시도 가능한 에러에 대해, 일정 횟수까지만큼 재시도 처리하는 스케줄러를 구현하여 결제 성공으로 이어질 수 있도록 하였다.

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

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

결제 상태 다이어그램

  • READY: checkout 으로 인해 최초 생성된 결제 상태
  • 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. 결제 상태 전환 관리”

결제 승인 과정에서 발생하는 다양한 에러에 따라 시스템은 다음과 같이 처리하도록 구현하였다.

결제 승인 처리 흐름과 에러에 따른 상태 처리

이러한 에러 상태를 구분하기 위해, 먼저 토스 측에서 발생하는 에러 코드를 명확히 정의할 필요가 있었다.

  • 토스 측에서 발생하는 모든 에러 코드에 대해 Enum으로 정의
  • 해당 에러 코드에 대해 성공 / 재시도 가능 여부 판단 메서드 구현

그 외에 추가적으로 타임아웃 관련 에러 코드를 추가해 재시도 가능한 에러로 처리하였다.

@Getter
@RequiredArgsConstructor
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을 던지도록 하였다.

@Service
@RequiredArgsConstructor
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실패 처리재고 복구
@Service
@RequiredArgsConstructor
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: 결제 승인 시작 처리는 했지만 아직 결과를 받지 못한 상태

이 두 가지 상태를 기반으로 재시도 가능한 결제를 주기적으로 조회하여 재시도 처리를 수행하여, 결제를 성공으로 마무리하거나, 실패로 처리하도록 구현하였다.

@Service
@RequiredArgsConstructor
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 상태로 변경
}
}
// ...
}

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

결제 재시도 다이어그램

  • 성공: 재시도 성공 후 결제 완료 처리(이미 재고가 차감되어 있으므로, 재고 감소나 복구는 필요 없음)
  • 토스 측 재시도 불가능한 에러: 해당 결제에 대해 실패 처리 + 재고 복구
  • 토스 측 재시도 가능 에러: UNKNOWN 상태로 변경
  • 재시도 횟수 초과: 해당 결제에 대해 실패 처리 + 재고 복구

재시도 횟수 초과 에러로, 계속 해서 UNKNOWN 상태로 유지되더라도, 일정 횟수 이상 재시도를 했을 때 결제 실패로 처리하여 무한히 재시도하는 것을 방지하였다.

결제 처리 중 서버의 장애나 네트워크 문제로 동일한 결제 요청이 중복될 가능성이 존재하고, 해당 결제들에 대한 복구 로직이 실행되기 때문에 중복 결제를 방지하기 위해 멱등키를 도입하였다.

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

이렇게 생성 된 멱등키는 토스 결제 서비스로 요청을 보낼 때 HTTP 헤더에 포함되어 전송하여, 동일한 결제 요청에도 중복 결제를 막고 성공적인 결제 처리를 보장할 수 있었다.

이번 결제 시스템 개선을 통해 기존 구현에서 발생했던 주요 문제점들을 해결할 수 있었다.

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

이번 결제 시스템 개선을 통해 재시도 가능한 에러와 불가능한 에러를 구분하고, 멱등키를 통해 중복 결제를 방지하며, 재시도 로직을 도입하여 결제의 안정성과 유연성을 크게 향상시킬 수 있었다.
이를 통해 결제를 복구할 수 있는 시스템을 구현하였으며, 결제 처리의 신뢰성을 높일 수 있었다.

그러나 정확한 상태 변경 이력 추적과 원장 및 지갑 관리 시스템의 부재로 인해 결제 내역의 투명성 및 결제 상태 추적이 어려운 한계점이 존재한다.
이를 보완하기 위한 추가적인 개선이 이루어진다면, 결제 시스템의 완성도를 더욱 높일 수 있을 것으로 기대된다.

결제 상태가 변경될 때마다 상태 변경 이력을 추적하고, 상태 변경에 따른 추가적인 로그를 남기지 않아 추후 에러에 대한 추적이 어려울 수 있음

책에서 제시한 것처럼 원장/지갑 관리 기능이 포함되지 않아 결제 내역과 잔액 간의 정확한 일치 여부를 추적할 수 없음

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

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

결제 시스템은 서비스 신뢰성과 직결되기 때문에 안정적이고 오류 없는 시스템을 구축하기 위해서는 다양한 시나리오를 검증하는 테스트 코드 작성이 필수적이다.
결제 승인 과정에서 발생할 수 있는 다양한 시나리오에 대응할 수 있고, 신뢰성 높은 결제 프로세스를 보장하기 위해 여러 테스트 전략을 적용해보았다.

크게 단위 테스트와 통합 테스트로 구분지어 테스트 코드를 작성했으며, 각 테스트 코드의 목표는 다음과 같이 설정하였다.

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

특히 토스 결제 시스템의 의존성을 제어하는 것이 가장 주요한 과제였는데, 이를 위해 여러 전략을 활용하여 결제 시나리오를 테스트하는 방법을 적용했다.

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

위 두 가지를 기반으로 테스트를 통해 보장하고자 했던 주요 목표는 다음과 같다.

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

각 목표를 달성하기 위해 취한 전략 및 작성한 테스트 코드의 예시는 다음과 같다.

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

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

결제 승인 과정은 다양한 예외 상황과 복잡한 절차적 흐름을 포함하고 있기 때문에, 이를 신뢰성 있게 처리하기 위해서는 다양한 시나리오를 고려한 테스트 코드 작성이 필수적이다.
단일 케이스를 넘어 여러 파라미터를 조합한 테스트를 통해 다양한 시나리오를 검증하고, 서비스 로직에서 발생 가능한 모든 에러에 대한 처리를 확인함으로써 신뢰성을 높일 수 있었다.

PaymentEvent 관련된 로직에서는 결제 승인 및 검증 과정에서 발생할 수 있는 다양한 시나리오를 파라미터화된 테스트로 작성하여, 단순 성공 케이스 뿐만 아니라, 예외 상황이나 여러 상태 값에 대한 다양한 케이스를 고려하여 테스트 코드를 작성했다.

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. 동시성 이슈와 타임아웃 처리: 멀티 스레드 테스트를 통해 결제 시스템의 동시성 이슈와 타임아웃 처리 능력을 검증함으로써 실서비스 환경에서 발생할 수 있는 문제를 미리 방지할 수 있었다.

특히, 외부 API 서버에 요청을 보내는 대신 페이크 객체를 사용해 실제 요청 없이 테스트를 수행한 점이 테스트의 유연성을 높이는 데 큰 도움이 되었다.

다만, 이번 테스트는 동시성 이슈와 타임아웃 문제를 기본적인 수준에서 검증했을 뿐, 실제 부하가 걸리는 환경에서의 완벽한 검증을 수행했다고 보기는 어렵다.
따라서, 더 정밀한 부하 테스트는 전용 툴을 사용해 추가적으로 진행해야 하며, 이를 통해 결제 시스템의 성능 지표와 처리량을 더욱 정확하게 파악할 수 있을 것이다.
이러한 추가적인 검증 작업을 통해 결제 시스템의 안정성을 한층 더 강화할 수 있을 것이다.

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

실행 환경: Java 17, Spring Boot 3.1.5, MySQL 8.0.33
!! 해당 내용은 복구 로직 적용 전의 내용을 담고 있으며, 현재 적용된 내용은 마지막 부분에 기술되어 있습니다.

프로젝트 진행 중 MySQL 관련 공부를 하던 중, 트랜잭션 범위의 중요성에 대해 학습하게 되었다.
특히, 트랜잭션 범위가 넓을 경우 발생하는 성능 문제를 인식하게 되었고, 이를 개선하기 위해 트랜잭션 범위 최소화 작업을 진행하였다.

트랜잭션 범위가 넓어지면 다음과 같은 문제점이 발생할 수 있다.

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

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

이전에 개발한 결제 연동 시스템에서 하나의 트랜잭션에서 네트워크 요청을 포함한 많은 작업을 수행하고 있었다.
때문에 트랜잭션 범위가 넓어지게 되었는데, 대략적인 코드와 로직은 아래와 같다.
(자세한 로직 및 전체적인 플로우는 링크 참고)

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 응답이 늦게 오는 경우를 시뮬레이션하여 확인해보았다.

// 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 해제를 대기하는 나머지 트랜잭션들

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

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

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

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

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

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

public class OrderService {
// ...
// @Transactional 어노테이션 제거하여 트랜잭션 X
public OrderConfirmResponse confirmOrder(OrderConfirmRequest orderConfirmRequest) {
// 1. OrderInfo + Product + User Fetch Join 조회(락 없이 단순 조회)
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 요청을 트랜잭션 범위 밖으로 배제할 수 있게 되었다.

외부 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) 사용자 경험 측면에서 좋지 않음
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)

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

트랜잭션 범위 최소화를 통해 레코드에 잠금이 걸리는 범위를 줄여 성능 향상을 가져올 수 있었으며,

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

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

외부 API의 지연으로 잠금 대기 시간이 무한정 늘어나는 장애를 방지해 안정성을 향상 시켜, 1,000개의 요청을 모두 성공시킬 수 있었다.
이전에 20개의 요청을 보냈을 때 Time Out이 발생했던 것과는 달리, 네트워크 지연이 발생하더라도 이전보다 더 안정적으로 서비스를 기대할 수 있게 되었다.

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

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

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

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

결제 시작 처리를 먼저 수행하고, 재고 감소를 수행하며, 결제 검증 및 외부 API 요청을 수행하는 로직은 동일하게 유지하였다.
시작 처리를 먼저 데이터베이스에 반영하기 때문에 그 이후에 발생하는 예외 상황들에 대해 맞는 복구 로직을 각각 작성할 필요가 있었다.

  • 재시도 가능과 불가능한 에러로 구분
  • 재시도 가능한 에러는 UNKNOWN 상태로 변경하고, 재고는 그대로 유지
  • 재시도 불가능한 에러는 FAIL 상태로 변경하고, 재고를 복구
@Service
@RequiredArgsConstructor
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
서버 위주의 포스팅이기 때문에 클라이언트 코드는 간략하게 작성
프론트엔드는 토스페이먼츠에서 제공해준 샘플 프로젝트 리액트 일부 수정 사용
!! 해당 문서는 2023년 11월 기준의 코드로 작성되었으며, 구현 코드는 많은 부분이 변경되었습니다.

KG이니시스, 다날 페이먼트, 토스 페이먼츠 등의 결제 서비스를 제공하는 업체들이 존재하는데, 그 중 토스페이먼츠를 이용하여 결제 시스템을 구현해보았다.
토스페이먼츠에서는 이미 클라이언트 코드를 제공해주어 쉽게 구현할 수 있다.
하지만 클라이언트에서만 처리하는 것은 보안에 취약하기 때문에 중간에 서버를 두어 검증 하는 단계를 추가하여 결제 시스템을 구현해보고자 한다.

문서가 잘 되어 있어 해당 문서를 참고하는 것이 가장 좋으나 핵심 용어를 간단하게 요약하면 아래와 같다.

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

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

결제 흐름 페이지에도 나와있듯이 결제 흐름은 세 단계로 나눌 수 있다.

  1. 요청
  2. 인증
  3. 승인

문서가 잘 되어 있기 때문에 전체적인 기본 플로우나 각 단계에 대한 설명은 토스 제공 공식 문서를 참고하는 것이 좋은 것 같다.
기본적인 결제 흐름은 위와 같으며 안전한 결제를 위하여 결제 정보를 검증하기 위해 서버를 두어 검증자의 역할을 수행하도록 하였다.

서버를 둔 토스 결제 흐름

  1. 주문 번호 생성 요청: 클라이언트에서 결제 요청 전 주문 번호를 서버에 요청
  2. 구매 요청 검증 및 DB 저장: 서버에서는 주문 번호를 생성하고 받은 정보와 주문 번호를 DB에 저장
  3. 주문 번호 반환: 서버에서 생성한 주문 번호를 클라이언트에게 반환
  4. 결제 요청: 받은 주문 정보로 결제 요청
  5. 결제 인증: 결제 요청을 통해 결제 위젯이 뜨면 결제 인증 진행
  6. 성공 리다이렉트: 결제 인증이 완료되면 결제 정보와 함께 성공 페이지로 리다이렉트
  7. 결제 승인 요청: 성공 페이지로 리다이렉트 되면 서버에 결제 승인 요청
  8. 결제 정보 조회: 서버에서 결제 인증 단계에서 토스페이먼츠에 저장된 결제 정보 조회
  9. 결제/주문 정보 양방향 검증: 서버에서 결제 정보와 클라이언트에서 받은 결제 정보, DB에 저장된 주문 정보 검증
  10. 결제 승인: 결제 정보에 이상이 없다면 토스에 결제 승인 요청
  11. DB 업데이트: 결제 완료로 DB 업데이트
  12. 성공내역 반환: 클라이언트에게 성공 내역 반환
  13. 결제 완료: 클라이언트에서 결제 완료 페이지로 이동

각 과정에 대한 설명을 코드와 함께 아래에서 자세히 설명하도록 하겠다.

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

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

실제 결제를 하기 전에 주문 번호를 생성해야 하는데, 토스에서 제공해준 기존 클라이언트 코드에서는 직접 주문 번호를 생성하고 있었다.
이러한 정보는 클라이언트에서 생성하는 것 보단 서버에서 생성하는 것이 더 안전하다고 생각하여 서버에서 주문 번호를 생성하도록 하였다.

const requestData = {
userId: USER_ID, // 인증 처리 생략으로 인해 userId를 직접 넘겨줌
amount: PRICE,
orderProduct: { // 구매하는 상품 정보와 수량
productId: PRODUCT_ID,
quantity: QUANTITY,
},
}
// 주문 번호 생성 요청
const response = await fetch("http://localhost:8080/api/v1/orders/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
})

아래 화면에서 결제하기 버튼을 누르면 실제 결제 요청을 시작하기 전에 서버에 주문 번호를 생성하는 요청을 보내도록 구현하였다.

결제 선택 클라이언트 화면

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. toEntity()로 builder()를 호출하여 생성자를 호출
orderCreateRequest.toEntity(
// userId로 사용자 정보 조회
userService.getById(orderCreateRequest.getUserId()),
// productId로 구매 상품 정보 조회
productService.getById(orderProduct.getProductId())
));
return new OrderCreateResponse(createdOrder); // 4. Order ID를 포함한 생성된 주문 정보 반환
}
// ...
}
// OrderInfo.java
@Getter
@Entity
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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. 성공 리다이렉트 - (클라이언트)”
// 위의 클라이언트 로직 이후...
// 반환 된 주문 번호 저장
const orderId = json.orderId;
try {
// 결제 요청 시작
await paymentWidget?.requestPayment({
orderId: orderId,
orderName: ORDER_NAME,
customerName: CUSTOMER_NAME,
customerEmail: CUSTOMER_EMAIL,
successUrl: `${window.location.origin}/success`,
failUrl: `${window.location.origin}/fail`,
});
} catch (error) {
// 에러 처리
console.error(error);
}

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

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

결제 인증까지 완료된다면 성공 페이지로 리다이렉트 되는데, 이 때 리다이렉트 정보에는 paymentKey, orderId, amount 정보가 포함되어 있다.
각 정보는 결제에 있어 중요한 정보이기 때문에 서버에서 검증 과정을 거치도록 한다.

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

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

토스에서 제공해준 클라이언트 코드에서는 성공 페이지로 리다이렉트 되면 결제 승인 요청을 클라이언트에서 직접하도록 되어있다.
하지만 결제 승인 요청을 클라이언트에서 직접 하게 되면 결제 정보를 조작하여 요청을 할 수 있기 때문에 서버에 요청을 보내 검증 과정을 거치도록 하였다.

// 리다이렉트로 받은 결제 정보 데이터를 서버에게 전달하기 위해 데이터 생성
const requestData = {
userId: USER_ID,
orderId: searchParams.get("orderId"),
amount: searchParams.get("amount"),
paymentKey: searchParams.get("paymentKey"),
};
async function confirm() {
// 토스가 아닌 우리가 구축한 서버에 결제 승인 요청
const response = await fetch("http://localhost:8080/api/v1/orders/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
})
const json = await response.json();
// 실패 시 에러 페이지로 이동
if (!response.ok) {
console.log(json);
navigate(`/fail?message=${json.message}`)
return;
}
console.log(json);
}
confirm();

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

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

이제 서버에서는 결제 승인 요청을 받게 되는데, 클라이언트에서 받은 승인 요청 정보와 결제 요청 및 승인을 통해 저장된 토스페이먼츠 결제 정보를 검증하게 된다.
우선 결제 정보 조회 및 승인 요청 코드는 아래와 같다.

@Service
public class PaymentService {
@Value("${spring.myapp.toss-payments.secret-key}")
private String secretKey; // 토스 페이먼츠 인증에 사용되는 시크릿키
@Value("${spring.myapp.toss-payments.api-url}")
private String tossApiUrl; // 토스 페이먼츠 API URL
public TossPaymentResponse getPaymentInfoByOrderId(String orderId) {
return findPaymentInfoByOrderId(orderId)
.orElseThrow(() -> PaymentException.of(PaymentErrorMessage.NOT_FOUND));
}
// 결제 정보 조회
public Optional<TossPaymentResponse> findPaymentInfoByOrderId(String orderId) {
return HttpUtils.requestGetWithBasicAuthorization(
tossApiUrl + "/orders/" + orderId,
EncodeUtils.encodeBase64(secretKey + ":"),
TossPaymentResponse.class);
}
// 결제 승인 요청
public TossPaymentResponse confirmPayment(TossConfirmRequest tossConfirmRequest) {
return HttpUtils.requestPostWithBasicAuthorization(
tossApiUrl + "/confirm",
EncodeUtils.encodeBase64(secretKey + ":"),
tossConfirmRequest,
TossPaymentResponse.class);
}
// ...
}

노출되면 안되는 값들은 application.yaml로 관리하였고, RestTemplate을 사용하여 토스측에 결제 정보 조회 및 승인 요청을 보내도록 하였다.
다음으로 자세한 수행 작업 및 순서는 주석의 번호를 따라가며 확인할 수 있다.

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, 그리고 User에 대해 Lock을 걸어 동시에 재고 차감 및 수정되는 것을 방지하였다.
검증 및 데이터 변경은 OrderInfo 엔티티에서 수행하도록 하였고, 검증하는 정보는 주석에 작성해두었다.

OrderInfo.java
@Getter
@Entity
@Builder
@Table(name = "order_info")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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
if (!this.orderId.equals(orderConfirmRequest.getOrderId())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_ORDER_ID);
}
// 저장된 user id == 클라이언트 요청 user id
if (!this.user.getId().equals(orderConfirmRequest.getUserId())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_USER_ID);
}
// 클라이언트 요청 payment key == 토스에 저장된 payment key
if (!paymentInfo.getPaymentKey().equals(orderConfirmRequest.getPaymentKey())) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_PAYMENT_KEY);
}
// 클라이언트 요청 amount == 토스에 저장된 total amount == 상품 가격 * 수량
if (!compareAmounts(paymentInfo, orderConfirmRequest)) {
throw OrderInfoException.of(OrderInfoErrorMessage.INVALID_TOTAL_AMOUNT);
}
}
private boolean compareAmounts(
TossPaymentResponse paymentInfo,
OrderConfirmRequest orderConfirmRequest
) {
BigDecimal paymentInfoTotalAmount = BigDecimal.valueOf(paymentInfo.getTotalAmount());
BigDecimal orderConfirmRequestAmount = orderConfirmRequest.getAmount();
BigDecimal orderInfoAmount = this.product.getPrice()
.multiply(BigDecimal.valueOf(this.quantity));
return orderInfoAmount.compareTo(paymentInfoTotalAmount) == 0 &&
orderInfoAmount.compareTo(orderConfirmRequestAmount) == 0 &&
orderConfirmRequestAmount.compareTo(paymentInfoTotalAmount) == 0;
}
// ...
}

코드를 살펴보면 validateOrderInfo가 결국 결제 승인 요청 전/후로 두 번 호출 되는 것을 확인할 수 있다.

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

검증 자체는 큰 비용이 아니기 때문에 불필요하게 결제 승인 요청을 보내는 것 보다는 검증 로직을 두 번 호출하는 것이 더 안전하다고 판단하였다.
비슷한 맥락으로 승인 요청 전 결제 정보 조회 API 요청 전에 재고 검증을 먼저 수행하도록 하였다.

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

결제 성공 클라이언트 화면

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

토스 결제 내역 화면

외부 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);
}
// ...
}

결제 승인 이후 오류가 발생한다면 서버의 데이터베이스에는 전부 롤백이 되지만 토스에는 결제 승인된 내역이 그대로 남아있게 된다.
우선은 변동 가능성이 큰 재고를 가진 Product에 대한 Lock을 얻어 내부에서 오류가 발생할 확률은 낮지만, 해당 케이스에 대한 처리가 필요하다.

결제 승인 단계에서 통신 중 응답이 지연되는 경우가 발생할 수 있다.
우리 서버의 타임 아웃이 5초이고, 토스 API의 응답이 지연되어 6초가 걸렸다고 가정하면 우리 서버는 5초 후에 응답을 받지 못하고 타임 아웃이 발생하게 된다.
하지만 토스사에서는 결제 승인이 완료되었기 때문에 결제가 완료된 것으로 처리되지만 우리 서버에서는 결제 승인이 완료되지 않은 것으로 처리될 수 있다.
이번에도 마찬가지로 데이터베이스는 롤백되지만 토스사에는 결제 승인된 내역이 그대로 남아있게 된다.

현재 트랜잭션 범위가 메서드 전체에 걸려있는데, 해당 메서드 안에 외부 API 요청이 2회가 있기 때문에 트랜잭션의 시간적 범위가 넓어지게 된다.
여기서 API 타임 아웃이 발생하게 되면 락을 획득한 상태에서 계속 대기하게 되는데, 이는 다른 사용자의 요청이 많아질 경우 성능 저하를 발생시킬 수 있다.