[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 3

2026. 6. 17. 13:31·프로젝트/Snap Trade

지난 포스트에 이어서 거래 매칭 엔진의 아키텍처를 개선합니다.

https://soberyl.tistory.com/71

 

[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 2

지난 포스트에 이어서 거래 매칭 엔진의 아키텍처를 개선합니다.https://soberyl.tistory.com/70 [Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 1핵심 개념주문 가격 방식시장가 (Market Order) - 수량 우선 가격

soberyl.tistory.com

 

  지난 포스트에서 계획했던 대로, 완벽한 이벤트 소싱 아키텍처로 전면 개편합니다.

아키텍처 v3 : 이벤트 소싱 기반 비동기식 아키텍처

이벤트 소싱 기반으로 개편된 아키텍처

  위의 설계에 따라서 코드가 변경되었습니다. 영속화를 담당하는 스레드가 1개에서 2개로 증설됐습니다. Projection을 담당하는 스레드가 하나 추가되었고, 이를 담당할 큐가 하나 추가되었습니다.

    // ==========================================
    // Journal Worker
    // ==========================================
    private void startJournalWorker() {
        new Thread(() -> {
            final int BATCH_SIZE = 1000;
            final List<MatchPayload> buffer = new ArrayList<>(BATCH_SIZE);
            final List<Trade> trades = new ArrayList<>(BATCH_SIZE);
            final List<OrderEvent> events = new ArrayList<>(BATCH_SIZE * 2);

            while (true) {
                try {
                    MatchPayload firstPayload = journalQueue.take();
                    if (!firstPayload.traces().isEmpty()) {
                        journalQueueTimer.record(Duration.ofNanos(System.nanoTime() - firstPayload.traces().get(0).getPersistEnterTs()));
                    }

                    buffer.add(firstPayload);
                    journalQueue.drainTo(buffer, BATCH_SIZE - 1);

                    trades.clear();
                    events.clear();

                    for (MatchPayload payload : buffer) {
                        if (payload.trade() != null) trades.add(payload.trade());
                        if (payload.events() != null) events.addAll(payload.events());
                        if (!payload.traces().isEmpty()) payload.traces().get(0).markDbWriteStart();
                    }

                    journalDbIoTimer.record(() -> eventExecutionRepository.appendEvents(trades, events));

                    for (MatchPayload payload : buffer) {
                        if (!payload.traces().isEmpty()) payload.traces().get(0).markDbWriteEnd();

                        projectionQueue.offer(new MatchPayload(
                                payload.trade(),
                                payload.makerOrder(),
                                payload.takerOrder(),
                                payload.events(),
                                payload.traces(),
                                System.nanoTime()
                        ));
                    }

                    buffer.clear();

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); break;
                } catch (Exception e) {
                    log.error("Journal worker encountered an error. Events were NOT passed to Projection.", e);
                }
            }
        }, "Journal-Worker-Thread").start();
    }

 

  여기서 최초의 계획은 Order Events에만 append해서 trades와 orders는 replay를 통해서 update하는 것이었으나, trades도 같이 insert를 하게 되었습니다.

CQRS with Event Logging

  원래 교과서적인 이벤트 소싱 아키텍처에 따르면 저널 워커 스레드에서 order Events 테이블에만 append insert를 수행하고, 나머지 테이블에 대해서(trades, orders)는 프로젝션 테이블로 replay를 통해 업데이트를 하는 구조여야 합니다.

 

  하지만 다음과 같은 이유에서 저널 스레드에서 trades 테이블에도 인서트를 같이 하게되는 구조로 변경했습니다.

1. Trades 테이블은 단순한 Read Model이 아닙니다.

  체결 수수료의 정산 기준이고, 규제기관 보고의 법적 근거이며 원장의 참조 대상입니다. 이런 데이터가 이벤트를 참조하여 프로젝션 큐에 페이로드를 집어넣는다면, 프로젝션 워커가 이를 언제 그 이벤트를 소비하여 trades 테이블에 쓸지 확정할 수 없습니다.

 

체결 수수료의 정산 기준

  가령 해당 자산에 대한 큰 이슈가 발생해서 시스템에 트래픽이 폭주하여 프로젝션 큐 처리가 30초 지연이 발생했다고 한다면, 이 30초 동안 매칭엔진에서는 거래가 체결되었지만, DB 테이블에는 아무런 기록이 없는 상황이 발생합니다. 뿐만아니라 이런 상황에서 과거의 잔고를 보게 되어 유저가 해당 잔고로 잘못된 트랜잭션을 요청하게되는 오정산이 발생할 수 있습니다.

 

원장의 참조 대상

  order events를 참조하여 프로젝션 테이블을 리빌드한다고 해도, 해당 Trade에 대한 ID가 같을 것이라는 것을 보장할 수 없습니다. 이는 ID 생성 정책에 따라 다르겠지만 본 프로젝트에서는 TSID를 사용하고 있고, 이는 시간 기반 생성이기 때문에 PK인 ID가 바뀌게 됩니다. 이 거래 내역을 참조하고 있는 모든 테이블은 참조하고 있는 데이터가 사라지게 되어 정합성이 무너집니다.

 

법적 증빙 불가

  이벤트 테이블을 동적으로 조합해서 보여주는 뷰 테이블을 법적인 자료로 제시하면 규제 기관은 이를 신뢰할 수 있는 근거라고 판단하지 않습니다. 거래 발생 시점의 스냅샷이 디스크에 확정적으로 물리적인 형태로 저장되어 있어야 합니다.

2. Trade는 Order의 파생물이 아닙니다.

  매칭엔진은 두 개의 주문을 만나게 하는 하나의 사건에서 두 개의 독립적인 사실을 만들어냅니다.

OrderEvent: "주문 A의 상태가 변경되었다." (주문 트래킹 용도)
Trade: "유저 X와 유저 Y 간에 물건이 교환되었다." (자산 이동 및 정산 용도)

  어느 하나가 다른 하나에 종속된 관계가 아니므로, 진실의 원천의 개념이 훼손된 것이 아니라고 볼 수 있습니다.

  코드적으로는 이 두 개의 DB 인서트를 하나의 트랜잭션으로 묶어서 원자성을 보장하면 이중 진실 문제를 해결할 수 있습니다.

    @Transactional
    public void appendEvents(List<Trade> trades, List<OrderEvent> events) {
        if (!trades.isEmpty()) {
            jdbcTemplate.batchUpdate(
                    "INSERT INTO trades (id, market_id, maker_order_id, taker_order_id, buyer_id, seller_id, price, quantity, quote_quantity, maker_fee, taker_fee, sequence_no, traded_at) " +
                            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())",
                    new BatchPreparedStatementSetter() {
                        ...
                    });
        }

        if (!events.isEmpty()) {
            jdbcTemplate.batchUpdate(
                    "INSERT INTO order_events (id, order_id, trade_id, event_type, status_before, status_after, fill_qty, fill_price, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    new BatchPreparedStatementSetter() {
                        ...
                    });
        }
    }

 

  프로젝션 워커 스레드에서는 orders 테이블을 업데이트 하는 기능을 수행합니다.

    // ==========================================
    // Projection Worker
    // ==========================================
    private void startProjectionWorker() {
        new Thread(() -> {
            final int BATCH_SIZE = 1000;
            final List<MatchPayload> buffer = new ArrayList<>(BATCH_SIZE);
            final Map<Long, Order> orderMap = new HashMap<>(BATCH_SIZE);

            while (true) {
                try {
                    MatchPayload firstPayload = projectionQueue.take();

                    projectionQueueTimer.record(Duration.ofNanos(System.nanoTime() - firstPayload.projectionEnterTs()));

                    buffer.add(firstPayload);
                    projectionQueue.drainTo(buffer, BATCH_SIZE - 1);

                    orderMap.clear();

                    for (MatchPayload payload : buffer) {
                        if (payload.makerOrder() != null) orderMap.put(payload.makerOrder().getId(), payload.makerOrder());
                        if (payload.takerOrder() != null) orderMap.put(payload.takerOrder().getId(), payload.takerOrder());
                    }

                    List<Order> ordersToUpdate = new ArrayList<>(orderMap.values());

                    if (!ordersToUpdate.isEmpty()) {
                        projectionDbIoTimer.record(() -> eventExecutionRepository.updateReadModels(ordersToUpdate));
                    }

                    buffer.clear();

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); break;
                } catch (Exception e) {
                    log.error("Projection worker encountered an error", e);
                }
            }
        }, "Projection-Worker-Thread").start();
    }

 

