[Java & Spring] 실시간 주식 차트는 어떻게 만들까 / feat.시세 fan-out 아키텍처

2026. 5. 22. 09:03·Java & Spring
반응형

실시간 주식 차트 앱을 켜 두면 호가창과 가격이 거의 끊김 없이 흐른다. 종목을 바꾸면 새 종목 시세가 즉시 따라온다. 이 화면을 백엔드 관점에서 다시 보면 질문이 생긴다. 사용자 한 명이 차트를 띄울 때마다 백엔드는 그 종목의 시세를 어디서 가져오고 있을까. 사용자가 수십만 명이라면 매 갱신마다 N × M 번씩 외부 API를 호출하고 있는 걸까. 그렇게는 절대 안 만들어진다는 건 짐작이 가지만, 그럼 어떻게 만드는 걸까.

 

답은 흔히 말하는 fan-out 아키텍처다. 외부 시세 채널은 한 번만 열고, 받은 데이터를 내부 Pub/Sub로 흘리고, WebSocket 게이트웨이가 그것을 자기 사용자에게 밀어준다. 이 글에서는 그 구조를 한 번 정리한다. 다만 이 글은 특정 서비스의 실제 내부 구조를 다루지 않는다. 같은 문제(많은 동시 사용자 + 외부 시세 한도 + 실시간성 요구)를 푸는 일반적인 아키텍처 패턴을 정리한 것에 가깝다.

이 구조가 필요한 이유

가장 단순한 구현부터 생각해 본다. 클라이언트가 1초마다 GET /quote?symbol=005930 같은 REST API를 호출해 현재가를 가져오는 방식이다. 한 명만 쓴다면 잘 동작한다.

 

문제는 다음 세 가지가 겹치면서 시작된다.

 

첫째, 정보가 압축된다. 장중에는 1초 동안 같은 종목에서 수십 건의 체결이 일어난다. 1초마다 한 번 받아오면 그 사이의 가격 흐름은 사라진다. 차트가 보여줘야 할 변동성이 화면에서 지워진다.

 

둘째, 백엔드 부하가 폭발한다. 동시 사용자 N명이 각자 M개의 종목을 1초 주기로 호출하면 초당 N × M 건의 요청이 백엔드로 들어온다. 사용자 1만 명이 평균 5개 종목을 본다면 초당 5만 요청이다. 그 뒤에 외부 시세 API 호출까지 따라붙는다.

 

셋째, 외부 시세 채널에는 한도가 있다. 거래소나 시세 벤더가 제공하는 실시간 데이터 채널은 보통 계약·계정 단위로 동시 실시간 구독 종목 수 한도가 있다. 사용자 수만큼 외부 연결을 늘려서 해결할 수 있는 문제가 아니다. 이 세 조건 때문에 "받아 와서 흘려보내는" 구조가 필요해진다. 서버가 외부 시세를 한 번만 받고, 그것을 사용자 N명에게 복사해서 밀어주는 구조다.

 

1초 polling 방식과 push 기반 fan-out 방식의 차이. polling은 사용자 수에 비례해 외부 호출이 늘어나고, fan-out은 외부 호출이 일정하게 유지된다.

 

먼저 알아야 할 배경지식

이 구조를 이해하려면 두 개념이 필요하다. fan-out과 push 전송 계층이다. fan-out 은 하나의 입력을 여러 출력으로 복제해 보내는 패턴이다. 시세 한 건이 들어오면 그 종목을 구독한 모든 사용자에게 복사되어 나간다.

 

fan-out은 어디서 일어나는지에 따라 모양이 달라진다. 메시지 브로커가 알아서 복사해 보내는 모델(Redis Pub/Sub, Kafka consumer group, NATS subject)이 있고, 애플리케이션 코드가 in-memory 구독자 맵을 들고 직접 복사해 보내는 모델이 있다. 실제 서비스는 두 단계를 섞어 쓴다. 외부 시세 → 내부 브로커가 1차 fan-out, 게이트웨이 인스턴스가 자기 WebSocket 세션으로 2차 fan-out 한다.

 

