[Snap Trade] 마켓 데이터 스트리밍 아키텍처 설계 - 1

2026. 6. 26. 17:29·프로젝트/Snap Trade

Snap Trade

핵심 개념

글로벌 시간 표준과 서버 타임스탬프 원칙 (Universal UTC & Server Timestamp Priority)

  거래소의 모든 데이터(체결 시각, 캔들 롤오버 기준점)의 절대적인 기준은 클라이언트의 단말기 시간이 아니라 코어 매칭 엔진 서버의 타임스탬프입니다. 바이낸스와 업비트 등은 전 세계 사용자의 로컬 시간대가 다르더라도, 일봉(1D) 캔들의 생성과 마감을 절대적인 UTC 00:00:00 (한국 시간 오전 9시)을 기준으로 통일하여 데이터의 글로벌 정합성을 보장하고 있습니다.

시계열 데이터와 스냅샷 데이터 (Time-Series and Snapshot)

  시계열 데이터는 시간이 지남에 따라 발생한 체결 내역이나 1분봉처럼 이전 데이터를 영구적으로 보존하며 계속 아래로 쌓아 나가는(Append-Only) 로그성 데이터입니다. 반면 스냅샷 데이터는 현재 시점의 최고가, 최저가, 현재가처럼 과거 데이터는 지워버리고 가장 최신 상태만을 끊임없이 덮어쓰기(Overwrite) 하는 현재 상태값입니다.

24시간 롤링 윈도우 (24-Hour Rolling Window)

  전통 주식 시장은 정해진 시간에 장이 마감되어 명확한 '전일 종가'가 존재하지만 쉬지 않고 돌아가는 암호화폐 거래소에는 장 마감이라는 개념이 없습니다. 따라서 현재 시간으로부터 정확히 24시간 전의 가격을 기준 시가(Open)로 간주하고 쉴 새 없이 기준점을 이동시키는 롤링 윈도우 방식을 사용하여 전일 대비 등락폭과 등락율을 계산합니다.

다중 시간대 분석 (Multi-Timeframe Analysis)

  동일한 자산의 가격 흐름이더라도 시간의 해상도(Resolution)를 어떻게 압축하느냐에 따라 전혀 다른 정보를 제공하게 됩니다. 시장 참여자들이 각자의 매매 전략(스캘핑, 스윙, 장기 투자)에 맞는 지표를 활용할 수 있도록 다양한 기준의 캔들을 동시에 제공해야 합니다.

가격 발견

  매칭 엔진에서 매도자와 매수자의 주문이 만나 체결이 성사되는 순간, 그 체결 가격이 곧 시장 참여자들이 합의한 해당 자산의 현재 가치가 됩니다. 이를 가격 발견이라고 합니다. 또한 마켓 데이터 시스템에서는 정보 전달 속도의 격차로 인한 불공정한 정보 비대칭성을 제거해야 합니다.

호가 심도와 (Market Depth)

  주문이 쌓여있는 대기열인 호가창(Orderbook)은 현재 시장에 특정 자산을 흡수할 수 있는 유동성(Liquidity)이 얼마나 풍부한지를 나타내는 지표가 됩니다. 트레이더는 각 가격대에 쌓인 물량을 분석해 다양한 트레이딩 방식을 운영하게 됩니다.

슬리피지(Slippage)

  내가 주문을 낸 시점의 기대 가격과, 실제 체결된 평균 가격 사이의 오차를 의미합니다. 시장가 매매에서만 발생하는 개념입니다.
  가령 시장가로 사과 10개를 시장가로 주문합니다. 호가창 맨 앞에 1,000원짜리 사과가 10개 있는 것을 보고 주문을 눌렀으나, 네트워크 지연 찰나의 순간에 누군가 5개를 먼저 사버렸습니다. 결국 1,000원에 5개, 그다음 비싼 호가인 1,200원에 5개를 체결받아 평균 매수 단가가 1,100원이 되었습니다. 이때 발생한 100원의 손실 갭을 슬리피지라고 합니다.

핵심 도메인