기능 테스트: 1000 TPS

  이제 기능 테스트를 수행해보겠습니다. 테스트 상황은 이전과 동일합니다.

전후가 동일한 K6 입장에서의 매칭 응답 구조

  로직 앞단의 경우 그 구조가 같으므로 결과는 이전과 동일할 것으로 예상합니다.

단일 스레드 기반 이전 아키텍처의 결과
이벤트 소싱 기반 아키텍처의 결과

  p(90)값은 동일하고, p(95)값이 올라갔습니다. 전체적으로 값은 이전보다는 안정적으로 보입니다. 이제 1ms 부근에서 안정성을 보였고 아키텍처 적으로는 다른 문제를 확인할 수 없으니 TPS를 올려보도록 하겠습니다 !

기능 테스트: 10000 TPS

10000 TPS k6 시나리오

  p(95)가 357.92ms, max가 1.74s가 나왔습니다. 갑자기 팍 튈때는 1.74s이므로 문제가 있는 것 같습니다. 좀더 정밀하게 보기 위해서 다른 지표들도 관찰해보겠습니다.

CPU

  JVM은 기본적으로 로컬 환경에서 띄워지면 OS의 CPU를 그대로 인식합니다. 따라서 JVM에서 사용 가능한 코어가 PC의 코어 수와 동일하게 됩니다.

제가 사용하고 있는 데스크탑의 스팩

  저는 Intel(R) Core(TM) i5-8500 CPU @3.00GHz를 사용하고 있습니다. 이는 하이퍼 스레딩을 지원하지 않는 물리적 코어 6개를 가지고 있는 프로세서입니다. 따라서 JVM에서도 코어 6개를 사용합니다.

 

  한편 프로세스의 CPU가 최대 0.68까지 사용하는 것을 볼 수 있습니다. 이는 굉장히 많이 사용하는 편입니다. 매칭엔진이 돌아가면 다른 로직이 돌아가는 것에 충분히 문제가 생길 수 있습니다.

  매칭이 0.01ms에서 0.04ms의 시간이 소요되기 시작했습니다. 피크일때는 0.96ms일때도 존재합니다. Engine Queueing 지연시간도 피크일때는 40ms, 평균적으로는 22ms를 보입니다.

  Gateway duration은 피크일 때를 제외하고는 크게 변화는 없는 것 같습니다.

  히카리 커넥션 풀은 최대 2개를 사용합니다. 이는 저널 스레드와 프로젝션 스레드만 DB에 접근하므로 의도대로 반영된 결과입니다.

  마지막으로 GC입니다. minor GC만 일어났고, 지속적으로 일어남을 확인했습니다. 특히 할당된 이든 영역의 메모리가 많은 상태에서 minor GC가 발생하면 최대 25ms의 STW 지연 시간이 생깁니다. 재밌는 것은 지연 피크가 발생한 시간이 대부분 10:20:45 ~ 10:21:15 부근인데, 이는 GC의 STW duration이 발생한 시간대와 많이 겹친다는 것입니다.

 

  거래에서는 평균적인 지연시간도 반드시 한 자리수 대의 ms로 줄여야하기는 하지만, 지금 피크 타이밍일 때 그 편차가 너무 큰 것 같습니다. 따라서 평탄화 작업을 고려해야합니다. 위에서 GC가 좀 문제였던 것 같습니다. 응답시간 개선을 위해서 GC의 STW 시간을 최적화해보겠습니다.


ZGC

  ZGC는 CPU를 좀 더 사용해서 STW 시간을 1ms 이하로 최적화할 수 있는 Java에서 제공하는 GC의 종류 중 하나입니다.