push 전송 계층 은 서버 → 클라이언트로 데이터를 능동적으로 보낼 수 있는 통로다. 브라우저 기반 서비스에서 사실상 표준은 WebSocket(RFC 6455)이다. 양방향 풀듀플렉스라서 클라이언트가 종목 구독을 바꿀 때도 같은 연결을 그대로 쓸 수 있다. 단방향만 필요하면 Server-Sent Events(SSE)도 선택지지만, 모바일 앱과 브라우저 양쪽을 고려하면 WebSocket이 가장 무난하다.

전체 구조

대규모 실시간 시세 분배의 일반적인 구조는 다음 네 단계로 나뉜다.

  1. 외부 시세 수신부. 거래소나 시세 벤더의 실시간 채널(WebSocket 등)을 단일 또는 소수의 프로세스가 구독한다. 모든 종목, 또는 우리 사용자들이 구독 중인 종목 전부를 한 번에 받는다.
  2. 정규화 / 집계 레이어. 받은 원본 메시지를 내부 표준 포맷으로 변환하고, 필요하면 다운샘플링(예: 100ms 윈도우 마지막 가격만)을 한다.
  3. 메시지 브로커. 정규화된 시세를 종목별 토픽으로 발행한다. 게이트웨이들이 토픽을 구독한다.
  4. WebSocket 게이트웨이. 클라이언트의 WebSocket을 종단한다. 자기 인스턴스에 연결된 사용자들의 구독 정보를 가지고 있다가, 브로커에서 받은 메시지를 해당 종목 구독자에게만 push 한다.

 

이 구조의 핵심 성질은 다음과 같다.

 

외부 시세 채널은 사용자 수와 무관하게 일정한 개수만 유지된다. 사용자가 늘어나면 게이트웨이 인스턴스만 추가하면 된다. 브로커에는 채널당 메시지 1건씩만 발행되므로 게이트웨이 수가 늘어도 추가 부담이 거의 없다. 종목별 토픽이 분리되어 있으므로, 사용자가 구독하지 않은 종목은 그 사용자의 게이트웨이 인스턴스에는 들어오지도 않는다.

 

메시지 브로커 선택

분배 채널로 무엇을 쓸지가 흔히 첫 번째 설계 결정이다. 셋을 비교해 보면 차이가 분명하다.

브로커 보존 보장 수준 지연 적합한 용도
Redis Pub/Sub 없음 at-most-once 매우 낮음 실시간 시세 분배
NATS Core 없음 (JetStream으로 추가) at-most-once 매우 낮음 실시간 시세 분배, subject 패턴 활용
Kafka 영속 at-least-once 비교적 높음 시세 로그 저장, 재처리, 분석

실시간 시세 자체는 "지금 받지 않으면 의미 없는 데이터"라서 보존이 필요 없다. 늦게 들어온 구독자가 옛날 가격을 받아도 가치가 없다. 그래서 메시지를 들고 있지 않는 가벼운 브로커가 어울린다. 대신 같은 시세를 분석·재처리·감사용으로 보관하고 싶다면 Kafka를 함께 둔다. 보통은 정규화 레이어가 두 곳으로 분기해서 보낸다. 실시간 분배는 Redis Pub/Sub 또는 NATS, 영속 로그는 Kafka. 이 분리는 흔한 패턴이다.

 

토픽 설계

토픽 이름은 보통 종목 코드 단위로 잡는다. NATS의 subject 표기를 빌리면 다음과 같다.

quote.KR.005930        # 삼성전자 현재가
orderbook.KR.005930    # 삼성전자 호가
trade.US.AAPL          # 애플 체결가

게이트웨이는 사용자가 구독한 종목의 토픽만 따로 subscribe 한다. 사용자가 종목을 바꾸면 게이트웨이가 토픽 구독을 갱신한다. 종목 수가 수천 개라도 토픽 수는 그에 비례해서 늘어난다. 종목별 토픽이 운영적으로 부담스러운 환경에서는 한 단계 위(예: quote.KR.kospi)에서 묶고, 게이트웨이가 종목 코드 기준으로 필터링하는 방식을 쓰기도 한다. 대신 게이트웨이가 받지 않을 메시지도 받아서 거르게 되므로 CPU·네트워크가 조금 더 든다. 선택은 트레이드오프다. 토픽 수를 많이 두면 브로커가 라우팅 비용을 지고, 적게 두면 게이트웨이가 필터링 비용을 진다.

 

WebSocket 게이트웨이가 하는 일