티커 (Ticker)

  특정 마켓의 현재 상태를 한눈에 보여주는 전광판(Snapshot) 데이터입니다.


  가장 최근에 체결된 현재가(Last Price)를 비롯하여, 최근 24시간 동안의 최고가, 최저가, 누적 거래량, 전일 대비 등락률 정보를 담고 있습니다. 거래소 메인 화면의 코인 목록이나 거래 화면 상단의 시세 요약 패널을 그리는 데 사용됩니다.

캔들스틱 / 봉 (Kline)

  1분, 1시간, 1일 등의 일정 시간 동안의 가격 변동을 하나의 봉(Candle) 형태로 요약한 데이터입니다.


  트레이더들이 차트의 추세를 분석할 때 사용하는 핵심 데이터로, 해당 시간대의 첫 체결가(Open), 최고가(High), 최저가(Low), 마지막 체결가(Close)와 총 거래량(Volume)을 묶어 OHLCV 형태로 관리됩니다.

호가창 (Orderbook / Depth)

  현재 시장에 대기 중인 매수 및 매도 주문들의 물량(Depth)을 가격대별로 모아서 층층이 보여주는 대기열 데이터입니다. 거래소 마켓의 매물대 표이며, 지정가 주문들이 호가창에 쌓여있는 유동성의 깊이를 나타냅니다.

실시간 체결 내역 (Recent Trades)

  매칭 엔진에서 방금 막 성사된 개별 거래 내역들의 흐름입니다.


  가격, 수량, 체결 시간, 그리고 해당 체결을 유발한 테이커(Taker)가 매수자인지 매도자인지를 나타내는 방향성(Side) 정보를 포함하여, 시장의 단기적인 매수/매도 압력을 나타냅니다.


핵심 로직

데이터 스트리밍 프로토콜

비교 항목 Polling / Long Polling SSE (Server-Sent Events) WebSocket (채택)
통신 방향 단방향 (Client to Server) 단방향 (Server to Client) 양방향 (Full-Duplex)
지연 시간 (Latency) 클라이언트 요청 주기에 종속돼 굉장히 높음 서버에서 즉시 Push 실시간 Push
네트워크 오버헤드 매 요청마다 HTTP 헤더를 동봉 연결이 유지됨 최초 연결 후 2~10 bytes 프레임만 전송
동적 구독 제어 (마켓 전환) 새로운 HTTP 요청 필요 구독 변경을 위한 별도 API 호출 필요 파이프 내부로 제어 메시지 전송
바이너리 데이터 지원 불가 불가 (UTF-8 텍스트만 지원) 가능 (Protobuf 등 직렬화 데이터 전송 지원)
거래소 도메인 적합도 부적합 부분 적합 (단순 시세 푸시나 알림용) 적합

 

  다양한 스트리밍 프로토콜을 비교해보고, 결국은 웹소켓을 선택했습니다. 특히 SSE와 웹소켓을 비교했을 때 거래소에서 잦은 마켓 전환을 고려하고, 스트리밍 데이터 자체의 크기가 큼을 보완하기 위한 바이너리 데이터 통신을 고려하면 웹소켓이 적합해 보입니다. 웹소켓에 대한 스팩 정리는 아래의 포스트를 확인하면 잘 정리되어 있으므로 생략하겠습니다..!

https://soberyl.tistory.com/55

 

[Network] WebSocket과 STOMP

Websocket 2008년 Ian Hickson이 처음으로 제안했으며, 2011년에 RFC 6455로 표준화된 통신 프로토콜입니다. 웹소켓의 설계 철학은 아래와 같습니다. 기존 HTTP 인프라 위에서 양방향 통신을 구현할 때 발생

soberyl.tistory.com

Public vs Private 채널 분리

  사용자는 로그인하지 않아도 차트와 마켓 요약 정보와 같은 거래소의 흐름을 볼 수 있어야 합니다. 또한 로그인을 하면 알림, 개인 사용자 설정 등의 내용을 반영할 수 있어야 합니다. 이는 웹소켓 채널을 Public과 Private으로 구분해야하는 부분인 것 같습니다. 메시지 프로토콜은 Stomp를 사용하겠습니다.

