1 왜 두 프로토콜을 알아야 하는가
서버→클라이언트로 데이터를 실시간으로 보내야 할 때 두 가지 선택지가 있다.
| 프로토콜 | 한 줄 요약 |
|---|---|
| SSE (Server-Sent Events) | HTTP 위에 얹은 단방향 스트리밍 — 서버→클라이언트만 |
| WebSocket | TCP 위 별도 프로토콜의 양방향 채널 — 양쪽 다 보낼 수 있음 |
겉보기에 WebSocket이 더 강력해 보이지만 워크로드에 따라 SSE가 더 단순·안정·저렴한 경우가 많다. MINERVA 04편 SSE 스트리밍이 SSE를 선택한 것이 정확히 그 사례 — LLM 토큰을 서버가 일방향으로 흘려보내기만 하므로 SSE가 적합.
본 글은 두 프로토콜의 차이·기초 사용법·결정 트리를 정리한다. SSE 자체의 깊이 있는 사용법은 SSE — 실시간 스트리밍의 가벼운 선택지에서 다루므로 본 글은 결정 근거에 집중한다.
2 핵심 차이 — 한 표로
- SSE: HTTP 응답을 끝내지 않고
data: ...\n\n형식으로 서버가 클라이언트에 메시지를 계속 보내는 표준 (text/event-stream) - WebSocket: HTTP로 핸드셰이크 후 별도 프로토콜로 업그레이드해 양쪽 모두 메시지를 보낼 수 있는 양방향 채널 (
ws://또는wss://)
| 기준 | SSE | WebSocket |
|---|---|---|
| 통신 방향 | 서버 → 클라이언트 (단방향) | 양방향 (둘 다 보낼 수 있음) |
| 프로토콜 | HTTP/1.1 또는 HTTP/2 | HTTP 핸드셰이크 후 별도 프로토콜 (ws:///wss://) |
| 클라이언트 API | EventSource (모든 모던 브라우저 표준) |
WebSocket (모든 모던 브라우저 표준) |
| 자동 재연결 | 브라우저가 자동으로 처리 | 직접 구현 필요 |
| 메시지 형식 | 텍스트만 (data: 라인) |
텍스트 + 바이너리 (Blob, ArrayBuffer) |
| 헤더·인증 | 일반 HTTP 헤더 그대로 사용 | 핸드셰이크 시점에만 헤더, 이후 별도 |
| 프록시·방화벽 | 일반 HTTP라서 통과 잘됨 | 일부 환경에서 차단 가능 |
| HTTP/2 multiplexing | 지원 (한 연결로 여러 SSE) | 별도 (HTTP/2 over WebSocket은 비표준) |
| 서버 구현 복잡도 | 낮음 (HTTP 응답 그대로) | 중간 (별도 라이브러리, 핸드셰이크) |
| 클라이언트 구현 복잡도 | 매우 낮음 (EventSource 한 줄) | 중간 (open/close/error 모두 처리) |
| 한 연결당 동시 메시지 | 줄 단위 순차 | 양쪽 동시 가능 |
3 SSE — HTTP 위 단방향
# FastAPI 서버
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
@app.get("/stream")
async def stream():
async def event_generator():
for i in range(10):
yield f"data: message {i}\n\n"
await asyncio.sleep(1)
return StreamingResponse(event_generator(), media_type="text/event-stream")// 브라우저 클라이언트
const source = new EventSource("/stream");
source.onmessage = (event) => {
console.log("Received:", event.data);
};
source.onerror = () => {
// 자동 재연결 — 브라우저가 알아서 처리
};핵심: HTTP 응답을 영원히 끝내지 않고 yield로 한 줄씩 보낸다. 클라이언트는 EventSource 한 줄. 자세한 구현은 기존 SSE 글 참조.
4 WebSocket — 양방향 채널
# FastAPI 서버
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() # 연결 수락
try:
while True:
# 클라이언트로부터 메시지 받기 (await — async generator 다음 단계)
data = await websocket.receive_text()
# 서버가 클라이언트에 응답
await websocket.send_text(f"echo: {data}")
except WebSocketDisconnect:
print("Client disconnected")// 브라우저 클라이언트
const ws = new WebSocket("ws://localhost:8000/ws");
ws.onopen = () => {
ws.send("Hello server"); // 클라이언트가 서버에 보낼 수 있음
};
ws.onmessage = (event) => {
console.log("Received:", event.data);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
ws.onclose = () => {
// 자동 재연결 안 됨 — 직접 setTimeout 등으로 재시도 구현
console.log("Disconnected");
// setTimeout(() => reconnect(), 1000);
};핵심: accept() 후 receive_text()/send_text()로 양방향. 재연결은 직접 구현해야 한다.
5 결정 트리
5.1 양방향이 필요한가?
사용자가 서버에 자주 메시지를 보내야 하는가?
├── Yes → WebSocket
└── No → SSE (또는 polling)
Yes 사례: - 채팅 — 사용자가 메시지를 자주 입력 - 실시간 협업 (Google Docs류) — 양쪽이 변경을 보내고 받음 - 게임 — 사용자 입력 + 서버 상태 양방향 - 화상 회의 시그널링
No 사례 (SSE 유리): - LLM 토큰 스트리밍 — MINERVA 04편 - 주식 시세·실시간 모니터링 대시보드 — 서버가 주기적으로 보냄 - 알림·이벤트 피드 — 서버 발생 이벤트를 클라가 받기만 - 진행률 표시 — 작업 진행 상태를 서버가 알림
5.2 일반 HTTP 환경 친화성
프록시·방화벽·로드밸런서가 까다로운 환경인가?
├── Yes → SSE (일반 HTTP 그대로 통과)
└── No → 둘 다 가능
기업 네트워크·일부 CDN은 WebSocket을 차단하거나 별도 설정이 필요. SSE는 일반 HTTPS면 그대로 동작 — 사내 환경 배포가 단순.
5.3 인증·세션
인증을 자주 갱신해야 하나? cookie 기반인가?
├── Cookie/Header 기반, 토큰 갱신 필요 → SSE
└── 핸드셰이크 시점 인증만으로 충분 → WebSocket
SSE는 매 요청마다 일반 HTTP 헤더가 동작하므로 cookie·Authorization 헤더가 자연스럽게 따라간다. WebSocket은 핸드셰이크 시점에만 헤더가 적용되고 이후 메시지에는 별도 메커니즘(메시지 안에 토큰 포함 등)이 필요.
5.4 메시지 형식 차이
바이너리(Blob, 이미지, 파일)를 직접 보내야 하나?
├── Yes → WebSocket
└── 텍스트만 (JSON·SSE) → SSE
SSE는 텍스트만. 바이너리를 보내려면 base64 인코딩 (오버헤드 33%). WebSocket은 바이너리 네이티브 지원.
5.5 서버 자원 효율
한 서버가 동시에 N개 long-lived 연결을 유지할 수 있나?
├── 둘 다 long-lived 연결 — 자원 상황 고려 필수
└── HTTP/2 multiplexing 활용하려면 SSE 유리
둘 다 연결을 오래 유지한다. HTTP/2 위 SSE는 한 TCP 연결로 여러 SSE 스트림 multiplexing이 가능 — 자원 효율 우수. WebSocket은 연결 1개당 별도.
6 MINERVA가 SSE를 선택한 근거
MINERVA 04편·08-1편이 SSE를 사용하는 이유를 결정 트리로 매핑하면:
| 결정 기준 | MINERVA 답 | 선택 |
|---|---|---|
| 양방향 필요? | No — LLM 토큰을 서버가 일방향으로 흘려보냄 | SSE |
| HTTP 환경 친화? | 사내 Azure 환경, CDN 통과 단순화 | SSE |
| 인증 방식? | API 토큰을 매 요청 헤더로 전달 | SSE (헤더 자연 통과) |
| 메시지 형식? | JSON 텍스트만 ({"type":"token","text":"..."}) |
SSE |
| 자원 효율? | HTTP/2 multiplexing으로 한 연결에 여러 스트림 | SSE |
SSE 선택의 트레이드오프: - 양방향이 필요해진다면(예: 사용자가 LLM 응답 중간에 “stop” 신호) WebSocket 또는 별도 cancel 엔드포인트로 우회 필요 - 현재 MINERVA는 cancel을 별도 HTTP DELETE 엔드포인트로 구현 — 단방향 SSE를 깨지 않고 양방향 효과만 추가
7 WebSocket이 더 적합한 시나리오
같은 LLM Agent 도메인에도 WebSocket이 더 자연스러운 경우가 있다.
| 시나리오 | 이유 |
|---|---|
| 멀티 사용자 실시간 협업 (canvas·문서) | 양쪽 다 자주 변경 송신 |
| 음성·비디오 통화 시그널링 (WebRTC + signaling) | 양쪽이 SDP·ICE 후보를 주고받음 |
| 라이브 게임 | 사용자 입력 빈도 높고 latency 중요 |
| Agent의 ReAct 루프에 사용자 개입(HITL) | 사용자가 중간에 추가 정보를 주입 |
| 실시간 streaming + 사용자 input 동시 | 한 연결로 둘 다 |
MINERVA가 미래에 16편 Checkpointing+HITL 패턴을 본격 도입할 때 — 사용자가 중간 단계에서 승인·수정을 자주 한다면 — WebSocket으로 전환할 수도 있다.
8 운영·배포 고려사항
8.1 Azure Container Apps / AWS ALB
| 환경 | SSE | WebSocket |
|---|---|---|
| Azure Container Apps | 기본 지원 | 별도 설정 (transport: http2 등) |
| AWS ALB | HTTP/2 활성화 시 | sticky session 권장 |
| Cloudflare | 기본 통과 | 가끔 차단 (websocket: on 필요) |
| 일반 nginx | 기본 | proxy_set_header Upgrade 추가 |
MINERVA 07-0편 프로덕션 배포에서 nginx Reverse Proxy는 WebSocket 사용 시 다음 설정이 추가된다:
location /ws {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400; # WebSocket idle timeout
}SSE는 일반 HTTP 설정 그대로.
8.2 Heartbeat·Idle timeout
| 항목 | SSE | WebSocket |
|---|---|---|
| Idle 연결 끊김 방지 | 30초마다 : 주석 줄 보내기 |
ping/pong 프레임 자동 |
| 연결 사망 감지 | EventSource가 자동 재연결 시도 | pong timeout으로 직접 감지 |
# SSE — 30초마다 keepalive 줄
async def event_generator():
while True:
if await has_data():
yield f"data: {get_data()}\n\n"
else:
yield ": keepalive\n\n" # 주석 줄 (클라이언트 무시)
await asyncio.sleep(30)# WebSocket — ping/pong (FastAPI/Starlette는 기본 제공)
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
try:
data = await asyncio.wait_for(websocket.receive_text(), timeout=60)
await websocket.send_text(f"echo: {data}")
except asyncio.TimeoutError:
await websocket.send_text("ping") # 살아 있는지 확인9 자주 발생하는 오류 패턴
@app.get("/stream")
async def stream():
return StreamingResponse(generator(), media_type="text/event-stream")
# CORS 미설정 → 브라우저가 EventSource 차단CORRECT:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)SSE는 일반 HTTP 응답이므로 CORS가 적용된다. WebSocket은 CORS가 적용되지 않지만 origin 헤더 검증을 직접 해야 한다.
CORRECT:
function connect() {
const ws = new WebSocket("ws://...");
ws.onclose = () => {
console.log("disconnected, reconnecting...");
setTimeout(connect, 1000); // 직접 재연결
};
ws.onerror = (e) => ws.close();
}
connect();WebSocket은 EventSource와 달리 자동 재연결이 없다. 직접 재시도 로직 필요. exponential backoff (1s → 2s → 4s)이 권장.
CORRECT:
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx 버퍼링 비활성화
},
)SSE 응답이 nginx·일부 CDN에서 버퍼링되어 클라이언트에 즉시 도달 안 할 수 있다. X-Accel-Buffering: no 헤더로 우회.
CORRECT:
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "text" in message:
text = message["text"]
elif "bytes" in message:
binary = message["bytes"]WebSocket은 텍스트/바이너리 둘 다. 클라이언트가 어떤 형식으로 보낼지 확실하지 않으면 receive()로 받아 분기.
10 정리
| 영역 | SSE | WebSocket |
|---|---|---|
| 방향 | 서버 → 클라 | 양방향 |
| HTTP 호환 | 그대로 | 핸드셰이크 후 업그레이드 |
| 자동 재연결 | 브라우저 자동 | 직접 구현 |
| 메시지 형식 | 텍스트만 | 텍스트 + 바이너리 |
| 인증 | 일반 HTTP 헤더 | 핸드셰이크 시점만 |
| 운영 (proxy/CDN) | 잘 통과 | 추가 설정 필요 |
| 구현 복잡도 | 낮음 | 중간 |
결정 트리 한 줄 요약: “사용자가 자주 보낼 메시지가 없고 일반 HTTP 인프라 그대로 쓰고 싶다면 SSE, 그렇지 않으면 WebSocket”.
11 응용 분야
| 시나리오 | 추천 | 이유 |
|---|---|---|
| LLM 토큰 스트리밍 (MINERVA) | SSE | 단방향 + HTTP 친화 |
| 모니터링 대시보드 (시계열 push) | SSE | 단방향 + 자동 재연결 |
| 진행률 표시 | SSE | 단방향 |
| 채팅 (실시간 typing 표시 포함) | WebSocket | 양방향 |
| 실시간 협업 (canvas, 문서) | WebSocket | 양방향 |
| 라이브 게임 | WebSocket | 양방향 + 저지연 |
| HITL 멀티턴 (사용자 중간 개입) | WebSocket 또는 SSE+별도 cancel API | 양방향 필요성 |
12 관련 주제
선행 학습
- API 기초 – HTTP 프로토콜 토대
- FastAPI 입문 – 라우터·StreamingResponse·WebSocket
- Python async/await 실전 패턴 – async generator + WebSocket 모두 의존
바로 이어 읽을 글
- SSE 깊이 사용 – 본 글 결정 후 SSE 선택 시 깊은 사용법
MINERVA 시리즈 응용
- MINERVA FastAPI 서빙 (04) – StreamingResponse + SSE 라우터
- MINERVA 스트리밍·관측성 (08-1) – SSE event_generator 구현
- MINERVA Checkpointing+HITL (16) – 미래에 WebSocket 전환을 검토할 시점