게이트웨이는 겉보기엔 단순해 보이지만 책임이 꽤 많다. 연결 수립 시점에 클라이언트 토큰을 검증하고 인증한다. 인증 이후의 메시지는 가볍게 처리할 수 있도록, 무거운 권한 체크는 가능한 한 connect 시점으로 옮긴다. 연결이 유지되는 동안 클라이언트로부터 다음과 같은 제어 메시지를 받는다.

{ "type": "subscribe",   "symbol": "005930" }
{ "type": "unsubscribe", "symbol": "005930" }
{ "type": "ping" }

 

이 메시지에 따라 게이트웨이는 내부 구독 맵(Map<SessionId, Set<Symbol>>)을 갱신한다. 그리고 필요한 경우 브로커에 신규 토픽 구독을 추가한다. 브로커에서 메시지가 들어오면 게이트웨이는 그 종목을 구독한 세션 목록을 찾아 한 번에 보낸다. 이때 한 게이트웨이 인스턴스가 수만 개 세션을 들고 있으므로, 동기 IO로 한 명씩 보내면 절대 못 따라간다. Netty, Vert.x, Node.js, Go 같은 이벤트 루프 기반 비동기 IO가 사실상 필수다.

 

종목별 토픽 기반 구독 라우팅. 같은 토픽 메시지가 브로커에서 여러 게이트웨이로 가지만, 각 게이트웨이는 그 종목을 구독한 사용자에게만 push 한다.

 

또 한 가지 자주 빠뜨리는 책임은 첫 화면 기준값 이다. 사용자가 차트를 열면 직전 가격이 즉시 필요하다. 실시간 push만 기다리면 다음 체결이 일어날 때까지 화면이 비어 있다. 그래서 클라이언트는 보통 connect 직후 REST API로 "현재가 1건" 을 받고, 그 다음부터 WebSocket 메시지로 갱신한다. WS와 REST는 경쟁 관계가 아니라 짝지어 쓰는 도구다.

 

처리량과 backpressure

장 개장 직후, 발표 직후 같은 시간대에는 인기 종목의 체결이 초당 수백 건씩 쏟아진다. 이걸 그대로 클라이언트로 흘리면 두 가지 문제가 생긴다.

 

첫째, 사용자가 못 본다. 60fps라도 1초에 60장이다. 그 안에 들어오는 수백 건의 가격 변경 중 사람 눈에 의미 있는 건 사실상 마지막 값뿐이다.

 

둘째, WebSocket이 끊긴다. 송신 큐가 쌓이면 게이트웨이의 메모리가 부풀고, 결국 슬로우 컨슈머로 판정되어 연결이 종료된다.

그래서 게이트웨이(또는 그 앞의 정규화 레이어)에서 다운샘플링을 한다. 일반적인 방법은 두 가지다.

 

시간 윈도우 기반 conflation. 종목당 100ms 또는 200ms 윈도우 안에 들어온 메시지 중 마지막 값만 남긴다. 같은 큐를 쓰되 동일 토픽의 옛 메시지는 새 메시지로 덮어쓴다. 이른바 conflation queue 패턴이다.

 

변화 임계값 필터. 가격 변동 폭이 0.01% 미만이거나 전 메시지와 동일하면 스킵. 추가로 클라이언트 측에서도 throttle을 둔다. 받은 메시지를 즉시 그리지 않고 다음 rendering frame까지 모았다가 마지막 값만 화면에 반영한다. 백엔드 + 프론트엔드 양쪽에서 압축해야 부하가 안정된다.

동시 연결 수 스케일링

수만 ~ 수십만 사용자가 동시에 WebSocket을 들고 있는 상황을 운영하려면 게이트웨이를 무상태에 가깝게 만들고 수평 확장한다. 한 인스턴스에 4만 ~ 10만 동시 연결을 두는 것이 흔한 규모다. 이 정도가 되면 애플리케이션 코드뿐 아니라 OS 레벨 한도가 같이 보인다. 파일 디스크립터 한도. WebSocket 연결마다 socket fd 하나가 든다. ulimit -n을 충분히 크게 잡고, systemd 서비스 단위에서도 LimitNOFILE을 함께 올린다. TCP 메모리. 동시 연결이 많으면 커널의 TCP 버퍼 메모리 합이 무시할 수 없는 크기로 자란다. net.ipv4.tcp_rmem, tcp_wmem을 워크로드에 맞게 조정한다. TLS 종료 위치. 모든 게이트웨이가 TLS handshake를 직접 처리하면 CPU가 거기서 거의 다 빠진다. L4 LB 또는 별도 TLS proxy에서 종료하는 편이 흔한 패턴이다. 게이트웨이 사이의 일관성은 브로커가 책임진다. 같은 종목 토픽은 모든 게이트웨이에 동시에 도착하고, 각 게이트웨이는 자기 세션에만 보낸다. 그래서 sticky session도 필요 없다. 사용자가 어느 게이트웨이에 붙어 있든 받는 데이터는 같다.

 