https://soberyl.tistory.com/26#%EC%B0%B8%EA%B3%A0%20%EB%AC%B8%ED%97%8C-1-6

 

[Memory] 자바의 메모리 관리 전략

Why, Java? 백엔드 개발에 참여하게 되면서 최근에 자바에 대해서 공부해보려고 하고 있습니다. Java + Spring을 사용하는 백엔드 팀이기 때문입니다. 최근에 kotlin으로도 Spring을 돌린다고 하던데, 일

soberyl.tistory.com

  이렇게 해서 CPU를 좀 더 사용해서 응답 지연시간 평탄화를 진행해보겠습니다. 자바 버전에 따라 다르지만, Java 24 이후부터는 단일 세대의 ZGC가 사라지고 Generational ZGC만이 남게되었습니다. 따라서 아래의 설정을 VM 설정에 넣어주면 ZGC가 활성화됩니다.

-XX:+UseZGC -Xms4G -Xmx4G -XX:+AlwaysPreTouch -XX:+DisableExplicitGC -XX:MaxMetaspaceSize=256m

 

  여기서 -Xms4G는 JVM에 메모리를 원하는 만큼 할당하는 하드 코딩 옵션입니다. JVM은 따로 설정하지 않으면 OS자원의 1/4만큼을 JVM 메모리로 할당합니다. 하드 코딩으로 이를 명시하면 JVM 메모리를 재조정하는 오버헤드가 발생하지 않고 통째로 4GB를 받기 때문에 성능 지연 방지에 좋습니다.

  AlwaysPreTouch는 JVM을 시작할때 할당 받은 가상 메모리에 모두 0을 기록하여 Page Fault를 먼저 발생 시키는 웜업 옵션입니다. 이 옵션을 사용할 때는 동적으로 JVM 메모리를 할당하는 것이 아니라 하드 코딩한 최대값을 넣어주는게 좋습니다.

VM 옵션 추가

  인텔리제이에서는 Run -> Edit configuration -> Modify options -> Add VM options 를 클릭하고 옵션을 적용한 코드를 넣으면 됩니다. ZGC가 어떤 변화를 가져올지 기대가 됩니다.

기능 테스트: ZGC 적용 10000 TPS

  ZGC를 사용한 이후의 결과는 놀라웠습니다. p(95) 값이 357.92ms에서 253.51ms로 100ms 정도가 감소했습니다. max값은 1.74s에서 947ms로 800ms 정도 줄어든 모습을 보였습니다. 평균값은 135ms에서 102ms로 줄어들었습니다. GC 옵션만 바꿨는데도 이렇게 큰 성능 차이는 획기적인 것 같습니다.

G1 GC와 ZGC의 트레이드 오프

  이전 G1 GC를 사용할때는 CPU를 평균적으로 0.04를 유지하다가 매칭을 수행할 때는 0.688까지 튀어오르는 모습을 보였습니다. 아래는 ZGC를 사용하고 나서의 CPU 사용량에 대한 패널입니다.

CPU with ZGC

  평소에는 CPU를 0.01~0.02를 더 사용하고 피크에는 0.701까지 튀어오릅니다. 평소에 오른 값과 비교해도 비슷한 것을 보니, ZGC를 사용하면 CPU를 0.02 정도를 추가적으로 사용하는 것으로 보입니다. (너무 좋은데?)

Metric with ZGC

  이전에는 Engine Queue Latency와 Core Matching Latency가 중간에 튀어올라서 전자는 40ms, 후자는 0.96ms까지 올라갔으나 ZGC를 적용하고 나서는 안정적이면서도 평균적으로도 줄어든 모습을 보입니다.

ZGC Custom panel

  G1 GC에서는 Commited에 Used가 닿으면 Minor GC가 동작하는 방식이어서 그래프가 직관적이었습니다. 하지만 ZGC는 그런 것없이 Young이 내려가고, Old가 올라가는 것을 보여줍니다. (11:28) 이때 STW 없이 백그라운드에서 동작하기 때문에 STW가 없습니다. 올바르게 동작하고 있는 것을 확인할 수 있습니다.

 

  그래도 CPU를 너무 많이 사용하고 있는 것은 문제가 되는 것 같습니다. 이를 해결하기 위해서 코드를 다시 살펴보겠습니다.

public class Order {
    @Column(precision = 36, scale = 18, nullable = false) private BigDecimal price;

    @Enumerated(EnumType.STRING)
    @Column(name = "time_in_force", length = 20, nullable = false) private TimeInForce timeInForce;

    @Column(name = "orig_qty", precision = 36, scale = 18, nullable = false)
    private BigDecimal origQty;

    @Column(name = "executed_qty", precision = 36, scale = 18, nullable = false)
    private BigDecimal executedQty = BigDecimal.ZERO;

    @Column(name = "cumulative_quote_qty", precision = 36, scale = 18, nullable = false)
    private BigDecimal cumulativeQuoteQty = BigDecimal.ZERO;
    
    ...
}

  여기서 도메인의 자료 형태가 전부 BigDecimal로 되어있습니다. 지금은 매칭 엔진에서 루프를 돌릴 때 BigDecimal 자료구조에서 제공하는 add, subtract 등을 사용해서 비교 및 계산을 수행하고 있습니다.


BigDecimal to Long

BigDecimal

  일반적인 Long은 부동소수점 연산으로 정확도 면에서 오차가 발생해서 한계가 있습니다. BigDecimal을 만든 COBOL는 "컴퓨터가 비즈니스에서 쓰이려면 인간이 쓰는 십진수를 정확히 다뤄야 한다"는 철학을 가지고 있었습니다. BigDecimal은 메모리가 허용하는 한 표현 가능한 수는 무한하면서도 10진수를 그대로 표현합니다.

public class BigDecimal {
    private final BigInteger intVal;    // 정수 부분 (힙 객체)
    private final int scale;            // 소수점 위치
    private final int precision;        // 전체 자릿수
    private volatile long intCompact;   // 최적화용 캐시
}

  이런 금융을 위해서 만들어낸 클래스임에도 제가 개발하는 도메인인 초고빈도 매매에서는 한계가 드러납니다.

