Skip to content
chungjeongsu edited this page Mar 14, 2026 · 7 revisions

v1 - 동기식 결제 구현

초기 결제는 아래와 같이 동기적으로 구현되었습니다.

image

DB 트랜잭션과 외부 API 호출 분리

DB 접근을 위한 커넥션 풀은 한정적인 자원입니다. 또한, 많은 사용자가 해당 커넥션 풀을 이용합니다.

때문에, 해당 커넥션 내부에서 서버에서 통제할 수 없는 외부 API 호출을 하지 말아야 한다고 생각하였습니다.

이로, Facade 패턴을 이용하여, 3가지의 결제 파이프라인을 구성합니다.

1번 작업 - 승인 요청 검증

1번 작업은 승인 요청을 검증하는 작업이었습니다.

이 작업에서는 첫번째 트랜잭션으로 클라이언트에서 받은 PaymentKey를 DB에 저장하고, 결제 상태를 READY에서 IN_PROGRESS로 바꿉니다.

상태를 통해 외부 API 중복 호출을 막을 수 있었습니다.

2번 작업 - 승인 API 호출

2번 작업은 토스 결제 API를 호출하는 작업이었습니다.

이 작업에서는 토스 API를 호출합니다. 외부 API와 서버의 경계였기 때문에, 다양한 예외가 발생할 수 있는 취약 경계였습니다.

아래는 이 작업에서 발생할 수 있는 문제입니다.

  1. API 호출 요청을 네트워크 문제로 보낼 수 없음
  2. 토스 내부에서 예외 발생(재시도 가능 vs 재시도 불가능)
  3. 실 결제(승인) 완료 후 응답 유실

1번 문제와 2번 중 재시도 가능한 문제, 3번 문제 이 세가지는 back-off를 적용한 재시도 전략으로 해결을 시도하였습니다.

나머지 2번 중 재시도 불가능 예외는 예외를 사용자에게 바로 던졌습니다.

위에서 볼 수 있듯 토스의 PaymentKey를 통해 중복 결제를 막을 수 있었고, 이로 재시도를 수월하게 수행할 수 있었습니다.

3번 작업 - 승인 결과 DB 저장

3번 작업은 외부 API 호출이 성공 후 해당 결과를 DB에 저장하는 작업이었습니다.

2번째 트랜잭션을 통해 IN_PROGRESS에서 DONE 상태로 업데이트합니다.

v1 - 문제점

1. 부분 실패로 인한 상태 전이 복잡도 증가

V1은 3개의 작업이 존재했습니다.

  1. 승인 요청 검증
  2. 승인 API 호출
  3. 승인 결과 DB 저장

이 구조에서는 단계 사이에서 부분 실패가 발생할 수 있습니다.

  • 1번 실패 : 검증부터 실패한 경우
  • 1번 성공-2번 실패: 검증 성공 IN_PROGRESS 상태에서 실 결제 실패한 경우
  • 1번 성공-2번 성공-3번 실패: 검증, 실 결제 성공 후 DONE 상태로 바꾸지 못한 경우

초기 의도한 상태 전이는 다음과 같았습니다.

  • 모두 성공 : DONE
  • 부분 실패 : FAILED

따라서 부분 실패를 FAILED로 수렴시키기 위해 각 단계마다 별도 예외 분기와 보상 트랜잭션이 필요했습니다.

특히 실패 지점이 1번, 2번, 3번으로 분산되어 try-catch와 상태 업데이트 로직이 곳곳에 퍼졌고, 결과적으로 결제 승인 플로우의 구현 복잡도와 변경 비용이 함께 증가했습니다.

2. 토스 API Latency - 기본 1초

동기식 결제였기 때문에, 결제 승인 API의 Latency가 우려되었습니다.

결제는 Payment 테이블 단일 레코드 조회/수정이었기 때문에, Payment 인스턴스 조회는 인덱스를 통해 쉽게 해결할 수 있을 것이라 생각했습니다.

하지만, 토스 API 호출은 서버에서 통제할 수 없었기에 약 100건의 결제를 진행해보았습니다.

100건의 호출 Latency 평균 1104ms(소수점 1에서 반올림)가 나왔기 때문에 성능 우려가 되었습니다.

때문에, 1초를 Sleep하는 목 서버를 구현하여 Artillery를 사용한 100건의 성능 테스트를 진행해보았습니다.

아래는 초당 10개의 결제 요청을 10초 발생 시킨 성능 그래프입니다.

image image

당연한 결과로, 1초~1.5초의 Latency를 볼 수 있습니다.

3. 재시도 시 쓰레드 점유 시간 - 동기적인 재시도 Worker 쓰레드 점유

결제라는 특수한 경우에서 1초 가량의 Latency는 문제가 될 부분이 없었습니다.

하지만, 진짜 문제는 예외를 통한 재시도가 되는 상황에서였습니다.

image image

위는 한 번의 Timeout 예외가 발생하는 상황입니다. 1~1.5의 Latency가 2까지 증가한 것을 볼 수 있습니다.

image image

위는 두 번의 Timeout 예외가 발생하는 상황입니다. 거의 4의 Latency를 갖는 것을 볼 수 있습니다.

# application.yml
server:
  tomcat:
    threads:
      max: 200 
      min-spare: 10 
    max-connections: 8192 
    accept-count: 100 
    connection-timeout: 20000 
  port: 8080 

Default 스프링 부트의 Worker Thread Pool의 설정값입니다.

즉, 타임 아웃 시 2개의 요청이 2개의 쓰레드를 차지하고 있어, 총 8개의 쓰레드만이 요청을 처리했음을 알 수 있습니다.

v2 - 비동기식 Outbox Pattern 도입

다시 정리해보자면,

  1. 실패 시 보상 트랜잭션을 통한 실패로 업데이트 - 복잡한 실패 처리
  2. 토스 API의 Latency 1초 - 기본적으로 높은 Latency
  3. 동기적인 재시도 - 동기적 재시도 시 쓰레드 점유로 인한 폭등하는 Latency

위와 같은 3가지의 문제로 인하여,

  1. 실패 시 자연스러운 실패 상태 업데이트
  2. Worker 쓰레드 비중 줄이기
  3. 비동기적인 재시도

위의 3가지의 해결책이 필요했습니다.

실패 시 자연스러운 실패 상태 업데이트 해결 - Outbox Pattern

image

Outbox 패턴을 적용한 결제 플로우는 위와 같습니다.

동기식 결제 프로세스는 실패 로직에 대한 아래와 같은 문제점이 있었습니다.

Clone this wiki locally