배경
실시간 시세 서비스에서는 단순히 웹소켓 연결이 된다는 것보다, 많은 사용자가 동시에 접속한 상태에서도 웹소켓 통신이 안정적으로 이루어질 수 있어야 한다는 것을 중점으로 생각했습니다. 특히 특정 종목으로 구독이 몰릴 경우 fanout 처리 시간이 급격히 증가할 수 있어, 먼저 대규모 연결 수립이 가능한지 확인하고, 그 위에서 fanout 병목을 측정·개선하는 방향으로 접근했습니다.
테스트 환경 구성(K6 + TestProvider 기반)
문제
- 실제 국내 주식 시장은 9시에 열러서 3시에 장이 마감되는 구조이기 때문에 오후 3시 이후에는 새로운 주식 가격을 받을 수 없습니다
- KIS에서 제공하는 API를 제어할 수 없기 때문에 서버에 의도된 스케일의 부하를 줄 수 없습니다.
고려한 점
별도의 Staging 환경이 없었기 때문에 평상시에는 KIS API를 이용하고 테스트 시에만 원하는 강도의 시세 이벤트를 받을 수 있어야 했습니다.
현재 주가 변동 시스템이 외부 KIS API에 의존하고 있었기 때문에 의존성을 분리하기 위해서 시세 공급 계층을 Provider 인터페이스로 추상화하고, 테스트 환경에서는 TesterProvider가 설정 가능한 RPS로 시세 이벤트를 발행하도록 구성했습니다.
여러가지 부하 테스트 방식 중 K6를 선택했는데 그 이유는 기존에 적용되어있던 그라파나와 친화적이라는 점과 코드 기반으로 테스트 시나리오를 작성하기 때문에 AI를 활용했을 때 효율이 극대화 된다는 점 이었습니다.
1차 개선
문제
DAU 1만을 가정한 서비스이기 때문에 최대 동시 접속 웹소켓 1만개를 연결할 수 있어야 했습니다.
하지만, 웹소켓 연결 1,000개 근처에서 JVM의 힙사용량이 3GB를 넘어 OOM이 터지는 상황이 발생했습니다.
![]() |
![]() |
웹소켓 연결 테스트 진행 시 모니터링 상황
초기에는 시세 데이터를 String으로 파싱하는 과정에서 단기 객체가 과도하게 생성된다고 가설을 세웠습니다. 그러나 단기 객체 생성이 GC를 통해서 잘 정리되도록 코드를 수정했음에도 불구하고 여전히 OOM이 유지됐습니다. 정확한 Heap 영역 모니터링이 필요해졌고 메트릭을 수집했습니다.
Grafana의 G1GC 메모리 그래프를 보면 Eden 영역은 GC마다 정상적으로 수거되고 있었는데, Old Gen이 연결 수에 비례해 꾸준히 증가하고 있었습니다. 주식 시세 데이터는 파싱 후 즉시 필요 없어지는 단기 객체라 Eden에서 수거되어야 합니다.
Old Gen에 누적된다는 건 다른 객체들이 여러 번의 GC 사이클을 살아남을 만큼 오래 참조되고 있다는 뜻이었습니다.
어떤 객체가 장기 참조되고 있는지 특정하기 위해 로컬에서 힙 덤프를 생성해 가장 많이 저장된 상위 10개의 데이터 타입을 확인했습니다.
그 결과, 메모리의 대부분을 WebSocketSession 객체와 세션당 고정 할당된 512KB 버퍼, 그리고 각 세션마다 독립적으로 살아있는 heartbeat 스레드가 점유하고 있다는 사실을 확인할 수 있었습니다. 연결이 유지되는 동안 이 객체들은 계속 참조 상태로 Old Gen에 남아 수거되지 않았고, 연결 수가 늘수록 Old Gen이 그대로 차오른 것이었습니다.
해결 방식
Per-connection 스케줄 태스크 제거 → 단일 sweep 루프로 전환
가장 큰 원인은 연결마다 ScheduledFuture가 생성되는 구조였습니다. heartbeat, auth timeout, missed pong 처리가 세션 단위로 분리되어 있어 동접 N개일 때 스케줄러 큐에 O(N)개의 태스크 객체가 상주했습니다. 이를 단일 주기 sweepConnections 루프로 통합해 전체 연결을 순회하면서 인증 미완료 강제 종료, heartbeat ping 전송, missed pong 기반 종료, 닫힌 세션 정리를 일괄 처리하도록 재설계했습니다. 태스크 객체 수가 O(N) → O(1)로 줄었습니다.
구독 인덱스 자료구조 정규화
기존의 Map<String, Set<String>> 기반이었던 구독 인덱스의 key가 "quote:{stockId}" 형태의 문자열이었습니다. subscribe/unsubscribe 경로마다 문자열 파싱이 반복됐고, String key의 해시 연산과 박싱 오버헤드가 누적됐습니다. 이를 Map<Long, Set<String>>으로 전환해 도메인 식별자인 stockId를 그대로 key로 사용하도록 정규화했습니다.
세션 상태 객체 경량화 및 버퍼 상한 조정
CustomWebSocketSession 내부의 userId(String, 32B)를 UUID(16B)로, LocalDateTime 기반 타임스탬프를 epoch millis(long)로 교체해 상태 객체당 불필요한 래퍼 객체를 제거했습니다. 또한 세션당 고정 할당되던 text/binary 메시지 버퍼 상한을 512KB에서 64KB로 축소해 동접 증가 시 이론상 힙 상한을 8배 낮췄습니다.
개선 결과