public class StompHeaderChannelInterceptor implements ChannelInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        // accessor를 조회하고 인증 정보를 확인
        if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
            String token = accessor.getFirstNativeHeader("Authorization");

            if (token != null && jwtProvider.validateToken(token)) {
                Long userId = jwtProvider.getUserIdFromToken(token);
                accessor.setUser(new Principal() {
                    @Override
                    public String getName() {
                        return String.valueOf(userId);
                    }
                });
            }
        }

        // 프라이빗 채널 구독 시도 시 권한 통제 (SUBSCRIBE)
        else if (accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
            String destination = accessor.getDestination();
            if (destination != null && destination.startsWith("/user/")) {
                if (accessor.getUser() == null) {
                    throw new IllegalArgumentException("Authentication required for private channels");
                }
            }
        }

        return message;
    }
}

  웹소켓 설정에서, 채널 인터셉터를 설정해야 합니다. 연결 시도 시 인증 정보를 확인하고 이를 저장합니다. 구독 시 요청시에는 인증 정보가 저장이 안되어있다면 private 채널을 구독하려고 하면 블락합니다.

    private final SimpMessagingTemplate messagingTemplate;

    @Async("webSocketTaskExecutor")
    public void broadcastTicker(Ticker ticker) {
        String destination = "/topic/ticker/" + ticker.getMarketId();
        messagingTemplate.convertAndSend(destination, ticker);
    }

    @Async("webSocketTaskExecutor")
    public void broadcastKline(Kline kline) {
        String destination = String.format("/topic/kline/%d/%s", kline.getMarketId(), kline.getInterval());
        messagingTemplate.convertAndSend(destination, kline);
    }

    @Async("webSocketTaskExecutor")
    public void sendPrivateNotification(Long userId, Object notificationData) {
        // /user/{userId}/queue/notifications
        messagingTemplate.convertAndSendToUser(
                String.valueOf(userId),
                "/queue/notifications",
                notificationData
        );
    }

  Stomp를 이용한 SimpMessagingTemplate 의존성을 주입합니다. 이를 이용해서 차트, 마켓 요약 정보, 알림 정보 등에 대한 웹소켓 발송 이벤트를 정의합니다.

 

  프론트엔드에서는 상황에 맞게 웹소켓 채널을 아래와 같이 갱신합니다.

  • 비로그인 사용자
    • 사이트 접속 -> 웹소켓 연결 -> Public 채널 구독
  • 비로그인 상태에서 로그인하는 사용자
    • API로 로그인에 성공하면 JWT 토큰을 발급
    • 기존 웹소켓을 통해 AUTH 메시지를 전송
    • 권한 획득 후 private 채널을 기존 public 채널과 추가적으로 구독
  • 이미 로그인된 상태로 사용자가 재접속
    • 사이트 접속 -> 웹소켓 연결 -> AUTH 메시지 전송 -> public + private 채널 구독

  여기서 비로그인 상태에서 웹소켓을 구독하고, 커넥션이 유지된 상태로 인증 정보를 인가하는 것은 스톰프에서 지원하지 않습니다. 따라서 프론트엔드에서 로그인을 한 직후에 웹소켓 연결을 끊고, 다시 CONNECT 메시지를 보내서 다시 연결하는 작업을 수행합니다.

마켓 데이터 실시간 스트리밍의 흐름

마켓 데이터 브로드캐스팅의 흐름

마켓 데이터의 흐름은 아래와 같습니다.

  1. 매칭 엔진 내부에서 거래가 성사되면 엔진이 가격, 수량 정보가 담긴 이벤트를 발행합니다.
  2. 이벤트 리스너가 체결 데이터를 수신해서 ConcurrentHashMap에 적재된 Ticker와 Kline 객체의 상태를 갱신합니다.
  3. 메모리 갱신이 완료된 Ticker와 Kline 객체를 직렬화해서 웹소켓 게이트웨이로 전달합니다. 이를 연결된 모든 클라이언트가 수신합니다.
    1. 클라이언트의 브라우저가 웹소켓을 통해 JSON 데이터를 수신합니다.
    2. Kline을 기반으로 트레이딩뷰 차트를 갱신하고, Ticker를 기반으로 요약 정보 패널을 갱신합니다.
  4. 백그라운드 스케줄러가 버퍼에 집계된 가장 최신 버전의 스냅샷과 분봉 데이터를 읽고 이를 RDBMS에 반영합니다.