코드 예제: 게이트웨이 안의 fan-out

문제 코드: 모든 세션에 동기 루프로 보내기

@Component
public class NaiveQuoteBroadcaster {

    private final Map<String, List<WebSocketSession>> bySymbol = new ConcurrentHashMap<>();

    public void onQuote(Quote q) {
        List<WebSocketSession> sessions = bySymbol.getOrDefault(q.symbol(), List.of());
        String json = toJson(q);
        // 모든 세션에 동기적으로 텍스트 메시지를 보낸다.
        for (WebSocketSession s : sessions) {
            try {
                s.sendMessage(new TextMessage(json));
            } catch (IOException e) {
                // 무시
            }
        }
    }
}

코드는 짧지만 문제는 세 가지다.

 

첫째, sendMessage가 블로킹된다. 한 세션이 느리면 그 뒤의 세션들이 모두 지연된다. 한 종목 구독자가 1만 명이면 마지막 사람은 9999명을 기다린다.

 

둘째, send 큐가 쌓이는 슬로우 컨슈머를 그대로 둔다. 결국 메모리가 부풀거나 OOM으로 죽는다.

 

셋째, 종목 인기가 폭발하는 시간대에 메시지 빈도가 올라가도 압축이 없다. 큐는 단조 증가한다.

개선한 코드: 비동기 전송 + 종목별 conflation

@Component
public class FanoutBroadcaster {

    private final Map<String, Set<WebSocketSession>> bySymbol = new ConcurrentHashMap<>();

    // 세션별로 "마지막 가격" 만 유지하는 종목별 컨플레이션 슬롯.
    private final Map<WebSocketSession, Map<String, Quote>> pending = new ConcurrentHashMap<>();

    public void onQuote(Quote q) {
        Set<WebSocketSession> sessions = bySymbol.getOrDefault(q.symbol(), Set.of());
        for (WebSocketSession s : sessions) {
            pending.computeIfAbsent(s, k -> new ConcurrentHashMap<>())
                   .put(q.symbol(), q); // 같은 종목 옛 가격은 덮어씀
        }
    }

    // 별도 스레드가 100ms 주기로 호출. 세션별로 모은 마지막 값을 한 번에 보낸다.
    @Scheduled(fixedRate = 100)
    public void flush() {
        pending.forEach((session, slot) -> {
            if (slot.isEmpty()) return;
            List<Quote> batch = new ArrayList<>(slot.values());
            slot.clear();
            sendAsync(session, toJson(batch));
        });
    }

    private void sendAsync(WebSocketSession s, String json) {
        // Netty/Reactor 환경이라면 backpressure 인지 송신이 가능하다.
        // 큐가 임계치를 넘으면 세션을 정리한다.
        if (s.getBufferedAmount() > 1_000_000) { // 가상 API. 실제는 라이브러리별로 다름
            close(s, "slow consumer");
            return;
        }
        s.sendAsync(json);
    }
}

이 형태가 production에 그대로 들어갈 수준은 아니지만, 세 가지 패턴은 분명해진다.

  • 메시지를 즉시 보내지 않고 세션별 conflation slot에 모은다. 같은 종목의 옛 가격은 자동으로 사라진다.
  • 일정 주기로 flush 하면서 한 세션에 한 번의 송신으로 묶는다.
  • 송신 버퍼가 임계치를 넘은 슬로우 컨슈머는 연결을 정리한다.

실제 라이브러리(Netty, Reactor Netty, Vert.x, Spring WebFlux + WebSocket)는 송신 버퍼 크기 조회와 비동기 송신 API를 표준으로 제공한다.

다른 방식과 비교

같은 문제를 다르게 푸는 방법도 있다. 비교 관점이 분명할 때만 의미가 있다.

 