BigDecimal.compareTo() 연산
1. scale 확인 (메모리 읽기)
2. scale이 다르면 정규화 (추가 연산)
3. intCompact 확인
4. 부호 확인
5. 자릿수 비교
6. 실제 값 비교
→ CPU 명령어 수십~수백 개
→ 힙 메모리 접근 (캐시 미스 가능)

  여기서 동작하는 방식이 결국 하나의 객체를 생성하고 안에 있는 필드들을 참조해가면서 비교하는 방식인데, 하나의 BigDecimal당 필드는 4개입니다. 따라서 비교 연산자를 사용하면 내부적으로는 많은 CPU 명령어가 사용됩니다.

BigDecimal 객체
헤더: 16 bytes
intVal: 8 bytes (참조, 힙 다른 위치의 BigInteger)
scale: 4 bytes
precision: 4 bytes
intCompact: 8 bytes
총: 40 bytes + BigInteger 별도 할당

  메모리는 객체가 생성되면 헤더를 보함해서 40Bytes + a를 차지합니다. 최종적으로 사용되는 Order 객체의 메모리는 아래와 같습니다.

Order {
    price: 참조(8B) → 힙의 BigDecimal(40B) → 힙의 BigInteger
    origQty: 참조(8B) → 힙의 BigDecimal(40B)
    executedQty: 참조(8B) → 힙의 BigDecimal(40B)
    remainingQty: 참조(8B) → 힙의 BigDecimal(40B)
}

Long (ticks / satoshi)

  Long을 사용하게 되면 생성과 연산 간 사용되는 CPU 사이클을 획기적으로 줄일 수 있습니다.

long 비교 (CMP 명령어)
1. 두 레지스터 비교
→ CPU 명령어 1개
→ 레지스터 연산 (캐시 무관)

  사용되는 메모리는 아래와 같이 40bytes + a에서 8bytes로 1/5로 줄일 수 있습니다.

long
8 bytes (스택 또는 객체 필드 인라인)
→ Order 객체 안에 직접 포함, 추가 힙 할당 없음

  최종적으로 사용되는 Order 객체의 메모리는 아래와 같습니다.

Order {
    price: long(8B) → 인라인
    origQty: long(8B) → 인라인
    executedQty: long(8B) → 인라인
    remainingQty: long(8B) → 인라인
}

 

  그러면 기존에 BigDecimal에서 보장했던 정합성에 대해서 Long으로 자료를 치환해도 여전히 보장이 되어야 전환이 가능할 것입니다.

Long (ticks / satoshi) 방식

  BigDecimal의 객체이기 때문에 정확성을 가지는 것이 아니라 십진수를 이진수로 변환하지 않음에 있습니다. 이를 응용해서 소수점까지 통째로 Long에 저장합니다. 즉, 실수를 있는 그대로 저장하는 것이 아니라,  "최소 단위의 몇 배인가"를 정수로 저장하는 방식으로 가격 정보를 저장합니다. 이러한 방식으로 CPU 사이클을 효과적으로 줄일 수 있을 것입니다.

BigDecimal("50000.50") :
  intCompact = 5000050
  scale = 2

Long(ticks/satoshi) :
  priceInTicks = 5000050L
  (scale은 시스템 전체가 알고 있으므로 변수로 저장 불필요)

  scale 값은 마켓이 존재하는 한 절대로 바뀌지 않습니다. 따라서 이 값은 항상 들고다닐 필요없이 전역에서 참조합니다.

Long * Long

  +, -과 같은 연산에서는 오버플로우가 발생하지 않습니다. 다만 * 연산에서는 발생하게 됩니다. 고정소수점 체계에서는 두 숫자를 곱하면, 각 숫자의 배수(Multiplier)가 서로 곱해지면서 최종 결과의 자릿수가 매우 커지기 때문입니다.

가격 : 소수점 2자리 (* 10^2) -> 예: 50,000.00 USDT -> 5,000,000L
수량 : 소수점 8자리 (* 10^8) -> 예: 2,000.00000000 BTC -> 200,000,000,000L
5,000,000 * 200,000,000,000 = 1,000,000,000,000,000,000,000 (10^21)

 

  자바의 Long.MAX_VALUE는 약 9.22 * 10^18 (922경)입니다. 따라서 계산 반환 시 오버플로우가 발생합니다. 따라서 곱셈의 경우에 한해서는 안전하게 계산할 수 있는 BigDecimal을 이용해서 계산합니다. 또한 매칭 엔진에서는 곱셈 연산을 배제하고, 정산 레이어에서 이를 계산해서 반영할 수 있도록 해서 매칭과 곱셈 연산을 고립시킵니다.

코드 수정

  BigDecimal로 선언된 코드를 long으로 전부 치환하고, 각각 market에서 정의한 가격과 수량의 고정 소수점 위치를 사용합니다.

기능 테스트: BigDecimal -> long 적용 10000 TPS

  내용이 깊어지므로, 정확한 가설을 세우고 테스트를 들어가겠습니다.

long으로 변환 시 개선 결과에 대한 가설

1. 참조 -> 레지스터에 올리고 -> 계산하고... 이런 매칭에서 필요한 CPU 연산 과정이 Compare와 Add와 같은 기본적인 ALU 연산으로 바뀌어서 계산 속도가 빨라지면서 매칭 코어 엔진의 매칭시간이 단축될 것이다.

2. 거래마다 할당해야하는 객체가 힙에 올려져야해서 할당 및 참조, 해제 시간이 소요되어야하는 부분이 지역변수로 바뀌면서 non-heap으로 바뀌면서 cpu에 올리는 시간이 압도적으로 줄기 때문에 오더 큐에 인큐하는 시간이 매우 빨라질 것이다.

3. 객체 당 사이즈가 매우 줄어들어서 사용하는 메모리가 많이 줄어들 것이고, 이에 따라서 GC의 걱정이 거의 없어질 것이다.