코드 개선

    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(), new PublisherEventHandler());

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

  매칭 엔진의 디스럽터 파이프라인의 마지막에 PublisherEventHandler()를 추가합니다. 이제 저널 스레드가 Order events에 이벤트를 적재하고 나면 그 다음으로 프로젝션과 브로드캐스팅 이벤트가 핸들링됩니다.

    private class PublisherEventHandler implements EventHandler<EngineEvent> {
        @Override
        public void onEvent(EngineEvent event, long sequence, boolean endOfBatch) {
            try {
                for (Trade trade : event.trades) {
                    eventPublisher.publishEvent(new TradeCompletedEvent(trade));
                }
                for (OrderEvent orderEvent : event.orderEvents) {
                    eventPublisher.publishEvent(orderEvent);
                }
            } catch (Exception e) {
                log.error("[Engine-Publisher] Failed to publish events at sequence: {}", sequence, e);
            } finally {
                event.clear();
            }
        }
    }

  트레이드가 완료되거나 주문 상태 변경에 대한 부분이 발생하면 비동기 이벤트를 발행합니다.

    @Async("marketDataTaskExecutor")
    @EventListener
    public void onTradeCompleted(TradeCompletedEvent event) {
        ...

        // Ticker
        long openPrice24h = openPrice24hCache.getOrDefault(marketId, price);

        Ticker ticker = tickerBuffer.computeIfAbsent(marketId, this::loadTickerFromDbOrInit);
        ticker.update(price, quantity, quoteQuantity, openPrice24h);

        webSocketBroadcastService.broadcastTicker(ticker);

        // Kline
        for (ChartInterval interval : HOT_INTERVALS) {
            KlineKey key = new KlineKey(marketId, interval);
            long openTimeMs = interval.getOpenTimeMs(currentMs);

            Kline kline = klineBuffer.compute(key, (k, existing) -> {
                if (existing == null) {
                    return createNewKline(marketId, interval.getCode(), openTimeMs, price, quantity, quoteQuantity);
                }

                if (existing.getOpenTimeMs() != openTimeMs) {
                    pendingKlinesToDb.offer(existing);
                    return createNewKline(marketId, interval.getCode(), openTimeMs, price, quantity, quoteQuantity);
                }

                existing.update(price, quantity, quoteQuantity);
                return existing;
            });

            webSocketBroadcastService.broadcastKline(kline);
        }
    }

  해당 비동기 이벤트를 Spring Async Listener가 수신하면 이를 인메모리 버퍼에 우선적으로 반영하고 해당 내용을 웹소켓으로 브로드캐스팅합니다.


기능 테스트: 1000vus 연결 & 브로드캐스트

  K6 스크립트로 부하 스크립트 시나리오를 정의합니다.

const messageLatency = new Trend('stomp_message_latency_ms');
const messageCounter = new Counter('stomp_messages_received');
const errorRate = new Rate('stomp_errors');

  먼저 커스텀 메트릭을 정의합니다. 체결이 발생하고 데이터가 사용자에게 브로드캐스트될 때까지 걸리는 시간을 messageLatency로 정의합니다. 즉 서버 처리 지연 시간 + 네트워크 지연시간이 messageLatency입니다.

 

  message가 알맞게 도착하는지 카운팅하기 위한 messageCounter, 에러 발생횟수를 파악하기 위한 errorRate를 선언합니다.

export default function () {
    const res = ws.connect(url, params, function (socket) {
        // 서버로부터 메시지 수신 시 라우팅 및 지연 시간 측정 로직
        socket.on('message', function (msg) {
            if (msg === '\n') return;

            // 연결 성공 시 구독 프레임 전송
            if (msg.startsWith('CONNECTED')) {
                const subscribeFrame = "SUBSCRIBE\nid:sub-0\ndestination:/topic/ticker/1\n\n\x00";
                socket.send(subscribeFrame);
            }

            // 실제 브로드캐스트 데이터 수신 시
            if (msg.startsWith('MESSAGE')) {
                messageCounter.add(1);

                const bodyStr = msg.substring(msg.indexOf('\n\n') + 2, msg.lastIndexOf('\x00'));
                try {
                    const payload = JSON.parse(bodyStr);
                    if (payload.timestamp) {
                        const latency = Date.now() - payload.timestamp;
                        messageLatency.add(latency);
                    }
                } catch (e) {
                }
            }
            else if (msg.startsWith('ERROR')) {
                errorRate.add(1);
                console.error("STOMP ERROR: ", msg);
            }
        });

        ...
    });

    check(res, { 'WebSocket handshake status is 101': (r) => r && r.status === 101 });
}

  이제 서버 실행 중에 체결이 발생해서 메시지가 브로드캐스팅 되는 것을 연출해야하기 때문에 더미 데이터를 발생시키는 스케쥴러 컴포넌트를 작성합니다.