짧은 polling. 단순하고 디버깅이 쉽다. 사용자 수가 적고 갱신 빈도가 낮은 내부 도구라면 충분하다. 동시 사용자 수와 종목 수가 일정 이상으로 커지면 부하가 선형으로 증가해 무너진다.

 

Long polling. 클라이언트가 요청을 보내면 서버가 변경이 생길 때까지 응답을 보류한다. push 효과를 얻지만, 매 갱신이 새 HTTP 요청이라 빈도가 높은 시세에는 비효율적이다. 실시간 시세보다는 알림(메시지 알림 등)에 어울린다.

 

Server-Sent Events. 단방향 push로 충분한 경우에는 단순한 선택이다. 클라이언트가 구독을 자주 바꾸는 차트 화면에서는 별도 REST API로 구독 변경을 보내야 해서 한 단계가 더 든다.

 

WebSocket + fan-out. 차트처럼 양방향 + 빈번한 push가 필요한 경우의 표준 조합. 구조가 복잡하지만 확장성과 비용 측면에서 가장 합리적이다.

마무리

실시간 차트의 핵심은 화려한 그래프가 아니라 그 뒤의 분배 구조다. 외부 시세 채널을 한 번만 열고, 내부 Pub/Sub로 흘리고, WebSocket 게이트웨이가 자기 사용자에게 밀어주는 fan-out 패턴이 거의 모든 실시간 분배 시스템에 공통된다.

이 구조를 이해하고 나면 다음 질문에 답할 수 있다.

  • 사용자가 1만 명 늘어나면 외부 시세 호출은 얼마나 늘어나는가
  • 게이트웨이가 슬로우 컨슈머로 죽는다면 어디부터 봐야 하는가
  • 인기 종목 시세를 받아도 화면이 안 멈추게 하려면 어디서 압축해야 하는가
  • 새 게이트웨이를 추가하면 자동으로 사용자가 분산되는가

다음 단계로 넘어간다면 거래 체결 같은 정확성·내구성이 중요한 흐름과, 시세 같은 지연·throughput이 중요한 흐름을 같은 시스템 안에서 어떻게 분리해 두는지 흐름까지 함께 보는 것이 자연스럽다.

반응형

'Java & Spring' 카테고리의 다른 글

[Java & Spring] Pub/Sub은 어떻게 컴포넌트를 떼어놓을까  (0) 2026.05.25
[Java] JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까  (0) 2026.05.08
[Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)  (6) 2025.08.02
[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기  (0) 2025.07.02
[Java] ReentrantLock 정리 (with. synchronized)  (0) 2025.06.27
'Java & Spring' 카테고리의 다른 글
  • [Java & Spring] Pub/Sub은 어떻게 컴포넌트를 떼어놓을까
  • [Java] JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까
  • [Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)
  • [Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기
Penguin Dev
Penguin Dev
What does the Penguin say?
    글쓰기 관리
  • Penguin Dev
    Pengha!
    Penguin Dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (157) N
      • Java & Spring (9)
      • Redis (1) N
      • System Hacking (4)
      • Algorithm (8)
        • Sorting algorithm (3)
      • Python (6)
      • Web (2)
        • Web Hacking & Security (2)
      • Write-Up (108)
        • pwnable.kr (17)
        • HackCTF (16)
        • 해커스쿨 FTZ (21)
        • LOB(lord of bufferoverflow) (19)
        • LOS (lord of sql injection) (28)
        • XSS-game (6)
        • Webhacking.kr (1)
      • SUA (19)
        • 오픈소스 보안 (19)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    tabat
    computeifabsent
    DB정리
    thread-safe
    spring boot
    동시성처리
    nop sled
    enumerate #list comprehension
    concurrenthashmap vs hashmap
    computeifpresent
    낙관적 락
    ConcurrentHashMap
    CountDownLatch
    코드트리
    Lock
    computeifabsent()
    쿠폰발급
    코드트리조별과제
    reentrantlock실습
    SpringBoot
    hashmap vs concurrenthashmap
    동시성
    putval()
    Java
    비관적 락
    lord of bufferoverflow
    spring
    ReentrantLock
    CacheHit
    computeifpresent()
  • 최근 댓글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
Penguin Dev
[Java & Spring] 실시간 주식 차트는 어떻게 만들까 / feat.시세 fan-out 아키텍처
상단으로

티스토리툴바