테스트 결과

  p(95)가 211.74ms로 줄었습니다! 이전 253.51ms보다 40ms 가량 줄어들었습니다. max값도 300ms 정도 줄어들어서 성능 개선이 많이 된 것 같습니다. 각각의 그라파나 패널들도 확인해보겠습니다.

  Old memory가 412MB에서 272MB로 줄어들었습니다. 기존의 66%로 메모리 측면에서도 많이 개선되었습니다.

  무엇보다 BigDecimal 객체를 새로 할당하고, 이를 통해서 계산을 수행하던 코어 매칭 엔진의 지연 시간이 1초에서 점차 줄어든 것에 비해서 처음부터 0.0088보다 작은 수치로 안정적으로 유지되고 있습니다. 이는 기존의 로직이 최적화가 매우 잘되었다는 신호인 것 같습니다.

 

  다만 CPU 사용량은 0.7에서 0.669로 0.03정도의 저하 밖에 발생하지 않았습니다. 또한 엔진 큐잉 딜레이는 기존과 거의 비슷합니다. 

    public void placeOrder(OrderTrace trace) {
        trace.markEngineEnter();
        gatewayTimer.record(Duration.ofNanos(trace.getEngineEnterTs() - trace.getIngressTs()));
        OrderRequestDto request = trace.getRequestDto();
        Long marketId = request.getMarketId();

        Order newOrder = Order.builder()
                .id(TsidCreator.getTsid().toLong())
                .userId(request.getUserId())
                .marketId(marketId)
                .side(request.getSide())
                .orderType(request.getOrderType())
                .timeInForce(request.getTimeInForce())
                .price(request.getPrice())
                .origQty(request.getQuantity())
                .build();

        AtomicLong seqGen = sequenceGenerators.get(marketId);
        if (seqGen == null) throw new IllegalStateException("Market ID " + marketId + " not pre-warmed.");
        newOrder.assignSequenceNo(seqGen.incrementAndGet());

        trace.setOrder(newOrder);
        orderQueue.offer(trace);
    }

  가설 중에 2의 내용은 결과와 다른 듯 보입니다. 애플리케이션 레벨의 로직이 가벼워졌음에도 CPU 점유율의 하락폭은 기대치만큼 크지 않았고, 또한 매칭 대기 큐에서 처리까지 걸린 평균 지연 시간도 감소하지 않았습니다. 분명 enqueue를 하는 시간도, dequeue를 하는 시간도 이전보다 더 빨라져야 했을텐데 말이죠.

매칭 대기 큐에서 단일 주문이 대기한 평균 지연 시간


BlockingQueue의 ReentrantLock

  자료구조 하나에 다량의 요청이 enqueue를 하게되고 이를 dequeue를 할 때 내부적으로 어떤 일이 일어나고 있는지 확인할 필요가 있습니다. Java의 BlockingQueue는 내부적으로 Lock을 이용한 동시성 제어를 수행합니다.

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    ...

    /** Lock held by take, poll, etc */
    private final ReentrantLock takeLock = new ReentrantLock();

    /** Wait queue for waiting takes */
    private final Condition notEmpty = takeLock.newCondition();

    /** Lock held by put, offer, etc */
    private final ReentrantLock putLock = new ReentrantLock();

    /** Wait queue for waiting puts */
    private final Condition notFull = putLock.newCondition();
}

  java.util.concurrent의 LinkedBlockingQueue의 소스코드를 확인해보면 동시성 관리를 위해 위와 같이 ReentrantLock을 인스턴스화 하여 사용하고 있는 것을 확인할 수 있습니다. 여러 개의 톰캣 스레드가 주문 요청을 전달하기 위해 해당 자료 구조에 빠르게 Enqueu를 진행하거나, 이 큐에서 자료를 빼내오는 Dequeue를 진행할 때 락을 요청합니다. 이때 락을 가지고 있지 않은 스레드들은 전부 Waiting 상태로 들어가고, 락을 획득하고 처리를 진행하게되는 스레드의 작업이 끝나면 이어서 자료구조를 사용하게 됩니다.

 

  여기서 매우 빠르게 주문 요청을 처리해야하는 도메인 시스템의 관점에서 이는 아래와 같은 병목을 유발합니다.

  1. 톰캣 스레드가 큐에 주문을 밀어 넣으러 오는 진입 빈도(Arrival Rate)가 큐가 락을 해제하고 다음 스레드를 처리하는 속도(Service Rate)를 넘어가게 되었습니다. 락을 잡고 있는 시간은 짧지만 대기하는 스레드가 늘어나면서 임계 구역의 크기와 상관없이 대기열 자체가 계속해서 늘어나서 지연 시간이 증가하게 됩니다.
  2. 락을 획득하지 못한 스레드가 단순히 CPU 코어에서 잠깐 대기(Stall/Spin)하고 끝난다면 국소적인 지연으로 끝나지만 ReentrantLock은 커널 레벨 락이므로, 실패한 스레드들을 즉시 park() 시켜 스케줄러 대기열로 보냅니다. CPU 코어는 이 스레드를 밀어내고 다른 톰캣 스레드를 올리는 컨텍스트 스위칭을 수행하게 됩니다. 새로 올라온 스레드도 똑같이 인큐 입구에서 락을 얻지 못해 다시 park() 됩니다. 이 현상이 초당 수십만 번 반복되면서 CPU의 모든 사이클이 컨텍스트 스위칭에 낭비되게 됩니다.

  아래는 Window의 perfmon를 이용해서 시스템의 컨텍스트 스위칭 성능 모니터링을 측정한 결과입니다.

윈도우 내장 도구 perfmon

  CPU가 폭발적으로 사용되기 시작하는 시간을 기점으로 JVM의 컨텍스트 스위칭이 가파르게 튀어오르는 것을 확인할 수 있습니다. 또한 컨텍스트 스위칭의 배율이 0.01이므로 모니터의 천장에 머물러 있다는 것을 봤을 때, 초당 최소 수만 번 이상의 컨텍스트 스위칭이 발생하고 있다는 뜻으로 해석할 수 있습니다. CPU가 순수하게 계산만 진행했다면 이는 비교적 평탄하게 하단부를 유지했을 것입니다. 이런 의도치 않은 컨텍스트 스위칭에서 발생하는 CPU 사용 낭비를 개선하고, 락경합에 의한 지연 시간을 최소화해야 합니다.

LMAX Disruptor Ring Buffer