이 방식을 통해서 웹소켓 약 1만개 연결까지 문제없이 연결되는 것을 메트릭을 통해서 확인했습니다
2차 개선
문제
실제 운영되는 환경을 테스트하기 위해서 DAU 1만을 가정헀을 때 동시 웹소켓 연결 5천개 + 각 세션(유저) 당 구독 종목 20개를 가정하고 k6 부하테스트를 돌렸을 때 웹소켓 리스너에서 가격 변동 이벤트 메시지를 수신한 후 각 웹소켓 세션에 새로운 가격 이벤트를 전달하기까지 약 21,000ms 가 소요되는 문제가 발생했습니다.
기존 주식 정보 업데이트 흐름
KIS로부터 가격 업데이트 이벤트 수신 → Redis에 가격 업데이트 → Redis pub/sub으로 브로드캐스트 → 웹소켓 리스너 서버에가 수신 → 각 유저 웹소켓 세션에 전달

fanout 구조의 병목 원인
기존 구조는 스레드풀 4개로 5,000개 세션에 메시지를 전송했습니다. sendMessage()는 세션 소켓에 데이터를 쓸 때까지 스레드를 점유하는 blocking 호출이었기 때문에, 스레드 1개가 세션 1개를 처리하는 동안 나머지는 대기했습니다. 각 스레드가 약 1,250개 세션을 순차 처리하는 구조였고, 세션당 write 시간이 누적되어 fanout 전체 latency가 p99 기준 21초에 달했습니다.
Virtual Thread 적용 — pinning 문제와 JDK 버전 선택
단순히 Virtual Thread를 도입하는 것만으로는 충분하지 않았습니다. Spring WebSocket 라이브러리의 sendMessage()에는 synchronized가 선언되어 있는데, JDK 21에서 Virtual Thread가 synchronized 블록 안에서 blocking I/O를 만나면 carrier thread에 고정(pinning) 되는 문제가 있습니다.
이 현상이 발생하는 이유는 JDK 21의 모니터 추적 방식에 있습니다. JDK 21까지 synchronized의 객체 모니터는 carrier platform thread 기준으로 소유권을 추적합니다. Virtual thread가 synchronized 안에서 unmount를 시도하면 carrier thread가 반환되고, 다른 virtual thread가 그 carrier에 마운트됩니다. 이때 JVM은 새로 마운트된 virtual thread가 모니터를 보유하고 있다고 인식해 상호 배제가 깨집니다. 이를 막기 위해 JVM은 synchronized 안에서 virtual thread의 unmount를 차단했고, 결과적으로 carrier thread를 그대로 점유한 채 blocking 상태가 됩니다. carrier thread가 4개인 상황에서 pinning이 발생하면 동시에 처리 가능한 세션은 결국 4개로 제한되어 기존 스레드풀 구조와 다를 게 없었습니다.
이 문제를 해결하기 위해 JDK 25로 업그레이드했습니다. JDK 24부터 적용된 JEP 491은 모니터 소유권 추적 방식을 carrier thread가 아닌 virtual thread 자체에 귀속시키도록 변경했습니다. virtual thread가 synchronized 안에서 blocking I/O를 만나도 carrier thread를 반환할 수 있고, 모니터는 해당 virtual thread에 묶여 있으므로 다른 스레드가 모니터를 획득할 수 없어 상호 배제도 유지됩니다.
추가로 가상스레드는 스레드 풀을 두면 안되기 때문에 기존의 4개의 스레드풀은 제거했고 4개의 스레드풀에서 가상 스레드가 생기는 형식은 아닙니다.
5,000개의 virtual thread가 각자 sendMessage()를 호출하다가 blocking이 발생하는 순간 carrier thread를 반환하고, 4개의 carrier thread가 대기 중인 virtual thread들을 계속 교체 실행합니다. fanout latency가 p99 기준 21,000ms → 650ms, 약 95% 감소했습니다.
개선 결과
결과적으로 JDK 버전 업그레이드를 통해서 fanout 문제를 해결 할 수 있었습니다. 여러 기능들이 포함되어있는 프로젝트의 JDK버전을 올리는 것은 기존 코드와의 호환성, 의존 라이브러리 지원 여부, 사이드 이펙트 검증 범위 등을 고려해야하기 때문에 어렵지만 웹소켓 서버가 독립된 서버로 분리되어 있었고 웹소켓과 관련된 로직만 존재했기 때문에 충분히 jdk25를 채택할 수 있다고 생각했습니다.
![]() |
![]() |
왼쪽은 기존 fanout 시간(21,000ms), 오른쪽은 개선 후 fanout 시간(650ms)
3차 개선
문제
기존의 fanout 문제는 해결됐지만 Redis로부터 가격 변동 이벤트가 발행되고 사용자에게 새로운 가격이 전달되기까지 30s가 걸리는 문제가 있었습니다.
개선 방안
최신 데이터 병합 최적화
실시간 현재가 서비스의 특성상 모든 중간값을 순차적으로 전달하는 것보다 최신값을 빠르게 전달하는 것이 핵심 요구사항입니다. 빠른 시세 반영이 중요한 서비스에서 5초 전 가격을 지연 전달하는 것은 의미가 없습니다. 또한 중복 이벤트 문제도 고려했습니다. 동일 종목의 현재가가 중복으로 발행되면 구독자 수에 비례해 fanout이 폭발적으로 증가합니다. 중복 이벤트 N개 * 구독자 M명 = N * M번의 fanout이 발생하는 구조로, 인기 종목일수록 부하가 집중됩니다. coalescing은 이 두 문제를 동시에 해결합니다.
병목은 두 지점에서 발생했습니다. ingress 단계에서는 Redis subscriber callback이 fanout 작업을 직접 붙잡고 있어 onMessage() → fanout → join 대기가 한 흐름으로 연결되었고, Redis consumer가 fanout 완료를 기다리는 동안 다음 이벤트를 처리하지 못했습니다. outbound 단계에서는 같은 세션의 동일 종목 이벤트가 모두 별도 Runnable로 큐에 누적되면서, 앞선 이벤트가 처리되기 전까지 뒤에 쌓인 이벤트들이 대기했습니다.
두 지점 모두 latest-only 방식으로 전환했습니다. ingress에서는 MarketEventIngressDispatcher를 도입해 onMessage() 후 즉시 반환하고, stockId별로 최신 이벤트만 유지한 뒤 별도 worker가 broadcast를 처리하도록 분리했습니다. outbound에서는 세션별로 동일 stockId 이벤트가 들어오면 미전송 이벤트를 최신값으로 덮어쓰는 세션별 coalescing을 적용했습니다.