@Component
@RequiredArgsConstructor
public class MockTradeGenerator {

    private final ApplicationEventPublisher eventPublisher;

    @Scheduled(fixedRateString = "${simulation.trade-interval-ms:100}")
    public void generateMockTrade() {
        // 더미 변수 선언

        Trade mockTrade = Trade.builder()
                ...
                .build();

        eventPublisher.publishEvent(new TradeCompletedEvent(mockTrade));
    }
}

  이렇게 되면 실제로 트레이드 테이블에 들어가는 정보는 없으나, 거래 체결 이벤트는 1000ms(1s) / 100ms = 10으로 초당 10건씩으로 전파됩니다.

k6 테스트 결과

  1000개의 웹소켓 세션이 잘 연결되었습니다. 발생한 에러는 없으며, 브로드캐스트가 p(95)에서 26ms 이내에 처리됨을 보여줍니다. 다만 이는 1번 마켓, 로컬에서는 BTC에 대해서 Ticker 채널 하나만 구독되어 있는 상태입니다. 로직을 수정해서 실제로 발생하는 모든 채널에 대해서 구독하도록 코드를 수정해야합니다.

 

  총 마켓 50개에 대해서 Ticker 채널 구독을 수립하고, BTC 차트를 보고 있는 상황, 즉 kline에 대해서는 BTC 채널을 구독하고 있는 상황을 k6에 적용해보겠습니다.

        socket.on('message', function (msg) {
            if (msg === '\n') return;

            if (msg.startsWith('CONNECTED')) {
                for (let i = 1; i <= 50; i++) {
                    socket.send(`SUBSCRIBE\nid:sub-t-${i}\ndestination:/topic/ticker/${i}\n\n\x00`);
                }
                socket.send(`SUBSCRIBE\nid:sub-k-1\ndestination:/topic/kline/1/1m\n\n\x00`);
            }

            if (msg.startsWith('MESSAGE')) {
                messageCounter.add(1);

                const headerEnd = msg.indexOf('\n\n');
                const headers = msg.substring(0, headerEnd);
                const bodyStr = msg.substring(headerEnd + 2, msg.lastIndexOf('\x00'));

                try {
                    const payload = JSON.parse(bodyStr);
                    const now = Date.now();

                    if (headers.includes('/topic/ticker/')) {
                        if (payload.timestamp) tickerLatency.add(now - payload.timestamp);
                    } else if (headers.includes('/topic/kline/')) {
                        if (payload.timestamp) klineLatency.add(now - payload.timestamp);
                    }
                } catch (e) {
                }
            }
        });

  마켓 50개를 구독하고, BTC의 1분봉 차트를 보고 있는 상황으로 코드를 수정했습니다.

K6 테스트 결과

  결과는 p(95)가 66ms로 증가했고, 최대값은 607ms까지 증가했습니다. 전반적으로 kline에 대해서는 이렇게 나왔는데, ticker에서 최대값이 1001ms까지 올라가는 부분이 발견됐습니다. ticker와 kline에는 등락 정보 등의 중요한 정보가 지속적으로 실시간 반영이 되어야 합니다. 최초에 목표했던 실시간 메시지 지연시간은 50ms 미만입니다. 따라서 지금의 결과의 지연시간 부분을 평탄화 하면서도 절대적으로 줄여야 할 것 같습니다.