URL

Disruptor와 LinkedBlockingQueue의 비교

  LMAX Disruptor를 이용해서 컨텍스트 스위칭 횟수를 개선하고 Lock-Free한 자료구조를 통해 락 경합 시간을 제거해보겠습니다.

코드 개선

    public static class EngineEvent {
        public OrderTrace trace;
        public final List<Trade> trades = new ArrayList<>(16);
        public final List<OrderEvent> orderEvents = new ArrayList<>(32);
        public final List<Order> modifiedOrders = new ArrayList<>(16);
        public long matchEndTs;
        public long journalEndTs;

        public void clear() {
            trace = null;
            trades.clear();
            orderEvents.clear();
            modifiedOrders.clear();
            matchEndTs = 0L;
            journalEndTs = 0L;
        }
    }
    
    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady() {
        preWarmSequences();
        recoverOrderBookState();

        // 기존 타이머 ...
        
        initDisruptor();
        Gauge.builder("engine.ringbuffer.remaining", ringBuffer, RingBuffer::remainingCapacity)
                .description("Remaining capacity of LMAX Disruptor RingBuffer")
                .tag("application", "snaptrade-engine")
                .register(meterRegistry);
    }
    
    private void initDisruptor() {
        int bufferSize = 65536;

        disruptor = new Disruptor<>(
                EngineEvent::new,
                bufferSize,
                DaemonThreadFactory.INSTANCE,
                ProducerType.MULTI,
                new BusySpinWaitStrategy()
        );

        disruptor.handleEventsWith(new MatchingEventHandler())
                .then(new JournalEventHandler())
                .then(new ProjectionEventHandler());

        ringBuffer = disruptor.start();
        log.info("LMAX Disruptor Pipeline started successfully.");
    }

 

  외부 의존성에 Disruptor을 추가하고 초기화에 대한 정의를 합니다. ProducerType을 Multi로 지정해서 여러 개의 스레드가 접근할 수 있도록 하고, BusySpinWaitiStrategy()로 지정해서 CPU 코어 하나를 완전히 점유해서 파이프라인을 수행하도록 합니다. 이 설정에 따라서 컨텍스트 스위칭이 매우 개선될 것입니다.

    private class MatchingEventHandler implements EventHandler<EngineEvent> {
        @Override
        public void onEvent(EngineEvent event, long sequence, boolean endOfBatch) {
            OrderTrace trace = event.trace;
            engineQueueTimer.record(Duration.ofNanos(System.nanoTime() - trace.getEngineEnterTs()));

            long matchStart = System.nanoTime();
            processMatch(event);
            coreMatchingTimer.record(Duration.ofNanos(System.nanoTime() - matchStart));

            event.matchEndTs = System.nanoTime();
        }
    }
    
    private class JournalEventHandler implements EventHandler<EngineEvent> {
        private final List<Trade> batchTrades = new ArrayList<>(1000);
        private final List<OrderEvent> batchEvents = new ArrayList<>(2000);

        @Override
        public void onEvent(EngineEvent event, long sequence, boolean endOfBatch) {
            batchTrades.addAll(event.trades);
            batchEvents.addAll(event.orderEvents);
            
            if (endOfBatch && (!batchTrades.isEmpty() || !batchEvents.isEmpty())) {
                journalDbIoTimer.record(() -> eventExecutionRepository.appendEvents(batchTrades, batchEvents));
                batchTrades.clear();
                batchEvents.clear();
            }
            event.journalEndTs = System.nanoTime();
        }
    }
    
    private class ProjectionEventHandler implements EventHandler<EngineEvent> {
        private final Map<Long, Order> batchOrderMap = new HashMap<>(1000);

        @Override
        public void onEvent(EngineEvent event, long sequence, boolean endOfBatch) {
            for (Order order : event.modifiedOrders) {
                batchOrderMap.put(order.getId(), order);
            }

            if (endOfBatch && !batchOrderMap.isEmpty()) {
                List<Order> ordersToUpdate = new ArrayList<>(batchOrderMap.values());
                projectionDbIoTimer.record(() -> eventExecutionRepository.updateReadModels(ordersToUpdate));
                batchOrderMap.clear();
            }
        }
    }

  파이프라인 내에서 처리할 태스크에 대해서 정의합니다. 저는 총 3개(매칭, 저널, 프로젝션)가 있으므로 3개의 핸들러를 선언합니다.

기능 테스트: LMAX Disruptor 적용 10000 TPS

  이 역시 가설을 세우고 테스트로 증명해보겠습니다.

가설

LMAX Disruptor을 이용하면

1. CPU 하나를 점유해서 파이프라인을 돌리므로 컨텍스트 스위칭이 매우 줄어들 것이고

2. 락 경합에 대한 대기 시간이 사라져서 좀더 개선된 K6 response latency 값을 얻을 수 있을 것입니다.

테스트 결과

Idle 상태에서도 공회전하는 Disruptor

  Disruptor를 사용하니 CPU 사용률이 0.49를 유지하고 있습니다. 기존의 평상 시에는 거의 0에 근접했는데 말이죠. 이는 파이프라인 내에서 3개의 핸들러를 적용했고, 정책이 BusySpinWaitiStrategy()여서 발생한 일입니다. 다른 옵션으로는

  • YieldingWaitStrategy(): 링버퍼에 데이터가 있는지 지정된 횟수만큼 확인하고, 그래도 없으면 yield()를 호출해서 CPU를 절약합니다. 유휴 상황에는 기존 대비 절반정도 줄어들게 됩니다.
  • SleepingWaitStrategy(): 데이터가 들어왔을 때 스레드를 깨우는 전략으로 추가적인 지연이 발생하지만, 유휴 상황에 CPU 사용률이 거의 0에 근접하게 됩니다.

  와 같은 것들이 존재합니다. 모놀리스식이므로 CPU를 절반이나 사용하나 싶지만, 다른 기능들은 CPU를 크게 사용하지 않고 초저지연 매매를 목표로하고 있는 점이 프로젝트의 특징입니다. 따라서 현재 도메인에서는 이를 유지하는 것이 옳은 것 같습니다. 