data-plane / control-plane 분리
시세 이벤트 폭증 시 subscribe ack, auth ack 등 제어 메시지가 밀려 heartbeat가 전달되지 않아 연결이 강제 종료되는 문제가 있었습니다. 시세 데이터(data-plane)와 제어 메시지(control-plane)를 별도 큐로 분리해 data-plane의 부하가 control-plane에 영향을 주지 않도록 격리했습니다.
개선 결과
최적화 결과, Redis에서 이벤트가 발행된 시점부터 유저에게 sendMessage()가 호출되기까지의 p95 지연 시간을 3.4초로 단축할 수 있었습니다. 웹소켓 리스너 내에서 21,000 ms → 3400ms로 바꾼것 만으로도 큰 향상이라고 할 수 있습니다. 그러나 실시간성이 핵심인 주식 시장 도메인에서 현재가가 3초 이상 지연되어 노출되는 것은 서비스 신뢰도에 치명적이라 판단했고, 목표 지연 시간을 300ms 이내로 설정했습니다.

병목 구간을 특정하기 위해 각 구간별 메트릭을 수집한 결과, Redis ping 지연이 최대 700ms, Redis publish → consume 구간이 p95 기준 1,560ms에 달하는 것을 확인했습니다. 이는 Redis가 단일 서버로 운영되면서 연산 처리가 지연되고 있음을 의미했고, 이를 해결하기 위해 Redis Cluster 도입을 결정했습니다.
4차 개선
문제
이전의 개선 과정에서 이미 웹소켓 내부의 fan out 처리는 최적화를 했지만 여전히 3,400ms가 걸리고 있는 것은 Redis에 부하가 있기 때문이라고 생각했습니다.
Redis의 부하를 줄여주기 위해서 스케일 아웃을 해야한다고 판단했고 그 중에서 레디스 클러스터를 채택했습니다.
또한 캐싱으로 인해 Redis에 1초당 450,000건의 업데이트가 일어나는 요구사항이 추가되어 레디스 클러스터의 도입은 필수적이었습니다
개선 방안
Pub/Sub 분산 전략 선택
Redis Cluster의 일반 PUBLISH는 내부적으로 모든 노드에 브로드캐스트되어, 앱 레벨에서 채널을 분리해도 Redis 내부 네트워크 오버헤드가 줄어들지 않습니다. 이에 따라 채널이 해시 슬롯에 바인딩되어 해당 노드에서만 메시지가 처리되는 Sharded Pub/Sub(Redis 7.0+)을 채택했습니다. 이 방식은 브로드캐스트 오버헤드를 구조적으로 제거할 뿐 아니라, 분산 라우팅·리밸런싱·failover 책임이 Redis에 위임되어 앱 코드 변경 없이 수평 확장이 가능합니다. 또한 Pub/Sub만 분산되는 것이 아니라 현재가 SET/GET, 구독 상태 SADD/SREM 등의 데이터 연산도 같은 슬롯 단위로 분산되어, 단일 인스턴스에 집중되던 전체 커맨드 부하가 고르게 나뉩니다.
at-most-once 한계 보완
Pub/Sub은 연결이 끊기면 메시지가 유실되는 at-most-once 특성을 가집니다. 이를 보완하기 위해 Pub/Sub은 실시간 푸시 채널로만 사용하고, 최신 현재가는 Redis에 키로 별도 저장하여 클라이언트 재연결 시 최신 값을 즉시 조회하는 catch-up 메커니즘을 설계했습니다. 실시간성은 Pub/Sub이 담당하고, 정합성은 키 조회가 보장하는 이중 구조입니다.
배포·장애 시 재연결 폭풍 — 코드에서 발견한 불일치
롤링 업데이트나 파드 장애 시, 해당 파드의 유저 약 2,500명이 다른 파드로 동시에 재연결하면 구독 재등록 커맨드가 Redis에 몰립니다. 이 과정에서 같은 코드베이스 안에서 파이프라인 적용이 일관되지 않은 문제를 발견했습니다. batchRenew()은 executePipelined()로 처리하고 있었으나, save()는 호출당 SADD + PEXPIRE 2건, remove()는 SREM + SCARD + 조건부 DEL 2~3건을 파이프라인 없이 개별 실행하고 있었습니다. 유저 2,500명 × 종목 20개 기준, 약 135,000건의 개별 Redis 왕복이 수 초 안에 발생하는 구조였습니다.
구독 등록·해제 경로에도 파이프라인을 적용하여 유저당 Redis 왕복을 40~60회에서 1~2회로 줄였습니다.
잘못된 측정을 의심 — 메트릭 구간 세분화
Redis Cluster 적용 후 스케일을 2배로 올려(WebSocket 10,000개 + 구독 1,000종목) 테스트한 결과, publish → consume p99가 약 1.2초로 측정되었습니다.
Redis ops가 초당 1,750건 수준인데 Pub/Sub 전달이 1.2초 걸리는 건 설명이 되지 않았습니다. Redis 서버가 느린 게 아니라 측정 구간에 다른 지연이 포함되어 있을 가능성을 의심했습니다.기존에는 consumedAt 하나만 찍고 있어서 모든 지연이 합산되어 있었습니다. 어디서 1.2초가 먹히는지 분리하기 위해 타임스탬프를 세 지점으로 나눴습니다.