테스트 결과 분석

  1000명의 VU가 각각 50개의 Ticker와 1개의 Kline을 구독하므로 서버 메모리에는 총 51000개의 구독 세션이 생성됩니다. Ticker 가격이 하나 바뀔 때마다, 내장 SimpleBroker는 ConcurrentHashMap을 조회해서 해당 마켓을 구독 중인 1000개의 세션을 찾은 뒤에 1000개의 메시지 전송 작업을 스레드 풀 큐에 밀어 넣게 됩니다. 이 큐가 지속적인 거래 체결로 쌓이게되면 큐잉 딜레이가 지연 시간에 추가됩니다. 특히 Ticker 지연이 더 긴 것은 Kline은 1개의 마켓만 갱신되지만 Ticker는 50개 마켓 전역에서 발생해 큐에 밀어넣는 작업량이 더 많기 때문입니다. 

 

  이를 정확하게 검증하기 위해서 WebSockets 스레드 풀 큐 사이즈를 모니터링을 하는 설정을 진행해보겠습니다. 웹소켓 설정을 살펴보겠습니다.

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final StompHeaderChannelInterceptor stompHeaderChannelInterceptor;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic", "/queue");
        registry.setApplicationDestinationPrefixes("/app");
        registry.setUserDestinationPrefix("/user");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(stompHeaderChannelInterceptor);
    }
}

 

  OutboundChannel에 대한 설정이 없습니다. 이에 대한 설정이 없는 경우 웹소켓은 다음과 같은 기본값으로 OutboundChannel이 설정됩니다.

Core Pool Size(기본 스레드 수): CPU 코어 수 * 2
Max Pool Size(최대 스레드 수): CPU 코어 수 * 2
Queue Capacity(대기열 크기): Integer.MAX_VALUE (약 21억)

  아웃바운드 채널 인터셉터를 수정해서 큐잉 딜레이를 측정해서 그라파나 대시보드에 가시화 시켜보겠습니다.

public class QueueDelayTaskDecorator implements TaskDecorator {

    private final MeterRegistry meterRegistry;

    @Override
    public Runnable decorate(Runnable runnable) {
        long enqueueTime = System.currentTimeMillis();

        return () -> {
            long queueingDelay = System.currentTimeMillis() - enqueueTime;

            Timer.builder("websocket_outbound_queue_delay")
                    .description("WebSocket Outbound Queueing Delay")
                    .register(meterRegistry)
                    .record(queueingDelay, TimeUnit.MILLISECONDS);

            if (queueingDelay > 100) {
                log.warn("WebSocket Outbound Queue Delay: {} ms", queueingDelay);
            }

            runnable.run();
        };
    }
}


public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureClientOutboundChannel(ChannelRegistration registration) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(16);
        executor.setMaxPoolSize(64);
        executor.setThreadNamePrefix("ws-outbound-");

        executor.setTaskDecorator(queueDelayTaskDecorator);

        executor.initialize();
        registration.taskExecutor(executor);
    }
}

  enqueueTime은 메인 스레드가 작업을 큐에 넣는 시점이고, queueingDelay는 이 메인 스레드가 해당 작업을 수행하는 시점과 enqueueTime의 차로 정의됩니다.

커스텀 패널 설정

  큐잉딜레이 최대치가 0.1s이고 브로드캐스트 지연평균은 0.07s입니다. 즉, 각각 100ms, 7ms입니다. CPU 사용률이 인상적인데, OS의 자원까지 합쳐서 거의 1에 근접하고 있습니다. CPU가 포화 상태에 이르면 실행 중인 스레드에게 공평하게 CPU를 할당하기 위해서 극단적인 컨텍스트 스위칭이 발생합니다. 큐잉 딜레이를 최대로 상정해도 1000ms가 걸리는 이유가 여기서 나오는 것 같습니다.

가상 스레드로 OS의 컨텍스트 스위칭을 JVM으로 이관

  아웃바운드 채널의 최대 플랫폼 스레드 수가 64입니다. 이는 많은 OS의 컨텍스트 스위칭을 야기합니다. 따라서 가상 스레드를 도입해서 이에 대한 딜레이를 좀 줄여 보겠습니다. 브로드캐스트 작업은 단순한 I/O bound 작업이기 때문에 많은 효율을 보일 것이라고 생각합니다.