도리어 늘어버린 응답 결과

    p(95)값이 대폭 늘었습니다만, 더 중요한 것은 max값이 26.14s라는 점입니다. 심각하게 늘었습니다.

  중간에 연산이 과해져서 프로메테우스의 측정이 불가했습니다.

  매칭 자체의 속도는 매우 준수합니다.

 

지금의 상황을 다이어그램으로 표현하면 아래와 같습니다.

  톰캣 스레드가 접근을 하고 링 버퍼 슬롯에 데이터를 안착시키면, 마지막 태스크가 끝날 때까지 해당 슬롯은 시퀸스 베리어로 삭제가 불가능해집니다. 지금의 테스트 시나리오와 같이 초고속으로 트랜잭션을 부어버리면 링 버퍼가 금방 차버리고, 꽉 차버리면 결국 다른 톰캣 스레드의 요청은 대기 상태로 계속 대기하게 됩니다. 이전에는 LinkedBlockingQueue로 접근을 허용하고 사용자에게 금방 반환을 해서 k6 반환값이 양호했던 것입니다.

  이전과 같이 저널과 프로젝션에 대한 태스크를 스레드를 선언해서 돌리면 어떻게 될까요? k6 응답은 개선할 수 있을 것입니다. 다만 결국 큐 사이즈는 제한을 두어야합니다. 제한을 두지 않으면 객체가 계속해서 쌓여서 OOM이 발생할 수도 있기 때문입니다. 이러나 저러나 결국 문제가 되는 것 같습니다.

 

  발생한 문제의 핵심은 느린 RDB I/O입니다. DB가 너무 느리기 때문에 큐를 사용하든, 디스럽터를 사용하든 역압으로 사용자의 주문 매칭에 대한 응답이 떨어질 수 밖에 없는 것입니다. RDB는 느리고, 이를 해결하기 위해서 벌크 인서트를 도입했지만 역시 지금의 부하 상황에서는 DB가 받아주지를 못합니다.

 

  사실 테스트 환경도 점차 문제가 되는 것 같습니다. 로컬 호스트 테스트이기 때문에 K6와 JVM을 사용할 때 프로메테우스 정도의 CPU를 할당받지 못하는 것으로 보이기 때문입니다. 요청 컴퓨터와 부하 컴퓨터를 따로 두어서 테스트 환경의 개선도 필요한 듯 보입니다.

기능 고립의 필요성

  최초 목표 관점에서 살펴보면 K6 응답이 실패권에 있습니다. 다만 이전 아키텍처로 돌아가서 비동기 스레드를 두어도 한계점을 알아버렸기 때문에, 다른 해결방법이 필요한 것 같습니다.

 

  인프라 관점에서 살펴보겠습니다. RDB에 데드락이 걸리거나 다른 이유로 일시적으로 다운되더라도, 매칭 엔진은 멈추면 안 됩니다. 외부 인프라가 죽더라도 코어 로직은 메모리 상에서 계속 체결을 진행하고 그 결과를 링 버퍼에 담아두어야만 시스템 전체의 대형 장애를 막을 수 있습니다. 매칭 엔진이 죽더어서 메모리 내용이 완전히 날라가더라도 이벤트 소싱 기반이기 때문에 스냅샷을 통해 메모리를 복구할 수 있습니다.

 

  또한 많은 CPU를 할당받아서 매칭 엔진은 돌아가야합니다. 저널과 프로젝션은 CPU보다 Disk I/O가 주된 태스크입니다. 즉, CPU-bound 태스크와 I/O-bound 태스크의 두 기능이 묶여 있다면 서버를 증설할 때 CPU와 디스크를 무조건 세트로 늘려야 하는 큰 비용 낭비가 발생합니다. 매칭 엔진을 고립시키면, 연산 서버(CPU 고스펙)와 데이터베이스 기록 서버(디스크 고스펙)를 각각의 병목에 맞게 독립적으로 스케일링할 수 있고, 이는 비용면이나 난이도면이나 더 나은 상황이 연출됩니다.

 

  백엔드 시스템 관점에서 살펴보면 디스크 I/O와 네트워크 I/O는 본질적으로 통제 불가능한 비결정적 작업입니다. 디스크 섹터에 배드 블록이 생기거나, 네트워크 패킷이 유실되어 지연되는 시간을 CPU는 예측할 수 없기 때문에 매칭 로직 중간에 이런 비결정적 코드가 섞이면 엔진 전체의 처리 시간이 널뛰면서(Jitter) 불안정한 상태에 놓입니다. 매칭 엔진은 다른 요인이 아니라 주문에 의해서만 상태가 변하는 순수 함수처럼 작동해야 합니다. 그래야 시스템이 전반적으로 안정적이고, 문제가 발생했을때 요인이 명확해집니다.

메시지 브로커를 사용한 이벤트 드리븐 방식과 직접 통신의 차이

 

  따라서 매칭 엔진을 DB I/O와 고립시키고, 나아가 이벤트 드리븐 방식으로 개선해야 찾아낸 문제점들을 아우르는 해결을 할 수 있을 것 같습니다.


결론

  이벤트 소싱 아키텍처로 전환하고, 응답 지연시간을 개선했습니다. 메모리 사용량과 CPU 사용률도 충분히 개선하고, 내부적인 자료 구조의 락 경합 시간에 대한 최적화도 진행해서 초고빈도 매매에 대응하기 위해서 최소로 만들어낸 것 같습니다. 컨텍스트 스위칭에 대해서 고려를 하고, 이를 개선해보기도 했습니다. 동시에 큰 문제점도 발견했습니다. 역압에 의한 문제와 메인 로직과 DB의 직접 통신의 한계점, 장애가 발생했을 때 복구 전략, 전체적인 일관성 유지 등... 해결할 수록 고민이 더 많이 생기는 것 같습니다. 하하하하...

 

  다음 포스트에서는 이런 부분들을 개선하는 포스팅을 진행해보겠습니다 !


TMI

아키텍처 전환 이후 발생한 일 (삽질..)

