WebSocket vs SSE vs 롱 폴링 | 실시간 통신 방식 비교
이 글의 핵심
실시간 웹에서 자주 쓰는 세 가지 패턴 — 양방향은 WebSocket, 서버 푸시 단방향은 SSE, 호환성 우선은 롱 폴링. 선택 기준과 구현 요점, WebSocket 프로토콜(RFC 6455) 핵심을 함께 정리합니다.
세 가지 패턴 비교
| 롱 폴링 | SSE | WebSocket | |
|---|---|---|---|
| 방향 | 서버 → 클라이언트 | 서버 → 클라이언트 | 양방향 |
| 프로토콜 | HTTP | HTTP | WS(HTTP 업그레이드) |
| 지연 | 높음(왕복) | 낮음 | 매우 낮음 |
| 복잡도 | 낮음 | 낮음 | 중간 |
| 자동 재연결 | 수동 | 브라우저(EventSource) | 수동(또는 라이브러리) |
| HTTP/2 | 예 | 예(멀티플렉싱) | 별도(업그레이드 후 WS 프레임) |
| 로드밸런서 | 단순 | 단순 | 스티키 세션 등 설계 필요 |
| 브라우저 | 넓음 | 넓음 | IE10+ 수준에서 넓음 |
표만 보면 WebSocket이 모든 면에서 앞서 보이지만, 실무에서 선택을 가르는 것은 지연 시간보다 운영 부담입니다. 롱 폴링과 SSE는 결국 평범한 HTTP 요청이라 기존의 인증 미들웨어, 로깅, CDN, 로드밸런서 설정을 그대로 씁니다. WebSocket은 연결이 업그레이드된 뒤부터는 HTTP가 아니므로 프록시의 업그레이드 헤더 전달, 유휴 타임아웃, 연결별 인증, 배포 시 연결 이전 같은 문제를 따로 풀어야 합니다. 그래서 “서버에서 클라이언트로만 보내면 되는가”를 먼저 묻고, 그렇다면 SSE를 기본으로 두는 것이 합리적인 출발점입니다.
롱 폴링
클라이언트가 요청을 보내면 서버가 데이터가 생기거나 타임아웃될 때까지 연결을 붙잡습니다. 응답을 받은 뒤 클라이언트는 곧바로 다시 요청합니다.
서버 (Node.js)
const waiters = new Map() // requestId → { res, timeout }
app.get('/api/updates', async (req, res) => {
const clientId = req.query.clientId
const lastEventId = parseInt(req.query.lastId ?? '0')
const pending = await getPendingEvents(clientId, lastEventId)
if (pending.length > 0) {
return res.json({ events: pending })
}
const timeout = setTimeout(() => {
waiters.delete(clientId)
res.json({ events: [] })
}, 30000)
waiters.set(clientId, { res, timeout })
req.on('close', () => {
clearTimeout(waiters.get(clientId)?.timeout)
waiters.delete(clientId)
})
})
function publishEvent(clientId, event) {
const waiter = waiters.get(clientId)
if (waiter) {
clearTimeout(waiter.timeout)
waiters.delete(clientId)
waiter.res.json({ events: [event] })
}
}
롱 폴링의 핵심은 “응답을 바로 보내지 않고 붙잡아 둔다”는 것입니다. 서버는 먼저 lastId 이후의 밀린 이벤트가 있는지 확인해 있으면 즉시 돌려주고, 없으면 응답 객체를 waiters에 보관했다가 이벤트가 생기거나 30초가 지나면 응답합니다. 30초 타임아웃을 두는 이유는 중간 프록시나 로드밸런서가 응답 없는 요청을 보통 60초 안팎에서 끊기 때문입니다. 그보다 짧게 빈 응답을 돌려줘야 클라이언트가 에러 없이 다음 요청으로 넘어갈 수 있습니다.
이 구현에는 실제 서비스에서 문제가 되는 부분이 두 가지 있습니다. 첫째, waiters가 clientId 하나당 응답 하나만 저장하므로 같은 사용자가 탭 두 개를 열면 두 번째 요청이 첫 번째를 덮어씁니다. 첫 번째 요청은 영영 이벤트를 받지 못하고, 더 나쁜 것은 첫 번째 요청의 타임아웃이 나중에 실행되면서 waiters.delete(clientId)로 두 번째 요청의 대기까지 지워 버린다는 점입니다. 요청마다 고유 ID를 붙여 clientId → Set<waiter> 구조로 관리해야 합니다. 둘째, publishEvent는 대기 중인 요청이 있을 때만 이벤트를 보내므로, 클라이언트가 응답을 받고 다음 요청을 보내기 전 짧은 틈에 생긴 이벤트는 사라집니다. getPendingEvents가 읽는 이벤트 저장소(DB나 Redis의 이벤트 로그)에 먼저 기록하고, 클라이언트가 lastId로 이어 받는 구조여야 유실이 없습니다. 서버를 여러 대로 늘리면 waiters가 프로세스 메모리에 있어서 다른 서버에 붙은 요청에는 알릴 수 없으므로, 여기서도 Redis Pub/Sub 같은 공유 계층이 필요해집니다.
클라이언트
async function poll(clientId, lastId = 0) {
try {
const res = await fetch(`/api/updates?clientId=${clientId}&lastId=${lastId}`)
const { events } = await res.json()
for (const event of events) {
handleEvent(event)
lastId = event.id
}
} catch (err) {
await new Promise(r => setTimeout(r, 2000))
}
poll(clientId, lastId)
}
클라이언트는 응답을 받자마자 다시 요청하고, 에러가 나면 2초 쉬고 다시 시도합니다. 마지막 줄의 재귀 호출은 await된 뒤에 실행되므로 호출 스택이 쌓이지 않습니다. 보강할 점은 두 가지입니다. res.ok를 확인하지 않아서 서버가 500 에러 HTML을 돌려주면 res.json()이 실패해 catch로 넘어가긴 하지만, 401처럼 재시도해도 소용없는 응답까지 2초마다 계속 반복합니다. 또 서버 장애 시 모든 클라이언트가 정확히 2초 간격으로 동시에 몰려오므로, 대기 시간을 점점 늘리고 무작위 지터를 더하는 편이 복구 중인 서버를 보호합니다.
쓰기 좋을 때: WebSocket/SSE를 쓰기 어려운 환경(구형 프록시, 내부 방화벽)에서 “거의 실시간”이 필요할 때. 이벤트가 몇 분에 한 번 수준으로 드물다면, 롱 폴링보다 30초~1분 간격의 일반 폴링이 서버 연결을 붙잡지 않아 더 단순하고 저렴할 수도 있습니다.
Server-Sent Events (SSE)
클라이언트에서 서버로 한 번 연결을 열고, 서버가 이벤트를 푸시합니다. 클라이언트→서버 데이터는 별도 요청이 필요합니다.
서버
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.write(':connected\n\n')
function sendEvent(event, data, id) {
if (id !== undefined) res.write(`id: ${id}\n`)
if (event) res.write(`event: ${event}\n`)
res.write(`data: ${JSON.stringify(data)}\n\n`)
}
sendEvent('connected', { clientId: req.query.clientId })
const subscription = eventBus.subscribe(req.query.clientId, (data) => {
sendEvent('update', data, data.id)
})
const heartbeat = setInterval(() => res.write(':heartbeat\n\n'), 15000)
req.on('close', () => {
subscription.unsubscribe()
clearInterval(heartbeat)
})
})
SSE의 메시지 형식은 단순한 텍스트입니다. 필드: 값 줄을 이어 쓰고, 빈 줄(\n\n)로 한 이벤트를 끝냅니다. data:는 내용, event:는 이벤트 이름(없으면 message), id:는 이벤트 ID이고, :로 시작하는 줄은 주석이라 클라이언트가 무시합니다. 하트비트에 주석 줄을 쓰는 이유가 이것입니다. 연결을 살려 두면서 클라이언트 코드에는 아무 이벤트도 발생시키지 않습니다. data에 줄바꿈이 들어가면 이벤트가 깨지므로 JSON.stringify로 한 줄로 만드는 것도 중요합니다.
헤더 중 X-Accel-Buffering: no는 Nginx가 응답을 버퍼에 모았다가 한꺼번에 보내지 않도록 하는 설정입니다. 이 헤더나 Nginx의 proxy_buffering off가 없으면 로컬에서는 잘 되던 스트림이 운영 환경에서 수십 초 동안 아무것도 오지 않다가 한 번에 쏟아지는 증상이 나타납니다. 제가 SSE를 처음 배포했을 때 겪은 문제가 정확히 이것이었고, 압축 미들웨어(compression)도 같은 방식으로 응답을 모으기 때문에 SSE 경로에서는 끄거나 이벤트마다 flush()를 호출해야 했습니다.
id:를 보내 두면 연결이 끊겼을 때 브라우저가 재연결 요청에 Last-Event-ID 헤더로 마지막으로 받은 ID를 실어 보냅니다. 서버가 이 헤더를 읽어 그 이후 이벤트부터 다시 보내면 끊긴 동안의 이벤트도 유실되지 않습니다. 위 예제는 ID를 보내기만 하고 재연결 시 읽지 않으므로, 유실이 중요하다면 req.headers['last-event-id']를 처리하는 부분을 추가해야 합니다. 재연결 대기 시간은 서버가 retry: 5000\n\n처럼 지정할 수 있습니다.
AI 스트리밍 예시
app.post('/api/chat', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
const stream = await openai.chat.completions.create({
model: 'gpt-4',
messages: req.body.messages,
stream: true,
})
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content
if (content) {
res.write(`data: ${JSON.stringify({ content })}\n\n`)
}
}
res.write('data: [DONE]\n\n')
res.end()
})
LLM 응답 스트리밍은 SSE가 가장 잘 맞는 사례입니다. 대부분의 LLM API 자체가 SSE로 토큰을 보내므로, 서버는 받은 조각을 같은 형식으로 브라우저에 중계하기만 하면 됩니다. 이 엔드포인트가 POST라는 점에 주의해야 합니다. 브라우저의 EventSource는 GET 요청만 보낼 수 있고 요청 본문이나 커스텀 헤더(Authorization 등)를 넣을 수 없습니다. 그래서 채팅처럼 메시지 기록을 본문으로 보내야 하는 경우에는 EventSource 대신 fetch로 요청하고 response.body.getReader()로 스트림을 읽으면서 data: 줄을 직접 파싱합니다. 이 경우 브라우저의 자동 재연결은 없습니다.
또 하나 놓치기 쉬운 것은 사용자가 중간에 페이지를 떠났을 때입니다. 위 코드는 클라이언트 연결이 끊겨도 for await 루프가 계속 돌면서 LLM 응답을 끝까지 받습니다. 토큰 비용은 끝까지 청구되고 결과는 아무도 받지 않습니다. req.on('close')에서 AbortController로 업스트림 요청을 취소해야 합니다. 에러 처리도 필요합니다. 스트리밍 도중 API 에러가 나면 이미 200 헤더를 보낸 뒤라 상태 코드로 알릴 수 없으므로, event: error 같은 별도 이벤트로 클라이언트에 알리고 연결을 닫는 방식을 씁니다.
클라이언트
const es = new EventSource('/api/events?clientId=user-123')
es.onopen = () => console.log('Connected')
es.onmessage = (e) => {
const data = JSON.parse(e.data)
console.log('Message:', data)
}
es.addEventListener('update', (e) => {
const data = JSON.parse(e.data)
updateUI(data)
})
es.onerror = () => {
console.log('Reconnecting...')
}
es.close()
onmessage는 event: 필드가 없는 이벤트만 받고, 서버가 event: update로 보낸 이벤트는 addEventListener('update', ...)로만 받을 수 있습니다. 이름 있는 이벤트를 onmessage로 기다리다가 아무것도 안 온다고 착각하는 경우가 많습니다. onerror는 연결이 끊길 때마다 호출되고, 이때 브라우저가 자동으로 재연결을 시도합니다. 다만 서버가 200이 아닌 상태 코드(401, 500 등)나 text/event-stream이 아닌 Content-Type으로 응답하면 재연결을 포기하고 readyState가 CLOSED가 되므로, 인증 만료 같은 상황은 직접 처리해야 합니다. 마지막 줄 es.close()는 연결을 끝내는 방법을 보여 주는 것이고, 실제로는 컴포넌트가 언마운트되거나 페이지를 떠날 때 호출합니다.
HTTP/1.1에서 SSE를 쓸 때 가장 흔한 함정은 브라우저의 도메인당 동시 연결 제한(약 6개)입니다. SSE 연결은 계속 열려 있으므로 사용자가 같은 사이트를 탭 여섯 개로 열면 연결 한도가 모두 차서 일반 API 요청과 이미지 로딩까지 멈춥니다. HTTP/2 이상에서는 하나의 연결에 여러 스트림을 싣기 때문에 이 제한이 사실상 사라집니다. 운영 환경에서 HTTP/2를 켜 두거나, BroadcastChannel로 탭 하나만 SSE를 열고 다른 탭에 이벤트를 나눠 주는 방식으로 해결합니다.
쓰기 좋을 때: 알림, 라이브 피드, 대시보드, 토큰 스트리밍처럼 서버→클라이언트 일방향이면 충분할 때.
WebSocket
전이중(full-duplex) — 양쪽이 언제든 메시지를 보낼 수 있습니다.
서버 (Node.js)
import { WebSocketServer } from 'ws'
import http from 'http'
const server = http.createServer(app)
const wss = new WebSocketServer({ server })
const clients = new Map()
wss.on('connection', (ws, req) => {
const clientId = getClientId(req)
clients.set(clientId, ws)
ws.on('message', (data) => {
const message = JSON.parse(data.toString())
handleMessage(clientId, message)
})
ws.on('close', () => clients.delete(clientId))
ws.on('error', () => clients.delete(clientId))
ws.send(JSON.stringify({ type: 'CONNECTED', clientId }))
})
WebSocketServer({ server })는 기존 HTTP 서버에 붙어서, Upgrade: websocket 헤더가 있는 요청만 가로채 WebSocket 연결로 바꿉니다. 같은 포트에서 REST API와 WebSocket을 함께 서비스할 수 있는 이유입니다. 연결이 맺어질 때 req로 원래 HTTP 요청을 받을 수 있으므로, 인증은 이 시점에 쿠키나 쿼리 문자열의 토큰을 검증해 처리합니다. 브라우저의 WebSocket API는 커스텀 헤더를 보낼 수 없어서 Authorization 헤더를 쓸 수 없다는 점이 SSE와 같은 제약입니다.
이 코드를 그대로 운영에 올리면 곧 만나는 문제가 있습니다. message 핸들러의 JSON.parse는 형식이 잘못된 메시지 하나에 예외를 던지고, 이 예외는 잡히지 않은 예외가 되어 Node 프로세스 전체를 종료시킵니다. 한 클라이언트의 잘못된 메시지로 모든 사용자의 연결이 끊기는 셈입니다. 파싱은 반드시 try/catch로 감싸고, 메시지 크기 상한(maxPayload 옵션)도 두어야 합니다. 또 clients가 clientId 하나당 소켓 하나만 저장하므로 같은 사용자의 두 번째 탭이 첫 번째를 덮어쓰고, 첫 번째 탭이 닫힐 때 close 핸들러가 두 번째 탭의 항목까지 지웁니다. 사용자당 Set으로 관리하는 편이 안전합니다. 끊긴 연결을 감지하는 하트비트도 빠져 있습니다. 네트워크가 갑자기 끊기면 close 이벤트가 오지 않아 죽은 소켓이 쌓이므로, ws 공식 문서의 예제처럼 주기적으로 ws.ping()을 보내고 pong이 오지 않은 연결을 terminate()하는 루프를 둡니다.
자동 재연결 예시
class ReliableWebSocket {
constructor(url) {
this.url = url
this.handlers = new Map()
this.reconnectDelay = 1000
this.connect()
}
connect() {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectDelay = 1000
this.emit('connected')
}
this.ws.onmessage = (e) => {
const data = JSON.parse(e.data)
this.emit(data.type, data)
}
this.ws.onclose = () => {
this.emit('disconnected')
setTimeout(() => this.connect(), this.reconnectDelay)
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000)
}
this.ws.onerror = (err) => console.error(err)
}
on(event, handler) {
this.handlers.set(event, handler)
return this
}
emit(event, data) {
this.handlers.get(event)?.(data)
}
send(type, data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...data }))
}
}
close() {
this.ws.onclose = null
this.ws.close()
}
}
WebSocket은 SSE와 달리 재연결을 브라우저가 해 주지 않으므로 이런 래퍼가 거의 필수입니다. 재연결 지연을 1초에서 시작해 두 배씩 늘리고 30초에서 멈추는 지수 백오프, 연결에 성공하면 지연을 초기화하는 부분, 의도적으로 닫을 때 onclose를 먼저 지워 재연결을 막는 부분이 핵심입니다.
실제 서비스에 쓰려면 몇 가지를 더 고려해야 합니다. 서버를 배포하면 모든 클라이언트가 동시에 끊기고 정확히 1초 뒤 한꺼번에 재연결을 시도하는데, 이 몰림이 새로 뜬 서버를 다시 쓰러뜨리기도 합니다. 지연에 Math.random()으로 무작위 값을 더하는 지터를 넣으면 재연결이 분산됩니다. 서버가 인증 실패로 종료 코드 1008(정책 위반)이나 4000번대 사용자 정의 코드로 닫았다면 재연결해도 계속 실패하므로 onclose의 event.code를 보고 재시도 여부를 정해야 합니다. send()는 연결이 열려 있지 않으면 메시지를 조용히 버리는데, 채팅처럼 유실되면 안 되는 메시지라면 큐에 모아 두었다가 onopen에서 보내고, 재연결 뒤에는 서버에 마지막으로 받은 메시지 ID를 알려 빠진 메시지를 받아 와야 합니다. handlers가 이벤트당 핸들러 하나만 저장한다는 점도 여러 컴포넌트가 같은 이벤트를 구독할 때 문제가 됩니다.
쓰기 좋을 때: 채팅, 협업 편집, 게임, 트레이딩 터미널 등 양방향이 필수일 때.
WebSocket 프로토콜 요약 (RFC 6455)
프레임워크가 대부분 가려 주지만, 운영 이슈(업그레이드 실패, 유휴 끊김, UTF-8 종료)를 좁히려면 다음 네 가지가 중요합니다.
업그레이드 핸드셰이크 (HTTP → WebSocket)
- 클라이언트는
GET에Upgrade: websocket,Connection: Upgrade,Sec-WebSocket-Version: 13,Sec-WebSocket-Key(16바이트 랜덤의 Base64)를 넣습니다. - 서버는
101 Switching Protocols로 응답하며,Sec-WebSocket-Accept = Base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))를 돌려줍니다. - 이후 같은 TCP 연결에는 HTTP 본문이 아니라 WebSocket 프레임이 흐릅니다. 역프록시는 업그레이드 헤더를 그대로 전달해야 합니다.
프레임 구조
프레임 헤더에는 FIN, opcode(텍스트/바이너리/제어), 페이로드 길이, 클라이언트→서버 마스크 비트 등이 있습니다. 큰 메시지는 여러 프레임으로 나뉠 수 있습니다.
텍스트(opcode 1) vs 바이너리(opcode 2)
- 텍스트 — UTF-8만 허용. 잘못된 인코딩은 연결 종료로 이어질 수 있음. JSON에 흔함.
- 바이너리 — 임의 바이트(Protobuf 등). 브라우저에서는
ArrayBuffer/Blob으로 수신.
하트비트: Ping/Pong vs 애플리케이션 JSON
- Ping(9) / Pong(10) — NAT·로드밸런서의 유휴 타임아웃을 넘기기 위한 제어 프레임.
- 앱 레벨
{"type":"ping"}도 널리 씁니다. Ping 주기는proxy_read_timeout·ALB 유휴 제한보다 짧아야 합니다.
Node·Socket.io·Redis까지 묶은 실무 흐름은 WebSocket 실시간 통신를 참고하세요.
WebSocket 확장
여러 대의 서버에 연결이 흩어지면 한 서버에만 붙어 있는 소켓으로는 다른 인스턴스의 클라이언트에게 바로 보낼 수 없습니다. Redis Pub/Sub 등으로 메시지를 브로드캐스트하는 패턴이 일반적입니다.
await sub.subscribe('messages', (message) => {
const { targetClientId, data } = JSON.parse(message)
const ws = localClients.get(targetClientId)
if (ws?.readyState === 1) ws.send(JSON.stringify(data))
})
이 구조에서는 모든 서버가 messages 채널을 구독하고, 메시지를 보내려는 서버는 Redis에 발행만 합니다. 각 서버는 받은 메시지 중 자기에게 붙은 클라이언트의 것만 전달합니다. Redis Pub/Sub은 발행 순간 구독 중이 아니던 서버에는 메시지를 남기지 않는(fire-and-forget) 방식이라, 서버가 재시작되는 동안의 메시지는 사라집니다. 유실이 허용되지 않는다면 Redis Streams나 메시지 큐를 쓰고 클라이언트 재연결 시 빠진 메시지를 다시 받아 오는 구조가 필요합니다. 로드밸런서 쪽에서는 WebSocket 업그레이드를 지원하는지, 유휴 타임아웃이 하트비트 주기보다 긴지 확인해야 하고, 롱 폴링이나 Socket.IO의 폴링 폴백을 쓴다면 같은 클라이언트의 요청이 같은 서버로 가도록 스티키 세션이 필요합니다.
선택 흐름도
양방향이 필요한가?
예 → WebSocket (또는 Socket.IO)
아니오 ↓
서버→클라이언트 스트리밍이 필요한가?
예 → SSE
아니오 ↓
장기 연결을 쓸 수 없는가?
예 → 롱 폴링
아니오 → 일반 HTTP(주기적 폴링)
특수 케이스:
LLM 스트리밍 → SSE
채팅 → WebSocket
라이브 알림 → SSE
협업 편집 → WebSocket
자주 묻는 질문 (FAQ)
Q. WebSocket 연결이 한동안 메시지가 없으면 끊기는 이유는 무엇인가요?
A. 중간의 NAT, 로드밸런서, 리버스 프록시가 일정 시간 트래픽이 없는 연결을 유휴 상태로 보고 끊기 때문입니다. Ping/Pong 제어 프레임이나 애플리케이션 수준의 ping 메시지를 주기적으로 보내 연결을 유지하며, 이 주기는 Nginx의 proxy_read_timeout이나 ALB 유휴 타임아웃보다 짧아야 합니다. 그래도 네트워크 전환 등으로 끊기는 경우는 생기므로 클라이언트에 자동 재연결 로직을 함께 두는 것이 좋습니다.