토스증권은 왜 느릴까? - 느린 4G에서 CSR, SSE, Streamable HTTP까지
Published:
Part 1. 체감에서 탐구로
계기
업무상 주가를 자주 봐야 한다. 평소엔 tossinvest.com을 켜두고 쓴다. UI도 익숙하고, 차트도 깔끔해서 따로 이유를 찾지 않았다.
그런데 어느 날, 창을 여는데 한참이 걸렸다. 체감상 10초 이상. 빈 화면만 덩그러니 떠 있었다.
처음에는 “인터넷이 이상한가?” 싶었다. 그래서 같은 창에서 finance.naver.com을 열어봤는데, 이건 1~2초 만에 콘텐츠가 다 떴다.
인터넷 문제는 아니네.
정확히는 느린 네트워크 환경에서 한쪽만 심하게 느렸던 것이다. 빠른 Wi-Fi에서는 둘 다 멀쩡하다. 그래도 “같은 네트워크에서 왜 이렇게 차이가 나지?”라는 호기심이 생겼다.
재현
크롬 개발자 도구를 열었다.
- Network 탭
- Throttling → Slow 4G
- 캐시 비활성화
이 상태에서 각각 새로고침.
| tossinvest.com | finance.naver.com | |
|---|---|---|
| 첫 화면까지 체감 시간 | ~14초 | ~3초 |
5배 차이. 단순히 “서버가 느리네” 같은 설명으로는 부족한 격차였다.
첫 관찰
Network 탭을 보니 요청 수부터 크게 달랐다. curl로 HTML만 딱 받아봤다.
$ curl -s -o /dev/null -w "size: %{size_download}, ttfb: %{time_starttransfer}s\n" https://www.tossinvest.com/
size: 48540, ttfb: 0.103s
$ curl -s -o /dev/null -w "size: %{size_download}, ttfb: %{time_starttransfer}s\n" https://finance.naver.com/
size: 179796, ttfb: 0.165s
역설이었다. HTML이 작은 쪽이 더 느렸다. 토스의 HTML은 48KB인데, 네이버는 180KB. 그런데 체감은 반대다.
HTML 크기가 작은데 느리다는 건, HTML 밖에서 뭔가 많이 받아오고 있다는 뜻이다. 그 “뭔가”를 찾아보기로 했다.
Part 2. 뜯어보기
원인 1 - CSR vs SSR
두 사이트의 HTML 본문을 실제로 열어봤다.
네이버 금융:
HTML 안에 이미 네비게이션, 뉴스 리스트, 주가 테이블, 링크가 죄다 들어있었다. <td>, <tr>, <div> 태그만 700개가 넘는다. JS가 전혀 없어도 콘텐츠는 그대로 보인다.
이게 SSR(Server-Side Rendering) 이다. 서버가 완성된 HTML을 내려주고, 브라우저는 받자마자 그림을 그린다.
토스증권:
반대였다. body 안에는 Google Tag Manager iframe, 브라우저 지원 여부 체크 코드, 그리고 #unsupported-device-section을 숨기는 CSS 정도가 전부였다. 콘텐츠는 한 줄도 없었다.
이게 CSR(Client-Side Rendering) 이다. HTML은 껍데기고, JS를 다 받아 실행해야 화면이 그려진다.
Critical Rendering Path를 그려보면 이렇다.
=== 네이버 금융 (SSR) ===
HTML (180KB, 콘텐츠 포함) → CSS → 첫 화면 표시
↓
나중에 JS로 인터랙티브 추가
=== 토스증권 (CSR) ===
HTML (빈 껍데기) → CSS → JS 31개 다운로드 → JS 파싱/실행
↓
API 호출 → 데이터 수신 → 렌더링
Slow 4G에서 이 차이는 곧 “콘텐츠 보이는 시점”의 차이다.
원인 2 - 6MB JS 번들
JS를 얼마나 받는지 측정했다.
$ curl -s https://www.tossinvest.com/ \
| grep -oE '/assets/v2/_next/[^"]*\.js' > urls.txt
$ wc -l urls.txt
31 urls.txt
$ xargs -I{} curl -sI "https://www.tossinvest.com{}" < urls.txt \
| grep -i content-length \
| awk '{sum+=$2} END{print sum/1024 " KB"}'
6017.09 KB
JS 파일 31개, 합쳐서 약 6MB. Slow 4G(1.5Mbps)에서 이걸 다 받으려면 이론적으로 30초 이상이다. 실제로는 병렬 다운로드 + gzip 압축 덕에 더 빠르지만, 파싱/실행까지 포함하면 14초가 설명이 된다.
네이버 쪽 JS는 9개, 총합 ~200KB. 약 30배 차이.
청크별로 뭐가 들어있는지 키워드를 분석해봤다.
| 파일 | 크기 | 뭘 담고 있나 |
|---|---|---|
_app.js | 2.3MB | 전체 앱 공통 로직 + 614개 API 엔드포인트 |
1840.js | 1.4MB | 주문/매매 엔진 (order 1,136건, stock 957건) |
5381.js | 620KB | 차트 렌더링 엔진 (SVG path 158개, d3, framer) |
index.js | 102KB | 실제 홈페이지 코드 |
| 기타 27개 | ~1.6MB | 관심종목, 대시보드, 인증 등 |
홈페이지 하나 보는데 주문/매매/차트 엔진이 통째로 딸려온다. Next.js의 코드 스플리팅이 제대로 안 되고 있거나, _app.js에 전역 의존성이 너무 많이 엮여 있는 상태다.
코드 속으로 - SSE 발견
_app.js를 prettier로 포매팅해서 열어봤다. 2.3MB짜리 파일이라 한눈에 파악은 불가능하지만, WebSocket, EventSource, stomp 같은 키워드로 검색했더니 흥미로운 게 나왔다.
setupEventSource() {
this.#x = new EventSource(this.#S, { withCredentials: true });
this.addEventSourceEvent(this.#x);
}
EventSource. 즉 SSE(Server-Sent Events) 를 쓰고 있었다. 실시간 시세 받는 부분이다.
여기서 멈추지 않는다. 그 밑에 꽤 복잡한 클래스가 이어진다.
class l {
static #P = {};
static getInstance(e) {
return (null == l.#P[e] && (l.#P[e] = new l(e)), l.#P[e]);
}
// ...
switchRole(e) {
this.detachRole();
e ? this.executeLeaderRole() : this.executeFollowerRole();
}
executeLeaderRole() { /* SSE 연결 직접 맺고 브로드캐스트 */ }
executeFollowerRole() { /* 리더로부터 메시지 수신 */ }
}
Leader-Follower 패턴이다. 브라우저에서 탭을 여러 개 열어도 SSE 연결은 한 탭(리더)만 맺고, 나머지는 BroadcastChannel로 데이터를 전달받는다. 리더가 죽으면 다른 탭이 승격된다.
에러 시 재연결 로직도 별도로 있다.
reconnect() {
if (this.#k >= 3) return;
this.clearReconnectTimer();
this.#C = setTimeout(() => {
this.#k += 1;
this.connect();
}, 1000);
}
왜 이렇게까지 복잡해졌을까? 짧게 말하면 SSE의 구조적 한계 때문이다.
SSE는 연결을 오래 유지한다. 탭마다 연결을 맺으면 서버 리소스가 빨리 차고, 사용자 한 명이 브라우저 탭 5개 띄워두면 연결도 5개가 된다. 그걸 한 개로 줄이려고 탭 간 공유를 직접 구현한 것이다.
실시간 통신 방식 비교
내친김에 실시간 통신 옵션을 전부 정리했다.
| HTTP Polling | SSE | WebSocket | Streamable HTTP | |
|---|---|---|---|---|
| 방향 | 클라이언트 → 서버 | 서버 → 클라이언트 | 진짜 양방향 (full-duplex) | 요청-응답 스트림 (POST + SSE 응답) |
| 프로토콜 | HTTP | HTTP | ws:// | HTTP (내부적으로 SSE 활용) |
| 연결 유지 | 매번 새로 | 유지 | 유지 | 요청 단위 스트림 |
| 인프라 호환 | 완벽 | 완벽 | 별도 설정 | 완벽 |
| 스케일아웃 | 쉬움 | 어려움 (세션 고정) | 어려움 (세션 고정) | 설계에 따라 다름 |
| 재연결 | 필요 없음 | 브라우저 자동 | 직접 구현 | 요청별 독립 가능 |
HTTP Polling은 가장 단순하다. 1초마다 “시세 줘” 요청. 실시간성은 떨어지지만 서버가 stateless라 확장이 쉽다. 뉴스 피드나 5분봉처럼 즉시성이 덜 중요한 곳에 맞다.
SSE는 서버 → 클라이언트 한 방향으로만 데이터가 흐른다. HTTP 기반이라 기존 인프라(nginx, CDN, 로드밸런서)를 그대로 쓸 수 있고, 브라우저가 자동 재연결도 해준다. 토스가 쓰는 방식이다. 단점은 양방향이 안 된다는 것, 그리고 연결이 오래 유지되다 보니 서버 세션 고정이 필요하다는 것.
WebSocket은 진짜 양방향 통신. 채팅, 게임, 초저지연 트레이딩 같은 곳에 쓴다. 대신 ws://라는 별도 프로토콜이라 nginx Upgrade 설정, 로드밸런서 sticky session, 방화벽 등 인프라 레이어마다 신경 쓸 게 늘어난다.
Streamable HTTP는 요즘 나온 중간 지점이다. 이건 별도 섹션에서 더 파보겠다.
Streamable HTTP 딥다이브
등장 배경
SSE와 WebSocket은 각자의 단점이 뚜렷했다.
- SSE는 GET만 쓰고 단방향
- WebSocket은 인프라가 복잡
그래서 “HTTP 단순함은 유지하면서, 양방향도 되고, stateless도 가능한” 방식이 필요해졌다. Spring-AI의 MCP(Model Context Protocol) 서버 프레임워크 쪽에서 이 프로토콜을 표준 옵션 중 하나로 밀고 있다.
핵심 아이디어
POST와 GET을 공유하는 단일 엔드포인트에 요청을 보내면, 서버가 상황에 맞게 응답 모드를 고른다.
- 단건이면 그냥 단일 JSON 응답 반환
- 스트리밍이 필요하면 SSE 스트림(
text/event-stream)으로 전환해서 이벤트를 흘려보냄
즉 “SSE를 대체하는 새 프로토콜”이 아니라, SSE를 transport로 품은 상위 레이어에 가깝다.
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
{"method": "subscribe", "params": {"stock": "005930"}}
서버는 상황에 따라 두 가지로 응답할 수 있다.
# (1) 단건이면 그냥 JSON
HTTP/1.1 200 OK
Content-Type: application/json
{"result": "subscribed"}
# (2) 스트림이 필요하면 SSE로
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"price": 72000, "type": "snapshot"}
data: {"price": 72100, "type": "update"}
...
SSE 단독 사용과 비교하면 핵심 차이는 세 가지다.
차이 1 - POST 기반
SSE는 GET이다. 구독할 정보를 URL 쿼리로 넘겨야 한다. 필터가 복잡해지면 URL이 길어지고, 민감한 정보는 URL에 못 넣는다.
Streamable HTTP는 POST다. body에 JSON으로 뭐든 넣을 수 있다.
POST /stream
{
"subscriptions": ["005930", "000660"],
"filters": {"priceChangeAbove": 3.0},
"fields": ["price", "volume"],
"auth": { "token": "..." }
}
차이 2 - 응답 모드를 서버가 고른다
SSE는 서버 → 클라이언트로 흐르는 스트림 한 종류만 있다. 요청은 쿼리 문자열에 의존하고, 서버가 “이건 스트림이 필요 없는 단순 응답인데?”라고 판단해서 바꿀 수가 없다.
Streamable HTTP는 같은 엔드포인트에서 서버가 JSON 단건 응답과 SSE 스트림 중에 고른다. 요청마다 지연 특성이 달라도 같은 API 표면을 유지할 수 있다.
한 가지 주의. 이걸 흔히 “양방향”이라고 부르는데, 브라우저 fetch는 half-duplex다. Request.duplex: 'half'는 Chrome 105+에서만 실험적으로 허용되고 Firefox/Safari는 아직 막혀 있다. 그래서 실제 MCP 구현도 “한 연결 안에서 양쪽이 동시에 핑퐁”을 가정하지 않는다. 클라이언트가 뭔가 또 보내야 하면 새 POST를 날리고, 서버가 push할 게 있으면 기존 스트림이나 GET으로 연 SSE를 통해 내려준다.
# 실제 MCP 동작은 이런 식:
클라이언트 → POST /mcp {"method": "subscribe", ...}
서버 ← SSE 스트림: {"price": 72000}, {"price": 72100}, ...
# 주문을 보내고 싶으면? 같은 스트림에 끼워 넣는 게 아니라 별도 POST:
클라이언트 → POST /mcp {"method": "order", ...}
서버 ← JSON: {"result": "체결완료"}
“하나의 의미적 세션” 안에서 여러 메시지를 주고받을 수 있다는 뜻이지, “하나의 TCP 연결에서 동시에 양쪽 스트리밍”은 아니다. 진짜 동시 양방향이 필요하면 여전히 WebSocket이다.
차이 3 - Stateless 운영을 선택할 수 있다
설계하기 나름이다. 요청에 필요한 정보를 다 담는 방식으로 짜면 서버가 상태를 들고 있지 않아도 된다.
POST /mcp
{
"sessionToken": "abc123",
"lastEventId": "evt-4582",
"subscriptions": ["005930"]
}
이렇게 두면 서버 A가 받아 처리하다가 연결이 끊기고 다음 요청은 서버 C가 받아도 된다. lastEventId 이후부터 스트림이 이어진다. 세션 고정이 필요 없다.
다만 이건 선택지지 속성이 아니다. MCP 공식 스펙은 Mcp-Session-Id 헤더로 세션 관리 모드도 허용하고, Spring AI MCP도 기본적으로 persistent connection을 전제한다. “Streamable HTTP = Stateless”가 아니라, “Stateless로도, Stateful로도 돌릴 수 있는 transport”에 가깝다.
코드 예시
서버 (Spring SseEmitter):
@PostMapping("/stream")
public SseEmitter subscribe(@RequestBody SubscribeRequest req) {
SseEmitter emitter = new SseEmitter();
priceService
.streamFrom(req.getLastEventId(), req.getStocks())
.forEach(price -> {
emitter.send(SseEmitter.event()
.id(price.getEventId())
.data(price));
});
return emitter;
}
클라이언트 (fetch + ReadableStream):
async function subscribe(stocks, lastEventId) {
const res = await fetch('/stream', {
method: 'POST',
body: JSON.stringify({ stocks, lastEventId }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const data = JSON.parse(decoder.decode(value));
updatePrice(data);
lastEventId = data.id;
}
subscribe(stocks, lastEventId); // 이어서 재구독
}
EventSource처럼 브라우저가 알아서 해주는 게 없다. fetch + ReadableStream을 직접 조합해야 한다.
토스 코드에 적용하면?
토스증권이 지금 구현해놓은 것과 비교해보자.
| 현재 (SSE 기반) | Streamable HTTP로 바꾸면 (stateless 설계 시) |
|---|---|
new EventSource(url, {withCredentials: true}) | fetch(url, {method: 'POST'}) |
| Leader-Follower 패턴 | 단순화 가능 (stateless로 설계한다면) |
| BroadcastChannel 탭 공유 | 단순화 가능 (설계 선택에 따라) |
| 재연결 시 처음부터 구독 | lastEventId로 이어서 |
| 서버 세션 고정 필요 | 아무 인스턴스나 가능 |
Leader/Follower/BroadcastChannel 관련 코드가 수백 줄 있는데, stateless로 설계한다면 상당 부분 단순화할 수 있다. 다만 transport를 바꾼다고 저 복잡도가 자동으로 사라지는 건 아니다. MCP 스펙 자체는 세션 관리(Mcp-Session-Id)도 허용하기 때문에, 실제 단순화는 “transport 변경 + stateless 설계 결정”이 함께 갈 때 생긴다.
제약사항 - 뭐가 필요한가
“Streamable HTTP”는 완전히 새 프로토콜이 아니라 기존 기술의 조합이다. 그래서 제약은 레이어별로 따져야 한다.
서버 (Java / Spring):
| 구성요소 | 최소 버전 | 비고 |
|---|---|---|
| Java | 17+ | Spring Boot 3.x 때문 |
| Spring Boot | 3.2+ | WebFlux streaming 안정화 |
| Spring AI MCP | 1.0.0-M6+ | MCP 스펙 2025-03-26 반영 |
MCP 스펙을 엄격히 따르고 싶을 때 기준이다. 단순히 “HTTP로 스트림 응답만 내려주면 됨” 수준이면 SseEmitter는 Spring 4.2(2015)부터 있었고 Java 8로도 된다. 스펙을 따르려는지, 구조만 빌려오려는지에 따라 요구치가 확 달라진다.
HTTP 프로토콜:
| 프로토콜 | 단방향 (서버→클라) | 양방향 |
|---|---|---|
| HTTP/1.1 | OK (chunked transfer) | 불가 (request body 스트리밍 불가) |
| HTTP/2 | OK | OK — 진짜 양방향 쓰려면 필수 |
| HTTP/3 | OK | OK (패킷 유실 많은 환경에 유리) |
HTTP/1.1은 request body를 한 번에 다 보내야 한다. 응답만 스트리밍 가능. MCP 스펙의 “진짜 양방향”을 쓰려면 HTTP/2 이상이 필요하다.
브라우저 (가장 걸리는 지점):
| 기능 | Chrome | Firefox | Safari |
|---|---|---|---|
fetch() | 42+ | 39+ | 10.1+ |
ReadableStream | 43+ | 65+ | 10.1+ |
TextDecoderStream | 71+ | 105+ | 14.1+ |
| Request body streaming (양방향 필수) | 105+ (2022) | 미지원 | 미지원 |
현실적으로 가장 크게 걸린다. 진짜 양방향 스트리밍은 Chrome/Edge에서만 된다. Firefox/Safari는 2026년 기준 여전히 request body streaming을 지원하지 않는다. 그래서 MCP 구현체들도 대부분 “POST 하나 보내고 응답은 스트림” 패턴을 기본으로 삼고, 클라이언트가 뭔가 보낼 일이 생기면 별도 POST를 또 날리는 식으로 양방향을 흉내 낸다.
JavaScript / Node.js:
- ES2017(async/await) 이상이면 작성 가능. 실무 타겟은 보통 ES2020+
- Node.js는 18+ 면 fetch, ReadableStream이 전부 내장. 16 이하면
undici필요
인프라 (덜 유명한데 진짜 중요):
- nginx 기본 설정은 응답을 버퍼링한다.
proxy_buffering off,proxy_read_timeout같은 값을 손봐야 스트림이 덩어리로 뭉쳐서 나가지 않는다 - 로드밸런서 idle timeout: AWS ALB 기본 60초. 오래 붙어 있는 연결은 잘린다
- CDN/WAF가 중간 버퍼링을 할 수도 있다 (Cloudflare 등 일부 설정 필요)
그래서 다 바꾸면 될까?
그렇진 않다. 단점도 분명하다.
- 브라우저 네이티브 API가 없다.
EventSource처럼 “객체 하나 만들면 끝”이 아니라 직접 읽어야 한다. - 진짜 양방향은 브라우저 커버리지가 좁다. 위 표처럼 Firefox/Safari가 아직 request body streaming을 안 받아준다.
- 생태계가 얇다. MCP 중심으로 표준화된 상태라 범용 사례가 아직 적다.
- 중간 프록시 타임아웃·버퍼링 문제가 생길 수 있다. 응답을 오래 붙들고 있는 요청이라 nginx 기본 설정으로는 끊기거나 뭉쳐 나간다.
- 증권처럼 돈이 오가는 서비스는 리스크가 크다. 검증되지 않은 프로토콜을 실시간 거래에 바로 넣긴 어렵다.
Part 3. 잠깐 — 토스가 잘못 만든 건가?
여기까지만 읽으면 “토스가 느리네”로 결론낼 뻔했지만, 한 발짝 물러서서 봐야 한다. 토스증권은 정보 조회 사이트가 아니라 거래 앱이다. 매수·매도, 호가, 체결, 차트, 보유 현황이 한 세션 안에서 쉴 새 없이 돌아간다. 이런 앱에서 진짜 중요한 건 첫 화면 진입 속도가 아니라 진입한 다음의 모든 것이다.
- 시세는 ms 단위로 갱신돼야 한다
- 호가창이 주문 도중에 깜빡이면 안 된다
- 종목 → 차트 → 주문 → 보유 페이지 전환은 즉시 반응해야 한다
- 차트 봉 단위 전환에 깜빡임 0
- 주문 버튼은 누르자마자 떠야 한다
이걸 SSR로 만들면 페이지 이동마다 서버를 한 번씩 더 다녀와야 하고, 그때마다 사용자 컨텍스트(보유 종목, 미체결 주문, 세션)를 다시 꾸려야 한다. 거래 중 깜빡임 한 번에 사용자 신뢰가 무너지는 도메인이라, 토스는 “한 번 비싸게 부팅하고, 그다음은 클라이언트가 전부 처리한다” 는 쪽을 의식적으로 골랐다. 6MB JS와 31개 청크는 그 선택의 결과물이지 사고가 아니다.
그래서 네이버 금융과의 단순 비교는 부분적으로 불공정하다. 네이버 금융은 읽기 전용 정보 사이트라 “페이지 로드 = 콘텐츠 표시”가 곧 끝이다. SSR이 압도적으로 유리하다. 토스증권은 트레이딩 워크스테이션이라 한번 부팅된 후 사용자가 그 안에서 수십 분~수 시간을 머문다. 무게중심이 다른 트레이드오프다.
실제로 토스증권은 한번 들어간 다음에는 종목 검색, 차트 전환, 주문 패널 호출이 거의 0ms다. 같은 Slow 4G에서도 진입 후 인터랙션은 SSR로 짠 네이버보다 훨씬 부드럽다. 6MB는 “거래 앱이 들어온 후 즉시 반응하기 위해 선불로 치르는 비용”이다. 처음에 느린 것을 감수하더라도, 한번 띄운 뒤로는 모든 게 손끝에 따라붙게 만들겠다는 의도다.
다만 이 전략은 첫 관문이 너무 높아지는 순간 무너진다. Slow 4G가 정확히 그 지점이다. “진입 후의 부드러움”을 경험할 기회 자체가 사라지면, 그 비싼 부팅의 대가는 회수가 안 된다. 지하철·엘리베이터·외곽지역에서 “지금 주가 한 번만”인 사용자는 그냥 네이버를 켠다.
그러니 손볼 여지는 “SPA를 버려라”가 아니라 “첫 관문만 부분적으로 SSR/SSG로 빠르게 띄우고, 거래 화면 진입 후에는 지금 그대로” 쪽이다. Next.js라면 정적 셸을 먼저 보내고, 인증 후 거래 영역만 lazy load 하는 식. 6MB는 그대로 받되 “받는 동안 화면이 비어 있느냐”가 달라진다.
마무리
이번 탐구에서 얻은 결론은 꽤 단순하다.
속도는 결국 아키텍처 선택의 결과물이다. 토스증권의 무거운 첫 진입은 거래 앱으로서 의도된 선택이고, 그 선택이 무너지는 환경(Slow 4G)이 따로 있다는 것까지 같이 봐야 공정한 진단이 된다.
도구는 많다. SSR 하이브리드, 라우트 레벨 코드 스플리팅, 동적 import, Streamable HTTP, HTTP/3, Early Hints 등. 그런데 어떤 걸 어디까지 쓸지는 조직의 리스크 허용치와 엔지니어링 우선순위에 달려 있다. 증권사는 특히 보수적일 수밖에 없다.
다만 사용자 입장에선 간단하다. 네트워크가 나쁠 때 네이버 증권을 켠다. 그게 내가 이 글을 쓰게 된 시작이었고, 결국 한 바퀴 돌아온 결론이다.
