const-tommy.dev
기록을 불러오는 중입니다
웹의 기본 통신인 HTTP는 단순한 약속 위에 서 있다. 클라이언트가 요청하면 서버가 응답하고, 응답이 끝나면 연결을 닫는다. 다음 요청은 또 새로운 연결이다. 그래서 HTTP를 두고 상태를 기억하지 않는 무상태(stateless) 프로토콜이라 부른다.
이 구조는 대부분의 웹 페이지에 잘 맞는다. 페이지를 열고, 데이터를 받고, 끝. 문제는 서버에 새로운 일이 생겼을 때 그걸 클라이언트에게 먼저 알려줄 방법이 없다는 것이다. HTTP의 세계에서 서버는 누가 묻기 전엔 입을 열 수 없다. 새 채팅 메시지가 도착해도, 주문 상태가 바뀌어도, 서버는 클라이언트가 다시 물어봐 줄 때까지 그 사실을 전할 수 없다.
실시간이 중요해진 현대 웹 — 채팅, 라이브 대시보드, 협업 편집 — 에서 이 한계는 치명적이다. 그래서 개발자들은 두 갈래로 움직였다. 하나는 HTTP를 그대로 둔 채 영리하게 우회하는 길(폴링, 롱폴링, SSE)이고, 다른 하나는 아예 새로운 통신 방식으로 넘어가는 길(WebSocket)이다.
서버가 먼저 못 알려준다면, 클라이언트가 계속 물어보면 된다. 이것이 폴링(Polling) 이다. 일정 간격마다 "새 소식 있어?"를 반복해서 묻는 것. 이메일을 몇 분마다 새로고침하는 것과 같다.
폴링의 매력은 단순함이다. 기존 REST API에 타이머 하나만 얹으면 끝이라, 프로토타입이나 MVP에서 "일단 동작하게" 만들기에 좋다. 익숙한 HTTP 도구를 그대로 쓴다는 점도 진입 장벽을 낮춘다.
대가는 낭비다. 대부분의 요청이 "없어"라는 빈 응답으로 돌아온다. 그런데도 매 요청마다 수백 바이트의 HTTP 헤더가 오간다. 갱신 주기를 짧게 하면 실시간성은 올라가지만 서버 부하가 폭증하고, 길게 하면 부하는 줄지만 새 소식이 늦게 도착한다. 실시간성과 효율 사이에서 어느 쪽도 만족시키지 못하는, 본질적인 트레이드오프에 갇혀 있는 셈이다.
setInterval로 일정 간격마다 fetch를 반복하는 커스텀 훅으로 구현한다. 대부분의 응답이 "없음"으로 돌아온다는 점에 주목하자.
import { useState, useEffect } from 'react';
function usePolling(url, interval = 3000) {
const [data, setData] = useState(null);
useEffect(() => {
const id = setInterval(async () => {
const res = await fetch(url); // 매번 새 연결 + 헤더 오버헤드
const json = await res.json();
if (json.hasNew) setData(json); // 대부분은 "없음"으로 돌아온다
}, interval);
return () => clearInterval(id); // 언마운트 시 정리
}, [url, interval]);
return data;
}
// 사용
function Notifications() {
const data = usePolling('/api/messages', 3000);
return <div>{data ? \`새 메시지: \${data.count}건\` : '대기 중...'}</div>;
}그래서 폴링은 몇 초의 지연이 허용되고, 구현 단순함이 무엇보다 중요할 때 여전히 합리적인 선택이다. "그냥 동작한다"는 이유로 실무에서 아직도 많이 쓰인다.
참고
폴링의 빈 응답이 아깝다면, 서버가 새 소식이 생길 때까지 응답을 미루면 어떨까. 이것이 롱폴링(Long Polling) 이다. 클라이언트가 요청을 보내면, 서버는 줄 게 있을 때까지 연결을 열어둔 채 기다린다. 새 데이터가 생기면 그제야 응답하고, 클라이언트는 응답을 받자마자 곧바로 다음 요청을 보내 다시 기다린다.
핵심은 서버가 의미 있는 말이 생겼을 때만 응답한다는 것이다. 이로써 폴링의 "없어" 응답이 사라진다. WebSocket이 널리 지원되기 전, 초기 페이스북 메신저 같은 실시간 서비스가 이 방식으로 동작했다.
하지만 롱폴링도 결국 HTTP 요청을 반복하는 구조라는 한계를 벗지 못한다. 응답을 받을 때마다 새 연결을 맺어야 하고, 매번 HTTP 헤더 오버헤드가 따라붙는다. 연결이 영원히 열려 있을 수는 없어서 타임아웃도 관리해야 한다. 진짜 스트림이 아니라 "끊고 다시 잇는 루프"에 더 가깝다. 더 나은 우회였지만, 우회는 우회였다.
응답을 받자마자 곧바로 다음 요청을 보내는 while 루프가 핵심이다. 폴링과 달리 빈 응답이 없다.
import { useState, useEffect } from 'react';
function useLongPolling(url) {
const [data, setData] = useState(null);
useEffect(() => {
let active = true;
const poll
참고
폴링과 롱폴링이 "요청을 반복하는" 한계 안에 있었다면, SSE(Server-Sent Events) 는 발상을 바꾼다. 클라이언트가 한 번 연결을 열면, 서버는 그 하나의 연결로 이벤트를 계속 흘려보낸다. 클라이언트는 매번 다시 묻지 않고, 열린 통로로 들어오는 데이터를 받기만 하면 된다.
SSE의 결정적 성격은 단방향이라는 데 있다. 서버에서 클라이언트로만 흐른다. 그래서 알림, 로그 스트림, 라이브 피드, 작업 진행 상황, 그리고 요즘 가장 익숙한 LLM의 토큰 스트리밍처럼 "서버가 일방적으로 밀어주면 되는" 상황에 딱 맞는다. AI에게 질문을 던지면 답변이 한 글자씩 흘러나오는 그 경험이, 전형적인 SSE다.
기술적으로도 영리하다. SSE는 별도 프로토콜이 아니라 일반 HTTP 위에서 동작한다. 그래서 기존 인프라와 잘 어울리고, 브라우저의 EventSource API가 연결이 끊기면 자동으로 재연결까지 해준다. 이 자동 재연결은 뒤에 나올 WebSocket에는 없는, 직접 구현해야 하는 기능이다.
new EventSource(url)로 연결을 한 번 열면, onmessage로 서버가 흘려보내는 이벤트를 받기만 하면 된다. 연결이 끊겨도 브라우저가 알아서 다시 잇는다.
import { useState, useEffect } from 'react';
function useSSE(url) {
const [events, setEvents] = useState([]);
useEffect(() => {
const source = new EventSource(url); // 연결은 단 한 번
인증이 필요하다면? 브라우저 내장
EventSource는 커스텀 헤더를 붙일 수 없다. 토큰을 쿠키(httpOnly)에 두면 연결 시 자동으로 실려 가장 깔끔하고, 토큰을 메모리에 들고 있어Authorization헤더로 보내야 한다면@microsoft/fetch-event-source같은 fetch 기반 라이브러리로 우회한다. 토큰을 쿼리스트링에 싣는 방식은 url 로그에 노출되므로 피하는 게 좋다.
한때 SSE의 약점으로 꼽히던 것이 HTTP/1.1의 "출처당 6개 연결 제한"이었다. SSE 연결 하나가 그 자리를 차지해버리는 문제다. 하지만 HTTP/2가 보편화되면서 이 제약은 대부분 사라졌고, 2026년 현재 SSE는 성숙하고 폭넓게 지원되는 기술로 자리 잡았다. 서버에서 브라우저로 흐르는 단방향 스트림이라면, SSE가 가장 단순하면서도 견고한 기본값이다.
참고
SSE까지도 결국 "서버가 보내고 클라이언트는 받기만 하는" 단방향이었다. 그런데 채팅, 멀티플레이 게임, 협업 편집처럼 양쪽이 수시로, 동시에 말을 주고받아야 한다면? 이때 등장하는 것이 WebSocket이다.
WebSocket은 HTTP 요청으로 시작해 한 번 핸드셰이크를 거친 뒤, 연결을 전이중(full-duplex) 양방향 채널로 업그레이드한다. 그 순간부터 클라이언트와 서버는 하나의 영구 연결 위에서 언제든 먼저 메시지를 보낼 수 있다. 더 이상 "요청-응답"의 차례를 기다리지 않는다.
WebSocket의 강점은 가장 낮은 지연과 오버헤드다. 연결을 맺은 뒤로는 프레임당 단 2바이트의 오버헤드만으로 메시지를 주고받는다(SSE는 메시지당 약 5바이트, 폴링은 매번 수백 바이트의 헤더). 그래서 실시간 채팅, 입력 중 표시, 협업 커서, 게임처럼 빈번하고 즉각적인 양방향 통신에 적합하다.
useRef로 소켓 인스턴스를 보관하고, onmessage로 서버가 먼저 push한 메시지를 받는 동시에 send로 클라이언트도 언제든 전송한다. 이 양방향성이 핵심이다.
import { useState, useEffect, useRef, useCallback } from 'react';
function useWebSocket(url) {
const ws = useRef(null);
const [messages, setMessages] = useState([]);
useEffect(() =>
면접에서 자주 갈리는 지점이 바로 여기다. "퀘스트 완료 → 보상 지급"처럼 요청-응답으로 충분한 것은 WebSocket이 필요 없다. WebSocket이 진짜 필요한 건, 내가 요청하지 않았는데도 서버가 능동적으로 보내야 하고, 그게 양방향일 때다. 여러 명이 같은 문서를 편집하면, 다른 사람의 변경이 내 화면에 실시간으로 반영돼야 한다. 내가 요청하지 않았는데 서버가 "누가 이걸 바꿨다"고 밀어줘야 하는 것 — 이것이 WebSocket의 자리다.
대가도 분명하다. WebSocket은 별도 프로토콜이라 SSE의 자동 재연결 같은 편의가 없어서, 연결 끊김·재연결·하트비트(연결 유지를 위한 주기적 핑)를 직접 관리해야 한다. 그래서 많은 팀이 Socket.IO 같은 라이브러리로 이 복잡성을 감춘다. 또한 2026년 기준으로도 브라우저 WebSocket은 백프레셔(backpressure) 를 제공하지 않는다. 즉 받는 쪽이 처리 속도를 못 따라갈 때를 대비한 큐잉과 느린 소비자 보호를 애플리케이션 레벨에서 직접 해줘야 한다.
참고
네 가지를 한 줄로 압축하면 이렇다. 폴링은 클라이언트가 반복해서 묻고, 롱폴링은 서버가 답을 미뤘다가 주고, SSE는 서버가 하나의 연결로 흘려보내고, WebSocket은 양쪽이 영구 채널로 동시에 주고받는다.
선택의 기준은 결국 두 가지 질문으로 수렴한다. 통신이 단방향인가 양방향인가, 그리고 서버가 먼저 말을 걸어야 하는가.
| 구분 | 폴링 | 롱폴링 | SSE | WebSocket |
|---|---|---|---|---|
| 방향 | 클라→서버 반복 | 클라→서버 (보류) | 서버→클라 단방향 | 양방향 |
| 프로토콜 | HTTP | HTTP | HTTP | WS (별도) |
| 연결 | 매번 새로 | 매번 새로 | 하나 유지 | 하나 유지 |
| 재연결 | 해당 없음 | 수동 | 자동 (EventSource) | 수동 (직접 구현) |
| 오버헤드 | 높음 (헤더 매번) | 중간 | 낮음 (~5B/msg) | 가장 낮음 (~2B/frame) |
| 복잡도 | 단순 | 중간 | 중간 | 높음 |
| 적합한 곳 | 프로토타입, 저빈도 갱신 | 레거시 실시간 | 알림·피드·스트리밍 | 채팅·게임·협업 |
기억할 것은 하나다. 더 새롭거나 더 복잡한 기술이 더 나은 것이 아니다. WebSocket이 가장 강력하다고 해서 모든 실시간 기능에 WebSocket을 쓰는 것은, 단방향이면 충분한 곳에 양방향 채널을 깔고 그 복잡성(재연결·하트비트·백프레셔)을 떠안는 과설계다. 문제가 요구하는 가장 단순한 방식에서 출발해, 그 한계가 분명해질 때 다음 단계로 올라가는 것 — 실시간 통신을 다루는 일의 본질은 거기에 있다.
참고
한 줄 요약
방향과 주도권을 생각하자. 서버만 말하면 SSE, 둘 다 말하면 WebSocket, 가끔 물어도 되면 폴링. 복잡도는 기본값이 아니라 필요가 증명할 때 더한다.