public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureClientOutboundChannel(ChannelRegistration registration) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8192);
        executor.setMaxPoolSize(8192);

        executor.setThreadFactory(Thread.ofVirtual().name("ws-outbound-vt-", 1).factory());

        executor.setTaskDecorator(queueDelayTaskDecorator);

        executor.initialize();
        registration.taskExecutor(executor);
    }
}

  스프링 웹소켓의 ChannelRegistration은 메서드 시그니처가 스팩 상 ThreadPoolTaskExecutor만 받도록 강제되어 있습니다. 따라서 이를 우회하도록 스레드 풀을 이전과 같은 방식으로 생성한 뒤에 스래드 팩토리를 가상 스레드로 선언해서 우회적으로 가상 스레드를 사용하도록 적용합니다. 원래 가상 스레드는 필요할 때마다 생성되는 온디멘드 방식이 권장되고 재사용되는 구조인 스레드 풀은 안티 패턴입니다만, 스프링 웹소켓의 특성 상 어쩔 수 없이 이렇게 우회해서 사용했습니다.

엥 거의 비슷하게 나오네...

  K6 결과는 좀 신기했는데, 최대치는 줄어들었으나 유의미하게 수치가 개선되지는 않았습니다.

그라파나 모니터링

  500ms로 큐잉 딜레이 최대치가 지속되면서 CPU는 이전과 비슷해보입니다. 가상 스레드가 적용이 된게 맞나 싶었지만, 만약 일반적인 플랫폼 스레드가 8천개 선언됐다면 개당 1MB 정도의 메모리를 차지하므로 시작과 동시에 크래시가 발생하였을 것이므로 가상 스레드가 맞습니다. 결국 I/O나 컨텍스트 스위칭으로 인한 것은 거의 없으며 CPU가 이렇게 되고 있는 것은 아래와 같은 가설을 세워볼 수 있습니다.

수많은 STOMP 프레임 인코딩

  convertAndSend()가 호출되면 이를 Jackson 라이브러리를 호출하여 메시지를 직렬화하고, Spring의 내장 SimpleBrokerMessageHandler가 ConcurrentHashMap 기반의 SubscriptionRegistry를 조회해서 /topic/ticker/BTC를 구독 중인 1,000명의 세션 ID를 찾아냅니다.

 

  이때 1000명의 유저에게 동일한 메시지를 보내더라도 스프링은 각 유저의 세션마다 별도의 STOMP 프로토콜 헤더를 새로 만들어야 합니다. clientOutboundChannel을 통과한 메시지는 StompEncoder를 거치게 되는데, 여기서 1명의 유저에게 보낼 때마다 아래와 같은 형태의 문자열을 메모리에서 조립(String Concatenation)하고 바이트 버퍼로 변환합니다.

MESSAGE
destination:/topic/ticker/BTC
content-type:application/json
subscription:sub-0
message-id:803jdf-1

{"marketId":"BTC", "price":95000000, ...} \0

  STOMP는 항상 이 헤더를 생성하고 웹소켓 메시지를 전송해야 합니다. 이때 지금과 같은 극단적인 팬 아웃 상황에서는 이 작업이 CPU 사이클을 크게 소모합니다.

 

  이 가설이 맞다면, 이를 해결해서 CPU 사용률을 최적화하고 보다 쾌적해진 상태에서의 브로드 캐스팅은 롱 테일 레이턴시를 없애서 결론적으로 VUS 1000명의 초당 10건의 트레이드 발생 환경에서는 안정화될 것으로 보입니다. 

로컬호스트 테스트의 한계

  위와 같은 내용 뿐만 아니라 테스트 클라이언트와 WAS를 같은 PC 자원에서 테스트하니 CPU 사용과 메모리 등에서 측정에 대한 오염이 생기는 것 같습니다. 어태커와 디팬더의 리소를 격리해서 테스트 환경을 재조성할 필요가 있는 것 같습니다. 다음 포스트에서는 테스트 환경을 다시 조성하고 더 많은 거래 상황에서 최적화를 진행 해보겠습니다 !

반응형

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

[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 3  (0) 2026.06.17
[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 2  (0) 2026.06.07
[Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 1  (0) 2026.06.02
[Snap Trade] 프로젝트 계획서  (0) 2026.04.30
'프로젝트/Snap Trade' 카테고리의 다른 글
  • [Snap Trade] 거래 매칭 엔진 아키텍처 설계 - 3
  • [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
      • 청춘
        • 희곡
        • 연극
      • 잡설
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
soberyl
[Snap Trade] 마켓 데이터 스트리밍 아키텍처 설계 - 1
상단으로

티스토리툴바