Skip to content

실행 환경: 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 장애 시 시스템이 무너지지 않도록 지탱하는 가치를 데이터로 입증

Last updated: