로딩 스피너는 아무런 정보도 주지 않습니다. AI 작업이 몇 분씩 걸리거나, 세 번째 재시도를 위해 다시 큐로 돌아가는 상황이라면 현재 상태를 확인할 수 있어야 합니다. Server-Sent Events(SSE)를 사용하면 WebSocket의 핸드셰이크 오버헤드나 롱 폴링(long polling)의 복잡한 조율 없이도 이러한 가시성을 확보할 수 있습니다. 서버는 단일 HTTP 응답을 열어둔 상태로 유지하며, 상황이 변할 때마다 평문(plain-text) 업데이트를 푸시합니다. 클라이언트는 데이터가 도착하는 대로 이를 읽습니다.
연결이 끊겼을 때 처음부터 다시 시작하고 싶지는 않을 것입니다. 잘 설계된 SSE 스트림은 중단된 지점을 기억합니다. Node.js 20과 표준 라이브러리만으로도 이를 구현할 수 있으며, 별도의 외부 패키지는 필요하지 않습니다.
와이어 포맷(wire format)의 모습
SSE 메시지는 단순한 텍스트입니다. 서버는 세 가지를 작성합니다. 선택 사항인 이벤트 이름, 필수 항목인 data 필드, 그리고 저장 지점(save point) 역할을 하는 id 필드입니다. 각 레코드는 두 개의 줄바꿈 문자로 끝납니다. 즉, 경계를 나타내는 빈 줄이 하나 포함됩니다.
정상적인 스트림의 와이어 포맷은 다음과 같습니다.
id: 14
event: status
data: {"phase":"testing","progress":43}
id: 15
event: status
data: {"phase":"retrying","attempt":2}
브라우저의 EventSource 클라이언트는 이 라인들을 자동으로 읽습니다. 각 블록에 대해 이벤트를 발생시키고 최신 id를 내부적으로 저장합니다. TCP 연결이 불안정해지면 클라이언트는 대기했다가 다시 연결을 시도하며, 저장된 식별자를 Last-Event-ID 헤더에 담아 서버로 다시 보냅니다. 이 헤더가 바로 이 패턴이 작동하는 핵심 이유입니다. 이 헤더가 없다면 지속 가능한 커서(durable cursor)를 가질 수 없습니다.
Node.js에서 서버 연결하기
Node의 내장 http 모듈로 이를 직접 처리할 수 있습니다. 요청이 들어오면 클라이언트가 이것이 페이지가 아닌 스트림임을 알 수 있도록 올바른 헤더를 설정하십시오.
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
버퍼링을 제거하십시오. 프록시나 프레임워크는 때때로 응답을 배치(batch) 처리하는데, 이는 실시간성을 저해하므로 매 청크(chunk)마다 flush를 호출해야 합니다.
ID를 먼저 보내고, 그다음 이벤트 유형, 페이로드 데이터, 마지막으로 종료를 알리는 빈 줄을 보냅니다. 순서가 중요한 이유는 클라이언트가 ID를 캡처할 수 있도록 빈 줄이 오기 전에 ID가 반드시 도착해야 하기 때문입니다. 네이티브 response.write()를 사용하는 경우 출력은 다음과 같습니다.
response.write(`id: ${cursor}\n`);
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
끝에 붙는 \n\n은 장식이 아닙니다. SSE 파서는 이를 레코드 종료 문자로 취급합니다. 이를 빠뜨리면 클라이언트는 추가 데이터를 기다리며 멈춰 있게 됩니다.
커서가 전부입니다
새로운 HTTP 연결이 새로운 상태를 보장하지는 않습니다. 클라이언트가 재연결할 때, Last-Event-ID 헤더는 클라이언트가 마지막으로 받은 메시지를 알려줍니다. 여러분의 역할은 처음부터가 아니라 그다음 메시지부터 재개하는 것입니다.
이는 서버 측에서 이벤트의 순서가 보장된 로그나 저널을 유지해야 함을 의미합니다. 데모용으로는 인메모리 배열로 충분합니다. 하지만 프로덕션 환경에서는 데이터베이스 로그, Redis 스트림 또는 Write-Ahead Journal(WAL)과 같이 지속 가능한 저장소를 사용해야 합니다. 서버가 재시작되어도 히스토리가 삭제되어 모든 클라이언트가 처음부터 다시 시작해야 하는 상황이 발생해서는 안 되기 때문입니다.
이벤트를 단조 증가하는 정수나 ULID로 인덱싱하십시오. 재연결 요청이 들어오면 id > lastEventId인 이벤트를 쿼리하여 순서대로 다시 재생(replay)합니다. 백로그된 메시지가 수백 개라면 약간의 인위적인 지연을 두거나 배치를 사용할 수 있지만, 클라이언트가 시간 순서대로 상태를 재구축할 수 있도록 가장 오래된 것부터 먼저 보내야 합니다.
중복 발생을 고려하십시오
네트워크는 신뢰할 수 없습니다. 서버가 이벤트를 보낸 후 TCP 확인 응답(acknowledgment)을 받지 못하면, 타임아웃 후에 동일한 이벤트를 다시 보낼 수 있습니다. 처음부터 '최소 한 번 전달(at-least-once delivery)'을 보장하도록 설계하십시오.
클라이언트 측에서 중복 제거는 간단합니다. 이벤트 ID를 키로 하는 Map을 유지하십시오. 새 이벤트가 도착하면 맵을 확인합니다. ID가 이미 존재한다면 중복된 데이터이므로 조용히 버립니다. 서버가 결정론적(deterministic)인 ID를 할당하기 때문에 중복이 발생해도 해롭지 않습니다. 맵이 무한정 커질 필요는 없습니다. 이벤트가 안전하게 처리되었음을 확인하면 오래된 ID를 제거하십시오. 브라우저 클라이언트의 경우 수백 개의 항목을 유지하는 슬라이딩 윈도우(sliding window) 방식이면 충분합니다.
커서가 만료될 때
결국 클라이언트는 몇 시간 또는 며칠 후에 재연결될 수 있습니다. 히스토리 버퍼가 마지막 천 개의 이벤트만 포함하고 있는데 클라이언트가 이천 개나 뒤처져 있다면, 누락된 구간을 다시 재생하는 것은 불가능합니다.
부분적인 히스토리를 스트리밍하지 마십시오. 이는 클라이언트를 불일치 상태로 만듭니다. 대신, 만료된 커서를 감지하면 다음 이벤트로 전체 스냅샷(full snapshot)을 보내십시오. 스냅샷에는 클라이언트를 현재 상태에 고정할 수 있는 새로운 커서가 포함되어야 합니다. 그 이후부터는 실시간 델타(delta)를 정상적으로 재개합니다. 클라이언트 코드가 데이터를 추가(append)하는 대신 로컬 모델을 언제 리셋해야 하는지 알 수 있도록, 프로토콜에 이 경계를 명확히 문서화하십시오.
스트림 보호하기
공개된 SSE 엔드포인트는 매력적인 공격 대상입니다. 누구나 연결을 유지할 수 있으며, 재전송 요청(replay requests)을 통해 스토리지의 읽기 부하를 증폭시킬 수 있습니다.
엔드포인트에 적절한 권한 부여를 적용하세요. 브라우저의 EventSource는 커스텀 헤더를 지원하지 않으므로, 토큰을 쿼리 스트링으로 전달하거나 엄격한 SameSite 정책이 적용된 쿠키를 사용하세요. 스트림 리소스를 할당하기 전에 토큰을 검증해야 합니다.
히스토리 제한과 사용자별 할당량(quota)을 설정하세요. 태스크당 저장되는 이벤트 수를 제한하고, 클라이언트당 동시 연결 수를 제한하세요. 연결 끊김과 재전송(replay)을 로그로 남겨, 커서 엔드포인트를 과도하게 호출하는 비정상적인 클라이언트를 식별할 수 있도록 하세요.
이 패턴은 어디든 적용됩니다
이 접근 방식은 HTTP에만 국한되지 않습니다. WebSockets, 메시지 큐 또는 에이전트 간(agent-to-agent) 인터페이스로 전환하더라도 동일한 규칙이 적용됩니다. 전송 방식은 달라질 수 있습니다(예: 바이너리 프레임이나 토픽 구독 사용). 하지만 근본적인 문제는 동일합니다. 커서, 내구성 있는 로그(durable log), 최소 한 번 전달(at-least-once) 의미론, 클라이언트 중복 제거, 그리고 커서가 만료되었을 때 전체 스냅샷으로 전환하는 폴백(fallback) 기능이 필요합니다. 상태 수렴(state convergence) 문제를 한 번 해결해 두면, 핵심 로직을 재설계할 필요 없이 TCP, WebSocket 또는 RabbitMQ와 같은 브로커를 통해 전송할 수 있습니다.
단순함을 유지하세요
Server-Sent Events가 잘 작동하는 이유는 일반적인 HTTP를 기반으로 하기 때문입니다. 프록시가 이를 이해하고, 로드 밸런서가 상태 확인(health-check)을 할 수 있으며, curl만큼 디버깅이 쉽습니다. 하지만 예외 케이스(edge cases)를 무시하면 이러한 단순함은 사라집니다. 커서를 구축하고, 재전송을 예상하며, 클라이언트에서 중복을 제거하고, 히스토리가 소진되면 스냅샷을 찍으세요. 그렇게 하면 불안정한 Wi-Fi, 서버 재시작, 가끔 발생하는 브라우저의 야간 절전 모드 상황에서도 장시간 실행되는 AI 태스크가 진행 상황을 정확하게 보고할 것입니다.
출처: Build a Reconnecting SSE Task Stream with Node.js
토론 참여하기: GyaanSetu AI Community