- publishedAt - SPUBLISH 직전에 페이로드에 포함
- arrivedAt - onMessage() 진입 시점
- consumedAt — Virtual Thread에서 실제 처리 시작 시점
이를 통해 세 구간을 독립적으로 측정했습니다.
- ts(이벤트 생성) → publishedAt(SPUBLISH 직전)
- publishedAt → arrivedAt (순수 Redis Pub/Sub 전달)
- arrivedAt → consumedAt (executor 큐 대기 + VT 스케줄링)
분석 결과, 1.2초의 대부분은 테스트 publisher가 1,000종목을 순차 발행하면서 tick 시작 시각을 전체 종목에 재사용한 데서 온 측정 왜곡이었습니다. 만약 이 과정 없이 수치를 그대로 믿었다면 "Redis Pub/Sub이 1.2초 걸린다"는 잘못된 전제로 불필요한 최적화를 시도했을 것입니다.

개선 결과
Redis Cluster를 도입하여 단일 서버에 집중되던 Pub/Sub과 데이터 연산 부하를 슬롯 단위로 분산시켰습니다.
Redis Ping 지연 p95: 700ms → 25ms 종단간 전달 지연 p95: 3,400ms → 240ms (약 93% 감소)
최초 목표였던 p95 300ms 이내를 달성했으며, 이 수치는 스케일을 2배로 올린(WebSocket 10,000개 + 구독 1,000종목) 테스트였기 때문에 더욱 의미 있는 개선입니다.