단일 지정가 매도벽에 대한 부하 테스트

  단순 단일 스레드 비동기 아키텍처에서 이벤트 소싱 아키텍처로 변환했습니다. 흥미로운 것은 이전보다 p(95) 값이 1ms 올라갔다는 것입니다. k6가 응답을 받을 때까지의 로직은 아래와 같이 이전과 동일한 구조입니다. 왜 같거나 작지 않고 오히려 2배 늘었을까요?

Backpressure

  기존에는 orderQueue에서 Engine-Core-Thread에서 매칭을 하고, 단일 워커가 Trade, Order, Event를 한 번의 배치로 처리했습니다.

  이벤트 소싱 구조에서는 Engine-Core-Thread에서 매칭을 하면 Journal Worker가 큐로 페이로드를 받아 OrderEvent와 Trade를 저장하고, Projection Worker가 이어받아서 Order 테이블을 업데이트 합니다. 파이프라인이 2단계로 늘면서 추가로 생긴 오버헤드가 존재합니다.

새로운 오버헤드의 전파

  기존에는 페이로드를 한번만 만들면됐으나 지금은 한번 더 만드는 오버헤드가 추가됐습니다.

projectionQueue.offer(new MatchPayload(
    payload.trade(),
    ...,
    System.nanoTime() // 두번 째 MatchPayload 객체 생성
));

  Jonrnal에서 새 MatchPayload 객체를 할당하고 LinkedBlockingQueue.offer()를 호출합니다. 내부에서 ReentrantLock 획득하면 Projection Worker의 take()가 동작합니다. (LockSupport.unpark) 이때 컨텍스트 스위치 가능성이 생깁니다. LinkedBlockingQueue에서는 내부적으로 ReentrantLock을 사용하는데 1000 TPS이면 초당 1000번의 lock 획득/해제가 발생하고
 큐가 2개면 초당 2000번의 lock 연산이 생깁니다. 즉, 큐 자료구조가 하나 더 생겼으므로 Lock 획득 및 해제의 오버헤드도 추가로 생겨났습니다.

 

  DB I/O도 한번의 처리에서 2번의 처리로 바뀌었으므로 RTT 오버헤드가 추가됐습니다. 이때 Journal Queue가 천천히 쌓이게 되면 Engine-Core-Thread가 journalQueue.offer()할 때 큐 풀 상황이 발생합니다. 이것이 Order Queue까지 전파되고, Order Queue의 대기시간이 늘어났으므로 k6의 응답이 좀 더 지연됩니다.

 

  정리하면 하위 파이프라인(Journal→Projection→DB)이 느려졌고, 이로 인해서 상위 큐(orderQueue)가 쌓이면서 HTTP 스레드가 더 오래 대기하게 만들어서 p(95) 응답 지연 값이 더 커졌다고 생각할 수 있습니다. 하지만 저는 큐 사이즈를 명시적으로 설정하지 않아서 21억정도 되는 크기의 큐입니다...

 

  의심이 돼서 다시 이전 로직으로 테스트를 돌려보니 이전에 측정한 값도 높게 나와서 도대체 뭐지 싶었습니다. 그래서 설정했던 ZGC를 다시 G1으로 돌려버리니 놀랍게도 측정값이 돌아왔습니다. ZGC가 CPU를 쓰기 때문에 평균적인 응답 지연은 조금 올라갈 수 있기 때문에 발생한 문제였습니다. 하... 그래도 역압에 대해서 공부했으니 다행이라고 생각합니다...

가장 최신의 Matching Core Engine Flowchart

Core Matching Engine

❓ReentrantLock

더보기

Java SE 공식 API 문서(Oracle)는 ReentrantLock을 다음과 같이 정의합니다.

"암묵적인 모니터 락(synchronized 메서드 및 블록)과 동일한 기본 동작 및 의미론을 가지면서, 확장된 기능(Extended capabilities)을 제공하는 재진입 가능한 상호 배제 락이다."

 

이 락은 JVM 내부의 AQS(AbstractQueuedSynchronizer)라는 프레임워크를 기반으로 동작합니다. 이는 스레드가 한 번 락을 획득하면, 그 스레드는 해당 락이 걸려 있는 내부의 다른 메서드나 블록도 들어갈 수 있습니다. 들어갈 때마다 내부 카운터(Hold Count)가 +1 증가하고, 방문을 나설 때(unlock) -1 감소합니다. 카운터가 0이 되어야만 다른 스레드에게 락이 양보되는 방식으로 락이 관리됩니다.

 

즉 Mutex의 동작과 같다고 볼 수 있는, 스레드 하나만 진입할 수 있는 상황을 강제하는 락입니다.

반응형

'프로젝트 > Snap Trade' 카테고리의 다른 글

[Snap Trade] 마켓 데이터 스트리밍 아키텍처 설계 - 1  (2) 2026.06.26
[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 2  (0) 2026.06.07
[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 1  (0) 2026.06.02
[Snap Trade] 프로젝트 계획서  (0) 2026.04.30
'프로젝트/Snap Trade' 카테고리의 다른 글
  • [Snap Trade] 마켓 데이터 스트리밍 아키텍처 설계 - 1
  • [Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 2
  • [Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 1
  • [Snap Trade] 프로젝트 계획서
soberyl
soberyl
개발과 청춘 일지
    반응형
  • soberyl
    soberyl 님의 블로그
    soberyl
  • 전체
    오늘
    어제
    • 분류 전체보기
      • 프로젝트
        • Snap Trade
        • 팀 프로젝트
      • 개발일지
        • 코딩 테스트
        • 알고리즘
        • CS
      • Full stack
      • Frontend
        • UXUI
        • React
        • Next.js
        • React Native
        • Monitor
      • Backend
        • NestJS
        • Spring
        • Monitor
        • DB
      • DevOps
        • CICD
      • Test
      • AI
      • Infra
        • AWS
        • On-premise
      • 청춘
        • 희곡
        • 연극
      • 잡설
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    거래소
    희곡
    코테
    io
    thread
    MySQL
    OS
    알고리즘
    연극
    코딩테스트
    프론트엔드
    DB
    EC2
    최적화
    CPU
    java
    백엔드
    memory
    설계
    테스트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
soberyl
[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 3
상단으로

티스토리툴바