- 예상 트래픽 기준: DAU 50K, Peak CCU 15K, Peak 20,000 TPS
- 인프라 설계: 단일 인프라 (4 Core, 8GB RAM) 매칭 엔진의 한계 처리량 계측 및 아키텍처 설계 수행
| 지표 | 목표값 |
|---|---|
| 매칭 엔진 처리량 | 20,000 TPS |
| 주문 처리 지연 (P99) | < 10ms |
| 실시간 메시지 지연 | < 50ms |
| REST API 응답 지연 (P95) | < 50ms |
| Orderbook 업데이트 주기 | < 10ms |
flowchart LR
Order[주문 유입] --> Match[Match<br/>인메모리 체결]
Match --> Journal[Journal<br/>이벤트 기록]
Journal --> Proj[Projection<br/>조회 모델]
subgraph Optimize["초저지연 3축"]
ZGC[ZGC]
Long[long 고정소수점]
Ring[LMAX RingBuffer]
end
Optimize -.-> Match
- 매칭 연산과 DB 트랜잭션이 한 경로에 결합된 상태에서 Queue 락 경합·
BigDecimal힙 할당·Minor GC가 동시에 발생했음. - 10,000 TPS 부하에서 응답이 p(95) 358ms, max 1.74s까지 튀며 초저지연 목표를 만족하지 못했음.
- GC(G1 vs ZGC), 자료형(
BigDecimalvslong), 큐(BlockingQueue vs Lock-Free RingBuffer) 3축을 비교한 결과, STW·힙 할당·락 경합을 제거하는 조합이 목표에 부합한다고 판단함.
- GC를 Generational ZGC로 전환해 일시정지(STW) 영향을 줄이고, 지연 분포의 꼬리를 완화함.
- 가격·수량을 고정소수점
long으로 재설계해 매칭 핫패스의 힙 할당을 제거하고 GC 압력을 낮춤. - LMAX Disruptor(RingBuffer 65,536, BusySpin)로 Match → Journal → Projection 파이프라인을 구축해 락 기반 큐를 제거함.
- Micrometer로 gateway / matching / journal 구간별 지연을 계측해 병목을 수치로 추적할 수 있게 함.
- ZGC 전환: 주문 지연 p(95) 358ms → 253ms.
long전환: Old 영역 메모리 412MB → 272MB, GC 부담 감소.- Disruptor 파이프라인 적용 후 동일 10,000 TPS 부하에서 max 지연 1.74s → 210ms, p(99) 안정적 두 자릿수 ms 구간으로 수렴.
flowchart LR
subgraph Engine["매칭 프로세스"]
M[Match] --> J[Journal]
J --> P[Publisher]
end
P -->|append| Kafka[(Kafka Commit Log)]
Kafka --> G1[account-service]
Kafka --> G2[market-data-service]
Kafka --> G3[notification-service]
Kafka --> G4[order-projection]
Kafka --> G5[stop-trigger-service]
G1 --> RDB[(RDB)]
G4 --> RDB
- LMAX Disruptor를 붙여도 RingBuffer가 가득 차면 톰캣 요청이 대기하는 백프레셔가 남았고, 매칭·DB Insert를 각각 최적화해도 E2E 지연이 개선되지 않았음.
- 근본 원인은 매칭(CPU-bound)과 저널/프로젝션(Disk I/O-bound)이 같은 프로세스·생명주기를 공유해, RDB 지연·장애가 매칭 핫패스까지 전파되는 구조에 있었음.
- RingBuffer는 프로세스 메모리 안에서만 속도 차이를 완충하므로, 버퍼 포화·프로세스 사망 시 미기록 체결은 유실되며 리플레이로도 복구할 수 없었음.
- 인프라(장애 격리)·비용(CPU/디스크 독립 스케일)·안정성(비결정적 I/O 제거)·내구성(독립 생명주기 버퍼) 4관점에서 매칭과 후처리 영속화를 분리하고, 스레드/인메모리 큐가 아닌 메시지 큐 패턴(Pub-Sub) 을 채택함.
- Redis Streams·RabbitMQ·Kafka를 1차 저장소·Replay·순서 보장 기준으로 비교한 뒤, 디스크 커밋 로그·재생 가능·파티션 키 기반 순서+병렬을 만족하는 Kafka를 Journal 이후 Publisher 팬아웃 버스로 선택함.
trade.completed/order.lifecycle/order.projection토픽을 두고, Confluent 파티션 공식N = max(T/p, T/c)기준으로 파티션 3·레플리카 2를 산정함.- 파티션 키는 MarketID·UserID·OrderID로 고정해 마켓/사용자/주문 단위 순서를 보장하고, 정산·시세·알림·프로젝션·스탑트리거를 독립 Consumer Group으로 분리해 동일 이벤트를 각자의 속도로 병렬 소비함.
- RDB 일시 장애가 매칭 코어를 멈추지 않도록 격리되어,
trade.completed를 서로 다른 Consumer Group 3개가 독립 구독하는 고가용 구조를 확보함. - 핫패스에서 Projection·Account·Notification 등 비결정적 I/O가 제거되어 RingBuffer를 막던 RDB 역압이 완화되고, 코어 매칭은 주문 입력만으로 상태가 바뀌는 결정성에 가까워짐.
- 후처리 이벤트는 Kafka 로그에 append되어 재시작·지연과 무관하게 오프셋 단위로 재소비 가능하며, 연산 서버와 기록 서버를 분리 스케일링할 인터페이스를 마련함.
flowchart LR
Trade[체결 이벤트] --> Buf[인메모리<br/>Ticker / Kline]
Buf --> WS[WebSocket STOMP]
Buf --> Sched[스케줄러]
Sched --> DB[(RDB 스냅샷)]
WS --> Pub["/topic 공개 시세"]
WS --> Priv["/user 개인 알림"]
- 거래소 시세는 서버 UTC 기준의 Ticker(스냅샷)·Kline(시계열)·24h 롤링 윈도우를 실시간으로 전달해야 하며, 목표 메시지 지연은 50ms 미만이었음.
- k6 1,000 VU로 마켓 50개 Ticker + BTC 1m Kline을 구독하자 p(95) 66ms, max 607ms(Ticker max 1,001ms)까지 지연이 튀어 목표를 초과했음.
- VU당 51개 구독으로 서버에 약 51,000 세션이 생기고, 체결마다 SimpleBroker가 구독 세션별 전송 작업을 Outbound 큐에 밀어 넣어 큐잉 딜레이·CPU 포화가 발생했음.
- Polling·SSE·WebSocket을 비교해 양방향·낮은 오버헤드·동적 구독·바이너리 확장성을 이유로 WebSocket + STOMP를 채택하고, Public(
/topic)·Private(/user) 채널과 JWT CONNECT/SUBSCRIBE 가드를 분리함. (설계 기록) - 체결 이벤트를 매칭 파이프라인 바깥의
marketDataTaskExecutor에서 수신해 인메모리 Ticker/Kline을 갱신한 뒤 즉시 방송하고, RDB 반영은 백그라운드 스케줄러로 분리함. - Outbound 채널에
QueueDelayTaskDecorator로websocket_outbound_queue_delay를 계측하고, 플랫폼 스레드 풀(16~64)에서 가상 스레드(core/max 8,192)로 전환해 I/O 팬아웃 비용을 줄이려 함. - 병목 가설을 “세션마다 반복되는 STOMP 헤더 인코딩·문자열 조립의 CPU 비용”으로 좁히고, 로컬호스트(어태커·디펜더 동일 PC) 오염을 인지해 다음 최적화 축을 설정함.
- 단일 Ticker 구독(1,000 VU)에서는 핸드셰이크 에러 0, 브로드캐스트 p(95) 26ms로 목표(< 50ms)를 만족함.
- 50 마켓 전역 구독 시 p(95) 66ms·Ticker max 1,001ms를 재현하고, Outbound 큐잉 딜레이 max 약 100ms·방송 평균 지연 약 70ms·CPU 포화(~1.0)를 Grafana로 확인함.
- 가상 스레드 적용 후 max는 줄었으나 p(95) 개선은 제한적이었고, 병목이 컨텍스트 스위칭보다 STOMP 프레임 인코딩 CPU에 있음을 수치로 입증함.