이미지 로딩 중...
AI Generated
2025. 11. 12. · 5 Views
바닥부터 만드는 ChatGPT 25편 Server-Sent Events 실시간 스트리밍
ChatGPT처럼 실시간으로 텍스트를 스트리밍하는 Server-Sent Events(SSE)를 완벽하게 이해하고 구현하는 방법을 배웁니다. 백엔드부터 프론트엔드까지 실전 코드와 함께 단계별로 학습합니다.
목차
- Server-Sent Events란 무엇인가 - 실시간 데이터 전송의 핵심
- Node.js로 SSE 서버 구현하기 - 기본 스트리밍 엔드포인트
- ChatGPT 스타일 스트리밍 구현 - OpenAI API와 SSE 통합
- 클라이언트에서 스트리밍 텍스트 렌더링하기 - React로 타이핑 효과 구현
- 에러 처리와 재연결 로직 - 안정적인 SSE 구현
- SSE와 WebSocket 비교 - 올바른 기술 선택하기
- 진행 상황 표시하기 - 퍼센티지와 단계별 업데이트
- 인증과 보안 - SSE 연결 보호하기
- 스케일링과 성능 최적화 - 다중 서버 환경에서의 SSE
- 테스트와 디버깅 - SSE 개발 효율 높이기
1. Server-Sent Events란 무엇인가 - 실시간 데이터 전송의 핵심
시작하며
여러분이 ChatGPT를 사용할 때 글자가 한 글자씩 타이핑되듯이 나타나는 것을 본 적 있나요? 사용자가 질문을 입력하면, 답변이 한 번에 뜨는 게 아니라 마치 사람이 타이핑하는 것처럼 실시간으로 나타납니다.
이런 실시간 스트리밍 기능을 구현하려면 서버에서 클라이언트로 계속해서 데이터를 전송할 수 있는 방법이 필요합니다. 기존의 HTTP 요청-응답 방식으로는 이런 실시간 스트리밍을 구현하기 어렵습니다.
매번 새로운 요청을 보내야 하고, 연결이 끊어지면 다시 연결해야 하는 번거로움이 있죠. 바로 이럴 때 필요한 것이 Server-Sent Events(SSE)입니다.
SSE는 서버에서 클라이언트로 실시간으로 데이터를 푸시할 수 있게 해주며, ChatGPT와 같은 스트리밍 AI 응답을 구현하는 데 완벽한 솔루션입니다.
개요
간단히 말해서, Server-Sent Events는 서버에서 클라이언트로 단방향 실시간 데이터 스트림을 제공하는 웹 기술입니다. SSE가 필요한 이유는 실시간 업데이트가 필요한 애플리케이션을 효율적으로 구현하기 위함입니다.
ChatGPT 같은 AI 챗봇, 실시간 뉴스 피드, 주식 가격 업데이트, 진행 상황 표시 등 서버에서 클라이언트로 지속적으로 데이터를 전송해야 하는 경우에 매우 유용합니다. 기존에는 폴링(Polling) 방식으로 주기적으로 서버에 요청을 보내 데이터를 확인했다면, SSE를 사용하면 하나의 연결을 유지하면서 서버가 준비될 때마다 자동으로 데이터를 전송받을 수 있습니다.
SSE의 핵심 특징은 첫째, HTTP 프로토콜을 기반으로 하여 기존 인프라를 그대로 활용할 수 있다는 점입니다. 둘째, 자동 재연결 기능이 내장되어 있어 네트워크 장애 시에도 안정적입니다.
셋째, 텍스트 기반 프로토콜이라 디버깅이 쉽고 구현이 간단합니다. 이러한 특징들이 SSE를 실시간 데이터 전송의 표준으로 만들어주고 있습니다.
코드 예제
// 클라이언트에서 SSE 연결 생성
const eventSource = new EventSource('/api/stream');
// 서버로부터 메시지를 받을 때마다 실행
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('받은 데이터:', data);
// UI 업데이트
document.getElementById('output').textContent += data.text;
};
// 연결 에러 처리
eventSource.onerror = (error) => {
console.error('SSE 연결 에러:', error);
eventSource.close();
};
설명
이것이 하는 일은 서버와의 지속적인 연결을 통해 서버가 데이터를 준비하는 대로 클라이언트에게 자동으로 전송하는 것입니다. 첫 번째로, new EventSource('/api/stream')으로 서버와의 연결을 생성합니다.
이 한 줄의 코드가 서버로 HTTP 요청을 보내고, 서버는 이 연결을 유지한 채로 데이터를 전송할 준비를 합니다. 일반적인 HTTP 요청과 달리, 이 연결은 바로 닫히지 않고 계속 열린 상태로 유지됩니다.
이렇게 하는 이유는 서버가 언제든지 새로운 데이터를 보낼 수 있도록 하기 위함입니다. 그 다음으로, onmessage 이벤트 핸들러가 설정되면서 서버로부터 데이터가 도착할 때마다 자동으로 실행됩니다.
서버가 데이터를 보낼 때마다 이 함수가 호출되며, event.data에 담긴 실제 데이터를 파싱하여 사용할 수 있습니다. JSON 형식으로 받은 데이터를 파싱하고, 이를 화면에 표시하거나 상태를 업데이트하는 등의 작업을 수행합니다.
마지막으로, onerror 핸들러가 네트워크 문제나 서버 에러가 발생했을 때 실행되어 안정적인 에러 처리를 제공합니다. 에러가 발생하면 연결을 정리하고 필요한 경우 재연결을 시도할 수 있습니다.
이는 실제 프로덕션 환경에서 매우 중요한 안정성 요소입니다. 여러분이 이 코드를 사용하면 서버에서 실시간으로 전송하는 모든 데이터를 자동으로 받아서 처리할 수 있습니다.
폴링처럼 주기적으로 요청을 보낼 필요가 없어 네트워크 트래픽이 줄어들고, WebSocket처럼 복잡한 설정 없이도 간단하게 실시간 기능을 구현할 수 있습니다. 특히 서버에서 클라이언트로만 데이터를 보내는 경우에는 WebSocket보다 훨씬 효율적입니다.
실전 팁
💡 EventSource는 자동으로 재연결을 시도하므로, 일시적인 네트워크 문제가 발생해도 사용자 경험이 크게 저하되지 않습니다. 하지만 명시적으로 연결을 종료하려면 반드시 eventSource.close()를 호출해야 메모리 누수를 방지할 수 있습니다.
💡 SSE는 GET 요청만 지원하므로, 인증 토큰은 URL 쿼리 파라미터나 쿠키를 통해 전달해야 합니다. 민감한 토큰을 URL에 노출하고 싶지 않다면 HttpOnly 쿠키를 사용하는 것이 보안상 더 안전합니다.
💡 브라우저는 도메인당 최대 6개의 SSE 연결만 허용하므로, 여러 탭에서 동시에 연결하면 문제가 발생할 수 있습니다. 개발 시에는 연결을 적절히 관리하고 불필요한 연결은 즉시 종료하세요.
💡 Chrome DevTools의 Network 탭에서 EventStream 타입으로 필터링하면 SSE 통신을 실시간으로 모니터링할 수 있어 디버깅이 매우 쉬워집니다.
💡 모바일 환경에서는 백그라운드로 전환 시 연결이 끊길 수 있으므로, Visibility API를 사용하여 포그라운드로 돌아올 때 재연결 로직을 구현하면 사용자 경험이 향상됩니다.
2. Node.js로 SSE 서버 구현하기 - 기본 스트리밍 엔드포인트
시작하며
여러분이 실시간 알림 시스템을 만들어야 하는데, 매 초마다 클라이언트가 서버에 "새로운 알림 있나요?"라고 물어보는 방식으로 구현한 적 있나요? 서버 부하도 높아지고, 실시간성도 떨어지는 비효율적인 상황이 발생합니다.
이런 폴링 방식의 문제는 불필요한 네트워크 요청이 반복되고, 서버 리소스가 낭비되며, 실제 데이터가 없을 때도 계속 요청을 보내야 한다는 점입니다. 사용자가 많아질수록 서버 부하는 기하급수적으로 증가합니다.
바로 이럴 때 필요한 것이 SSE 서버 구현입니다. 한 번의 연결로 서버가 준비될 때마다 자동으로 데이터를 전송할 수 있어, 효율적이고 실시간성이 뛰어난 시스템을 만들 수 있습니다.
개요
간단히 말해서, SSE 서버는 클라이언트와의 연결을 유지하면서 데이터가 준비될 때마다 특정 형식으로 응답을 전송하는 HTTP 엔드포인트입니다. SSE 서버가 필요한 이유는 클라이언트에게 실시간으로 업데이트를 푸시할 수 있는 효율적인 방법을 제공하기 위함입니다.
실시간 대시보드, 진행 상황 추적, AI 응답 스트리밍 같은 경우에 매우 유용하며, 서버 측에서 데이터 전송 타이밍을 완전히 제어할 수 있습니다. 기존에는 Long Polling으로 연결을 오래 유지하거나 WebSocket으로 양방향 통신을 구현했다면, SSE를 사용하면 표준 HTTP 프로토콜 위에서 간단하게 단방향 스트리밍을 구현할 수 있습니다.
SSE 서버의 핵심 특징은 첫째, Content-Type을 'text/event-stream'으로 설정하여 브라우저가 SSE로 인식하게 합니다. 둘째, 각 메시지는 "data: " 접두사로 시작하고 두 개의 개행 문자(\n\n)로 구분됩니다.
셋째, 연결을 계속 유지하면서 원하는 시점에 데이터를 전송할 수 있습니다. 이러한 특징들이 SSE를 구현하기 쉽고 디버깅하기 편한 기술로 만들어줍니다.
코드 예제
// Express.js로 SSE 엔드포인트 구현
app.get('/api/stream', (req, res) => {
// SSE를 위한 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 즉시 연결 확인 메시지 전송
res.write('data: {"status": "connected"}\n\n');
// 1초마다 현재 시간 전송
const intervalId = setInterval(() => {
const data = { time: new Date().toISOString() };
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
// 클라이언트 연결 종료 시 정리
req.on('close', () => {
clearInterval(intervalId);
});
});
설명
이것이 하는 일은 클라이언트의 요청을 받으면 연결을 유지한 채로 데이터를 계속해서 전송하는 것입니다. 첫 번째로, 세 개의 중요한 HTTP 헤더를 설정합니다.
Content-Type: text/event-stream은 브라우저에게 이것이 SSE 스트림임을 알려주며, 브라우저는 이 헤더를 보고 EventSource로 처리합니다. Cache-Control: no-cache는 프록시나 브라우저가 응답을 캐싱하지 못하게 하여 항상 실시간 데이터를 받도록 보장합니다.
Connection: keep-alive는 연결을 계속 유지하라는 명시적인 지시입니다. 그 다음으로, res.write()를 사용하여 연결을 닫지 않고 데이터를 전송합니다.
일반적인 res.json()이나 res.send()는 응답을 보내고 연결을 닫지만, res.write()는 연결을 유지한 채로 부분적인 응답을 보낼 수 있습니다. 메시지 형식은 반드시 "data: "로 시작하고 "\n\n"(두 개의 개행)으로 끝나야 합니다.
이 형식을 지키지 않으면 클라이언트가 메시지를 인식하지 못합니다. setInterval을 사용하여 주기적으로 데이터를 전송하는 예시를 보여주고 있지만, 실제로는 데이터베이스 변경, 외부 API 응답, 파일 처리 진행 상황 등 어떤 이벤트에도 반응하여 데이터를 전송할 수 있습니다.
중요한 것은 연결이 유지되는 동안 언제든지 res.write()를 호출할 수 있다는 점입니다. 마지막으로, 클라이언트가 연결을 종료할 때(req.on('close')) 타이머를 정리합니다.
이는 메모리 누수를 방지하는 매우 중요한 단계입니다. 클라이언트가 페이지를 닫거나 eventSource.close()를 호출하면 이 이벤트가 발생하며, 서버는 더 이상 필요 없는 리소스를 정리해야 합니다.
여러분이 이 코드를 사용하면 실시간으로 데이터를 클라이언트에게 푸시할 수 있는 강력한 엔드포인트를 만들 수 있습니다. 폴링처럼 클라이언트가 계속 요청을 보낼 필요가 없어 서버 부하가 크게 줄어들고, 데이터가 준비되는 즉시 전송할 수 있어 실시간성이 보장됩니다.
실전 팁
💡 SSE 연결은 장시간 유지되므로, 프록시나 로드 밸런서의 타임아웃 설정을 확인해야 합니다. Nginx의 경우 proxy_read_timeout을 충분히 길게 설정하고, 주기적으로 더미 메시지(heartbeat)를 보내 연결이 끊기지 않도록 하세요.
💡 클라이언트가 연결을 끊었는지 확인하려면 res.write()의 반환값을 체크하거나 'close' 이벤트를 사용하세요. 연결이 끊긴 상태에서 계속 write를 시도하면 메모리 누수와 성능 저하가 발생합니다.
💡 개발 환경에서는 res.flushHeaders()를 명시적으로 호출하여 헤더를 즉시 전송하면 연결이 더 빨리 확립되어 디버깅이 쉬워집니다.
💡 여러 클라이언트에게 동시에 메시지를 브로드캐스트하려면 활성 연결(response 객체)을 배열이나 Set에 저장해두고, 이벤트 발생 시 모든 연결에 write하는 패턴을 사용하세요.
💡 에러가 발생했을 때는 표준 'error' 이벤트 형식(event: error\ndata: ...)을 사용하여 클라이언트가 적절히 처리할 수 있도록 하세요.
3. ChatGPT 스타일 스트리밍 구현 - OpenAI API와 SSE 통합
시작하며
여러분이 AI 챗봇을 만들 때 사용자가 질문을 하고 한참을 기다린 후에야 답변이 한 번에 나타나는 경험을 제공한 적 있나요? 사용자는 AI가 작동하고 있는지, 얼마나 기다려야 하는지 알 수 없어서 답답함을 느낍니다.
이런 문제는 특히 GPT-4 같은 대형 모델을 사용할 때 심각합니다. 답변 생성에 10초 이상 걸릴 수 있고, 사용자는 그동안 아무런 피드백을 받지 못해 불안해하거나 페이지를 떠나기도 합니다.
이는 사용자 경험을 크게 저하시키는 요인입니다. 바로 이럴 때 필요한 것이 ChatGPT 스타일의 스트리밍 응답입니다.
OpenAI API의 스트리밍 모드와 SSE를 결합하면, 생성되는 즉시 단어 하나하나를 사용자에게 보여줄 수 있어 훨씬 자연스럽고 매력적인 경험을 제공합니다.
개요
간단히 말해서, ChatGPT 스타일 스트리밍은 OpenAI API의 스트리밍 응답을 받아서 SSE를 통해 클라이언트에게 실시간으로 전달하는 아키텍처입니다. 이 방식이 필요한 이유는 사용자에게 즉각적인 피드백을 제공하여 대기 시간을 체감적으로 줄이고, 마치 실제 사람과 대화하는 듯한 자연스러운 경험을 만들기 위함입니다.
AI 챗봇, 코드 생성 도구, 글쓰기 도우미 등 생성형 AI를 사용하는 모든 애플리케이션에서 필수적인 기능입니다. 기존에는 OpenAI API 응답이 완전히 완료될 때까지 기다렸다가 한 번에 표시했다면, 스트리밍을 사용하면 토큰이 생성되는 순간마다 즉시 사용자에게 보여줄 수 있습니다.
이 패턴의 핵심 특징은 첫째, OpenAI API에 stream: true 옵션을 설정하여 스트리밍 모드를 활성화합니다. 둘째, API로부터 받은 각 청크(chunk)를 파싱하여 실제 텍스트 내용을 추출합니다.
셋째, 추출한 내용을 즉시 SSE 형식으로 클라이언트에게 전달합니다. 이러한 특징들이 실시간 AI 응답 경험을 가능하게 만듭니다.
코드 예제
// OpenAI 스트리밍 + SSE 통합
app.post('/api/chat/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// OpenAI API 스트리밍 호출
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": true}\n\n');
res.end();
} catch (error) {
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
}
});
설명
이것이 하는 일은 OpenAI API로부터 스트리밍 응답을 받아서 실시간으로 클라이언트에게 중계하는 것입니다. 첫 번째로, OpenAI API를 호출할 때 stream: true 옵션을 설정합니다.
이 옵션은 OpenAI 서버에게 완성된 응답을 한 번에 보내지 말고, 토큰이 생성되는 대로 실시간으로 전송하라고 지시합니다. 일반 모드에서는 전체 응답이 완성될 때까지 기다려야 하지만, 스트리밍 모드에서는 첫 토큰이 생성되자마자 받기 시작합니다.
이렇게 하면 초기 응답 시간이 크게 단축되어 사용자 경험이 향상됩니다. 그 다음으로, for await...of 루프를 사용하여 스트림의 각 청크를 비동기적으로 처리합니다.
OpenAI API는 Server-Sent Events 형식으로 응답을 보내며, JavaScript의 비동기 이터레이터가 이를 자동으로 파싱합니다. 각 청크에는 choices[0].delta.content에 새로 생성된 텍스트 조각이 들어 있습니다.
이 내용을 추출하여 즉시 클라이언트에게 전달함으로써, AI가 생각하는 과정을 실시간으로 보여줄 수 있습니다. 각 청크를 받을 때마다 res.write()로 SSE 형식에 맞게 데이터를 전송합니다.
JSON 형식으로 감싸서 보내면 클라이언트에서 파싱하기 쉽고, 필요한 경우 메타데이터를 함께 전송할 수도 있습니다. 빈 문자열은 전송하지 않도록 체크하여 불필요한 네트워크 트래픽을 줄입니다.
마지막으로, 스트리밍이 완료되면 {"done": true} 신호를 보내고 res.end()로 연결을 종료합니다. 이 완료 신호는 클라이언트가 로딩 상태를 종료하고 UI를 업데이트하는 데 사용됩니다.
에러가 발생하면 에러 정보를 SSE 형식으로 전송하여 클라이언트가 적절히 처리할 수 있도록 합니다. 여러분이 이 코드를 사용하면 ChatGPT와 똑같은 타이핑 효과를 구현할 수 있습니다.
사용자는 즉각적인 피드백을 받아 대기 시간이 짧게 느껴지고, 긴 응답도 스트리밍으로 보면서 기다릴 수 있어 이탈률이 크게 감소합니다. 또한 응답이 생성되는 중간에 사용자가 취소할 수 있는 기능도 쉽게 추가할 수 있습니다.
실전 팁
💡 OpenAI API 스트리밍은 토큰 단위로 전송되므로, 한글의 경우 음절이 깨져서 올 수 있습니다. 클라이언트에서 버퍼링하여 완성된 문자만 표시하거나, 서버에서 TextDecoder를 사용하여 올바르게 디코딩하세요.
💡 사용자가 중간에 생성을 취소할 수 있도록 하려면, 클라이언트에서 연결을 끊을 때 OpenAI API 스트림도 함께 중단(abort)해야 불필요한 토큰 비용이 발생하지 않습니다.
💡 여러 사용자의 요청을 동시에 처리할 때는 각 스트림을 독립적으로 관리해야 합니다. 응답 객체를 혼동하지 않도록 클로저나 비동기 컨텍스트를 활용하세요.
💡 OpenAI API 호출 실패 시 자동 재시도 로직을 구현하되, 스트리밍이 이미 시작된 경우에는 재시도하지 말고 에러를 클라이언트에게 전달하여 사용자가 재요청하도록 하세요.
💡 프로덕션 환경에서는 API 키를 환경 변수로 관리하고, 사용자별 요청 제한(rate limiting)을 구현하여 API 비용을 통제하세요.
4. 클라이언트에서 스트리밍 텍스트 렌더링하기 - React로 타이핑 효과 구현
시작하며
여러분이 서버로부터 스트리밍 데이터를 잘 받고 있는데, 화면에 표시하려니 텍스트가 깜빡이거나 매끄럽게 나타나지 않는 경험을 한 적 있나요? 매번 새로운 청크가 올 때마다 전체 컴포넌트가 리렌더링되어 성능이 저하되고, 사용자 경험이 어색해집니다.
이런 문제는 React의 상태 업데이트와 렌더링 메커니즘을 제대로 이해하지 못해서 발생합니다. 잘못 구현하면 불필요한 리렌더링이 발생하고, 스크롤 위치가 틀어지며, 메모리 사용량도 증가합니다.
바로 이럴 때 필요한 것이 효율적인 스트리밍 텍스트 렌더링 패턴입니다. React의 상태 관리와 라이프사이클을 올바르게 활용하면, 부드럽고 자연스러운 타이핑 효과를 구현할 수 있습니다.
개요
간단히 말해서, 스트리밍 텍스트 렌더링은 SSE로 받은 텍스트 조각들을 누적하여 화면에 부드럽게 표시하는 클라이언트 측 구현입니다. 이 패턴이 필요한 이유는 실시간으로 업데이트되는 텍스트를 사용자에게 자연스럽게 보여주면서도, 성능을 최적화하고 메모리를 효율적으로 관리하기 위함입니다.
AI 챗봇의 응답, 실시간 로그 스트리밍, 진행 상황 메시지 등에 활용됩니다. 기존에는 전체 응답을 받아서 한 번에 표시했다면, 스트리밍 렌더링을 사용하면 각 청크를 받을 때마다 기존 텍스트에 추가하여 마치 타이핑하는 것처럼 보여줄 수 있습니다.
이 패턴의 핵심 특징은 첫째, useState로 누적된 텍스트를 관리하여 각 청크를 기존 내용에 추가합니다. 둘째, useEffect로 컴포넌트 마운트 시 SSE 연결을 생성하고 언마운트 시 정리합니다.
셋째, 상태 업데이트 시 이전 상태를 참조하는 함수형 업데이트를 사용하여 동시성 이슈를 방지합니다. 이러한 특징들이 안정적이고 부드러운 스트리밍 UI를 만들어줍니다.
코드 예제
// React로 스트리밍 텍스트 렌더링
import { useState, useEffect } from 'react';
function ChatMessage({ messageId }) {
const [content, setContent] = useState('');
const [isStreaming, setIsStreaming] = useState(true);
useEffect(() => {
const eventSource = new EventSource(`/api/chat/stream/${messageId}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.done) {
setIsStreaming(false);
eventSource.close();
} else if (data.content) {
// 함수형 업데이트로 이전 내용에 추가
setContent(prev => prev + data.content);
}
};
eventSource.onerror = () => {
setIsStreaming(false);
eventSource.close();
};
// 클린업: 컴포넌트 언마운트 시 연결 종료
return () => eventSource.close();
}, [messageId]);
return (
<div className="message">
<p>{content}</p>
{isStreaming && <span className="cursor">▊</span>}
</div>
);
}
설명
이것이 하는 일은 서버로부터 받은 텍스트 조각들을 하나씩 화면에 추가하면서 자연스러운 타이핑 애니메이션을 만드는 것입니다. 첫 번째로, 두 개의 상태를 선언합니다.
content는 지금까지 받은 모든 텍스트를 저장하고, isStreaming은 현재 스트리밍이 진행 중인지를 나타냅니다. 이 두 상태를 분리하는 이유는 스트리밍이 완료된 후에도 텍스트는 유지되어야 하지만, 로딩 인디케이터(커서)는 사라져야 하기 때문입니다.
이렇게 상태를 적절히 분리하면 UI 로직이 명확해지고 관리가 쉬워집니다. 그 다음으로, useEffect 내부에서 EventSource를 생성하고 이벤트 핸들러를 등록합니다.
useEffect를 사용하는 이유는 컴포넌트가 마운트될 때 한 번만 연결을 생성하고, 언마운트될 때 자동으로 정리하기 위함입니다. 의존성 배열에 messageId를 추가하여, 메시지가 바뀌면 이전 연결을 끊고 새 연결을 생성합니다.
이는 메모리 누수를 방지하는 중요한 패턴입니다. onmessage 핸들러에서 가장 중요한 부분은 setContent(prev => prev + data.content) 입니다.
단순히 setContent(content + data.content)로 작성하면 안 됩니다. 그 이유는 상태 업데이트가 비동기적으로 일어나기 때문에, 여러 청크가 빠르게 도착하면 일부를 놓칠 수 있습니다.
함수형 업데이트를 사용하면 React가 항상 최신 상태를 기반으로 업데이트하므로 데이터 손실이 없습니다. data.done이 true일 때는 스트리밍이 완료된 것이므로 isStreaming을 false로 설정하고 연결을 닫습니다.
이 신호는 서버에서 보내는 것으로, 클라이언트가 더 이상 데이터를 기다리지 않아도 된다는 것을 알려줍니다. 커서 애니메이션이 사라지고 사용자는 응답이 완성되었음을 알 수 있습니다.
마지막으로, useEffect의 클린업 함수에서 eventSource.close()를 호출합니다. 이는 컴포넌트가 언마운트되거나 messageId가 변경될 때 실행되며, 불필요한 연결을 정리하여 메모리 누수를 방지합니다.
사용자가 다른 페이지로 이동하거나 컴포넌트가 사라질 때 연결이 자동으로 종료되므로 안전합니다. 여러분이 이 코드를 사용하면 ChatGPT와 똑같은 부드러운 타이핑 효과를 만들 수 있습니다.
텍스트가 한 글자씩 나타나면서 사용자는 AI가 실시간으로 응답을 생성하고 있다는 느낌을 받게 됩니다. 또한 React의 최적화 기법을 활용하여 성능 저하 없이 구현할 수 있습니다.
실전 팁
💡 긴 텍스트를 스트리밍할 때는 자동 스크롤 기능을 추가하세요. useRef로 메시지 끝 요소를 참조하고, content가 업데이트될 때마다 scrollIntoView()를 호출하면 사용자가 항상 최신 내용을 볼 수 있습니다.
💡 성능 최적화를 위해 React.memo()로 컴포넌트를 감싸고, 부모 컴포넌트의 불필요한 리렌더링이 전파되지 않도록 하세요. 특히 채팅 목록에서 여러 메시지를 렌더링할 때 중요합니다.
💡 네트워크 에러나 타임아웃을 처리하기 위해 에러 상태를 추가하고, 사용자에게 재시도 버튼을 제공하면 사용자 경험이 향상됩니다.
💡 타이핑 커서 애니메이션은 CSS animation으로 깜빡이게 만들면 더 사실적입니다. @keyframes blink를 정의하여 0.5초마다 투명도를 토글하세요.
💡 접근성을 위해 aria-live="polite" 속성을 추가하면 스크린 리더가 업데이트되는 내용을 사용자에게 읽어줍니다.
5. 에러 처리와 재연결 로직 - 안정적인 SSE 구현
시작하며
여러분이 완벽하게 작동하는 스트리밍 기능을 만들었는데, 사용자가 지하철에서 터널을 지나가거나 Wi-Fi가 불안정한 환경에서 사용할 때 연결이 끊어지는 상황을 경험한 적 있나요? 연결이 끊어지면 스트리밍이 멈추고, 사용자는 새로고침을 해야 하는 불편함을 겪습니다.
이런 문제는 네트워크가 불안정한 모바일 환경에서 특히 심각합니다. 일시적인 네트워크 장애, 서버 재시작, 로드 밸런서 타임아웃 등 다양한 원인으로 연결이 끊어질 수 있으며, 이를 처리하지 않으면 사용자 경험이 크게 저하됩니다.
바로 이럴 때 필요한 것이 강력한 에러 처리와 재연결 로직입니다. 네트워크 문제를 자동으로 감지하고 복구하여, 사용자가 아무것도 하지 않아도 서비스가 계속 작동하도록 만들 수 있습니다.
개요
간단히 말해서, SSE 에러 처리는 연결 실패, 타임아웃, 서버 에러 등의 상황을 감지하고 적절히 대응하는 로직입니다. 이 패턴이 필요한 이유는 실제 프로덕션 환경에서는 항상 완벽한 네트워크 연결을 기대할 수 없기 때문입니다.
모바일 앱, 글로벌 서비스, 장시간 실행되는 애플리케이션에서는 네트워크 장애가 불가피하며, 이를 우아하게 처리하는 것이 서비스 품질을 결정합니다. 기존에는 연결이 끊어지면 사용자가 수동으로 새로고침해야 했다면, 재연결 로직을 구현하면 자동으로 복구를 시도하고 성공하면 스트리밍을 이어갈 수 있습니다.
이 패턴의 핵심 특징은 첫째, 다양한 에러 유형을 구분하여 적절한 대응을 합니다(네트워크 에러, 서버 에러, 인증 에러 등). 둘째, 지수 백오프(Exponential Backoff) 전략으로 재연결 간격을 점진적으로 늘려 서버 부하를 줄입니다.
셋째, 최대 재시도 횟수를 설정하여 무한 루프를 방지합니다. 이러한 특징들이 안정적이고 효율적인 SSE 연결을 보장합니다.
코드 예제
// 재연결 로직이 포함된 SSE 클라이언트
import { useState, useEffect, useRef } from 'react';
function useSSEWithRetry(url, maxRetries = 5) {
const [data, setData] = useState('');
const [status, setStatus] = useState('connecting');
const retryCount = useRef(0);
const eventSourceRef = useRef(null);
const connect = () => {
setStatus('connecting');
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => {
setStatus('connected');
retryCount.current = 0; // 연결 성공 시 카운터 리셋
};
es.onmessage = (event) => {
const parsed = JSON.parse(event.data);
setData(prev => prev + parsed.content);
};
es.onerror = () => {
es.close();
setStatus('error');
// 지수 백오프로 재연결 시도
if (retryCount.current < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, retryCount.current), 30000);
retryCount.current++;
setTimeout(() => {
console.log(`재연결 시도 ${retryCount.current}/${maxRetries}`);
connect();
}, delay);
} else {
setStatus('failed');
}
};
};
useEffect(() => {
connect();
return () => eventSourceRef.current?.close();
}, [url]);
return { data, status };
}
설명
이것이 하는 일은 네트워크 문제가 발생했을 때 자동으로 복구를 시도하면서도, 서버에 과부하를 주지 않도록 지능적으로 재연결을 관리하는 것입니다. 첫 번째로, useRef를 사용하여 재시도 횟수와 EventSource 인스턴스를 저장합니다.
useState가 아닌 useRef를 사용하는 이유는 이 값들이 변경되어도 컴포넌트를 리렌더링할 필요가 없고, 리렌더링 간에도 값이 유지되어야 하기 때문입니다. retryCount는 몇 번 재시도했는지 추적하고, eventSourceRef는 연결을 명시적으로 종료할 수 있도록 참조를 보관합니다.
그 다음으로, onopen 핸들러에서 연결이 성공하면 재시도 카운터를 0으로 리셋합니다. 이는 매우 중요한 부분인데, 만약 리셋하지 않으면 나중에 다시 연결이 끊어졌을 때 이전의 재시도 횟수가 누적되어 즉시 최대 재시도에 도달할 수 있습니다.
연결이 성공했다는 것은 네트워크가 정상화되었다는 의미이므로, 카운터를 리셋하여 다시 처음부터 재시도할 수 있게 합니다. onerror 핸들러에서 가장 핵심적인 재연결 로직이 실행됩니다.
먼저 현재 연결을 닫고, 재시도 횟수가 최대값보다 작은지 확인합니다. 지수 백오프 공식 Math.pow(2, retryCount.current)를 사용하여 1초, 2초, 4초, 8초, 16초...
식으로 재연결 간격을 늘립니다. 하지만 Math.min(..., 30000)으로 최대 30초를 넘지 않도록 제한합니다.
이렇게 하는 이유는 초기에는 빠르게 재시도하여 일시적인 네트워크 끊김을 빠르게 복구하고, 시간이 지날수록 간격을 늘려 서버 부하를 줄이기 위함입니다. setTimeout을 사용하여 계산된 지연 시간 후에 connect() 함수를 다시 호출합니다.
이는 비동기적으로 재연결을 시도하며, 사용자 인터페이스를 차단하지 않습니다. 재시도 횟수가 최대값에 도달하면 더 이상 재시도하지 않고 'failed' 상태로 설정하여, UI에서 사용자에게 수동 새로고침을 안내할 수 있습니다.
마지막으로, status 상태를 통해 현재 연결 상태를 추적합니다. 'connecting', 'connected', 'error', 'failed' 네 가지 상태를 구분하여 UI에서 적절한 피드백을 제공할 수 있습니다.
예를 들어 'connecting'일 때는 로딩 스피너를, 'error'일 때는 재연결 중 메시지를, 'failed'일 때는 새로고침 버튼을 보여줄 수 있습니다. 여러분이 이 코드를 사용하면 네트워크가 불안정한 환경에서도 안정적으로 작동하는 애플리케이션을 만들 수 있습니다.
일시적인 연결 끊김은 자동으로 복구되고, 사용자는 중단 없는 경험을 누릴 수 있습니다. 또한 서버에 과도한 부하를 주지 않으면서도 빠른 복구를 제공하는 균형 잡힌 전략입니다.
실전 팁
💡 재연결 시도 중에는 사용자에게 "재연결 중... (시도 3/5)" 같은 명확한 피드백을 제공하여 앱이 멈춘 것이 아님을 알려주세요.
💡 네트워크 상태를 감지하는 Navigator.onLine API와 결합하면, 오프라인 상태에서는 재연결을 시도하지 않다가 온라인이 되면 즉시 연결할 수 있습니다.
💡 서버에서 특정 에러 코드(예: 401 인증 실패)를 보낼 때는 재연결을 시도하지 말고 즉시 로그인 페이지로 리다이렉트하는 등의 처리가 필요합니다. 이를 위해 서버에서 event: error 형식으로 에러 유형을 전달하세요.
💡 개발 중에는 Chrome DevTools의 Network 탭에서 "Offline" 모드를 시뮬레이션하여 재연결 로직을 테스트할 수 있습니다.
💡 재연결 성공 시 서버에 마지막으로 받은 데이터의 ID를 전송하여, 누락된 데이터를 다시 받을 수 있도록 구현하면 데이터 무결성이 보장됩니다.
6. SSE와 WebSocket 비교 - 올바른 기술 선택하기
시작하며
여러분이 실시간 기능을 구현하려고 할 때 SSE와 WebSocket 중 어떤 것을 선택해야 할지 고민한 적 있나요? 인터넷에는 "WebSocket이 더 좋다", "SSE로 충분하다" 같은 다양한 의견이 있어서 혼란스럽습니다.
이런 혼란은 각 기술의 장단점과 적합한 사용 사례를 명확히 이해하지 못해서 발생합니다. 잘못된 선택은 불필요하게 복잡한 구현, 과도한 서버 리소스 사용, 혹은 기능 제약으로 이어질 수 있습니다.
바로 이럴 때 필요한 것이 SSE와 WebSocket의 차이점을 명확히 이해하고, 프로젝트 요구사항에 맞는 기술을 선택하는 것입니다. 두 기술 모두 장점이 있으며, 상황에 따라 최선의 선택이 달라집니다.
개요
간단히 말해서, SSE는 서버에서 클라이언트로의 단방향 스트리밍에 최적화된 반면, WebSocket은 양방향 실시간 통신을 위한 기술입니다. 이 비교가 필요한 이유는 프로젝트의 성격에 따라 적합한 기술을 선택함으로써 개발 시간을 단축하고, 성능을 최적화하며, 유지보수를 쉽게 하기 위함입니다.
ChatGPT 같은 AI 응답에는 SSE가 적합하고, 채팅 애플리케이션에는 WebSocket이 적합합니다. 기존에는 실시간 통신이라면 무조건 WebSocket을 사용하는 경향이 있었다면, 이제는 요구사항을 분석하여 더 간단하고 효율적인 SSE를 선택할 수 있는 안목이 필요합니다.
이 비교의 핵심 특징은 첫째, 통신 방향(단방향 vs 양방향)입니다. 둘째, 프로토콜 복잡도(HTTP 기반 vs 별도 프로토콜)입니다.
셋째, 브라우저 지원과 인프라 호환성입니다. 넷째, 재연결 및 에러 처리의 난이도입니다.
이러한 특징들을 이해하면 올바른 기술 선택을 할 수 있습니다.
코드 예제
// SSE: 서버 → 클라이언트 단방향
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
console.log('새 알림:', event.data);
};
// WebSocket: 양방향 통신
const socket = new WebSocket('ws://localhost:3000');
socket.onopen = () => {
// 클라이언트 → 서버 메시지 전송 가능
socket.send(JSON.stringify({ type: 'subscribe', channel: 'chat' }));
};
socket.onmessage = (event) => {
console.log('받은 메시지:', event.data);
};
// 핵심 차이: SSE는 send() 메서드가 없음
// 클라이언트에서 서버로 데이터를 보내려면 별도 HTTP 요청 필요
fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message: 'Hello' })
});
설명
이것이 하는 일은 두 가지 실시간 통신 기술의 근본적인 차이를 보여주는 것입니다. 첫 번째로, SSE는 표준 HTTP 프로토콜 위에서 작동합니다.
EventSource 객체를 생성하면 서버로 일반 HTTP GET 요청을 보내고, 서버는 'text/event-stream' 응답을 유지한 채로 데이터를 계속 전송합니다. 이는 기존의 웹 인프라(프록시, 로드 밸런서, CDN)와 완벽하게 호환되며, 추가 설정이 거의 필요 없습니다.
HTTPS를 사용하면 자동으로 보안 연결이 되고, CORS도 일반 HTTP 요청과 동일하게 처리됩니다. 반면 WebSocket은 초기 HTTP 핸드셰이크 후에 프로토콜을 WebSocket으로 업그레이드합니다.
이는 완전히 다른 통신 방식으로 전환되는 것을 의미하며, 일부 프록시나 방화벽에서 차단될 수 있습니다. Nginx나 Apache 같은 웹 서버에서 WebSocket을 지원하려면 추가 설정이 필요하고, 로드 밸런싱도 sticky session을 고려해야 합니다.
두 번째로, 통신 방향의 차이가 명확합니다. SSE는 서버에서 클라이언트로만 데이터를 보낼 수 있습니다.
EventSource 객체에는 send() 메서드가 없으며, 클라이언트가 서버로 데이터를 보내려면 별도의 HTTP 요청(fetch, axios 등)을 사용해야 합니다. 이는 ChatGPT처럼 "요청 → 스트리밍 응답" 패턴에 완벽하게 맞습니다.
사용자가 질문을 POST로 보내고, 응답을 SSE로 받는 구조입니다. WebSocket은 socket.send()로 언제든지 서버에 데이터를 보낼 수 있습니다.
하나의 연결로 양방향 통신이 가능하므로, 실시간 채팅, 멀티플레이어 게임, 협업 도구처럼 빈번한 양방향 교환이 필요한 경우에 적합합니다. 하지만 단순히 서버의 업데이트를 받기만 한다면, WebSocket은 과한 선택일 수 있습니다.
세 번째로, 자동 재연결 기능의 차이입니다. EventSource는 브라우저에 자동 재연결 로직이 내장되어 있어, 연결이 끊어지면 자동으로 재연결을 시도합니다.
서버에서 retry: 3000 같은 필드를 보내 재연결 간격을 제어할 수도 있습니다. 반면 WebSocket은 재연결 로직을 직접 구현해야 하며, 이는 추가적인 코드와 복잡도를 의미합니다.
마지막으로, 디버깅과 모니터링의 용이성입니다. SSE는 일반 HTTP 요청이므로 Chrome DevTools의 Network 탭에서 모든 메시지를 쉽게 확인할 수 있습니다.
EventStream 타입으로 필터링하면 실시간으로 전송되는 데이터를 볼 수 있어 디버깅이 매우 쉽습니다. WebSocket도 DevTools에서 확인할 수 있지만, 메시지가 바이너리 형식일 경우 해석이 어려울 수 있습니다.
여러분이 이 비교를 이해하면 프로젝트 요구사항에 맞는 기술을 선택할 수 있습니다. AI 스트리밍, 실시간 알림, 진행 상황 업데이트처럼 서버에서 클라이언트로만 데이터를 보내는 경우에는 SSE가 더 간단하고 효율적입니다.
반대로 채팅, 게임, 협업 도구처럼 빈번한 양방향 통신이 필요하면 WebSocket을 선택하세요.
실전 팁
💡 SSE는 HTTP/2를 사용하면 하나의 TCP 연결로 여러 SSE 스트림을 멀티플렉싱할 수 있어 연결 제한 문제를 해결할 수 있습니다.
💡 IE를 지원해야 한다면 SSE는 네이티브로 지원되지 않으므로 polyfill이 필요하거나 WebSocket을 고려해야 합니다(하지만 2023년 기준 IE 지원은 거의 불필요).
💡 서버 비용을 고려할 때, SSE는 연결당 하나의 HTTP 연결을 유지하므로 동시 접속자가 많으면 리소스가 많이 필요합니다. 이 경우 연결 풀링이나 Redis Pub/Sub 같은 백엔드 최적화를 고려하세요.
💡 모바일 앱에서는 WebSocket이 백그라운드 연결 유지에 더 유리할 수 있지만, 배터리 소모가 크므로 필요할 때만 연결하는 전략이 중요합니다.
💡 하이브리드 접근도 가능합니다. 일반적인 요청/응답은 REST API로, 서버 업데이트는 SSE로, 긴급한 양방향 통신은 WebSocket으로 분리하여 각 기술의 장점을 살릴 수 있습니다.
7. 진행 상황 표시하기 - 퍼센티지와 단계별 업데이트
시작하며
여러분이 파일 업로드, 데이터 처리, AI 모델 학습 같은 시간이 오래 걸리는 작업을 진행할 때 사용자에게 아무런 피드백도 주지 않은 적 있나요? 사용자는 작업이 진행 중인지, 얼마나 남았는지 알 수 없어서 불안해하고 페이지를 떠나기도 합니다.
이런 문제는 사용자 경험에 치명적입니다. 연구에 따르면 사용자는 진행 상황을 볼 수 있을 때 훨씬 더 오래 기다릴 의향이 있으며, 프로세스에 대한 신뢰도가 높아집니다.
반대로 피드백 없이 기다리면 몇 초만 지나도 답답함을 느낍니다. 바로 이럴 때 필요한 것이 SSE를 활용한 실시간 진행 상황 업데이트입니다.
서버의 작업 진행률을 실시간으로 클라이언트에게 전송하여, 사용자가 정확히 얼마나 남았는지 알 수 있게 만들 수 있습니다.
개요
간단히 말해서, 진행 상황 표시는 장시간 실행되는 서버 작업의 현재 진행률과 상태를 SSE를 통해 실시간으로 클라이언트에게 전달하는 패턴입니다. 이 패턴이 필요한 이유는 사용자에게 투명성을 제공하고, 대기 시간에 대한 불확실성을 줄이며, 작업이 정상적으로 진행되고 있다는 확신을 주기 위함입니다.
파일 처리, 데이터 마이그레이션, 리포트 생성, AI 추론 같은 장시간 작업에서 필수적입니다. 기존에는 작업이 완료될 때까지 로딩 스피너만 보여주었다면, 진행 상황 업데이트를 사용하면 0%에서 100%까지 실시간으로 변화하는 프로그레스 바와 현재 단계 설명을 제공할 수 있습니다.
이 패턴의 핵심 특징은 첫째, 퍼센티지 기반 진행률로 시각적 피드백을 제공합니다. 둘째, 각 단계별 상태 메시지로 무슨 일이 일어나는지 설명합니다.
셋째, 예상 완료 시간이나 처리된 항목 수 같은 추가 정보를 제공할 수 있습니다. 이러한 특징들이 우수한 사용자 경험을 만듭니다.
코드 예제
// 서버: 진행 상황을 SSE로 전송
app.post('/api/process', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const items = req.body.items; // 처리할 아이템 목록
const total = items.length;
for (let i = 0; i < total; i++) {
// 실제 작업 수행
await processItem(items[i]);
// 진행 상황 업데이트 전송
const progress = Math.round(((i + 1) / total) * 100);
res.write(`data: ${JSON.stringify({
progress,
current: i + 1,
total,
status: `처리 중: ${items[i].name}`,
eta: estimateTimeRemaining(i + 1, total)
})}\n\n`);
}
// 완료 신호
res.write(`data: ${JSON.stringify({
progress: 100,
status: '완료',
done: true
})}\n\n`);
res.end();
});
설명
이것이 하는 일은 서버의 장시간 작업을 작은 단위로 나누고, 각 단계가 완료될 때마다 진행 상황을 클라이언트에게 알려주는 것입니다. 첫 번째로, 전체 작업을 개별 항목으로 나눕니다.
예를 들어 100개의 이미지를 처리해야 한다면, 각 이미지가 하나의 단위가 됩니다. total 변수에 전체 개수를 저장하여 퍼센티지를 계산할 수 있는 기준을 만듭니다.
이렇게 작업을 분할하는 것이 진행 상황 추적의 첫 단계입니다. 그 다음으로, for 루프에서 각 항목을 순차적으로 처리합니다.
await processItem()은 실제 작업(파일 처리, API 호출, 데이터베이스 작업 등)을 수행하는 비동기 함수입니다. 각 항목이 완료될 때마다 진행률을 계산합니다.
((i + 1) / total) * 100 공식으로 현재까지 완료된 비율을 퍼센티지로 변환하고, Math.round()로 정수로 만들어 깔끔하게 표시합니다. 진행 상황 데이터 객체에는 여러 유용한 정보가 포함됩니다.
progress는 프로그레스 바를 업데이트하는 데 사용되고, current와 total은 "15/100" 같은 텍스트 표시에 사용됩니다. status는 현재 무슨 작업을 하고 있는지 사람이 읽을 수 있는 설명을 제공하며, 사용자가 진행 상황을 구체적으로 이해하는 데 도움을 줍니다.
eta(Estimated Time to Arrival)는 남은 예상 시간을 계산하여 "약 2분 남음" 같은 정보를 제공할 수 있습니다. estimateTimeRemaining() 함수는 지금까지 걸린 시간을 기반으로 남은 시간을 추정합니다.
예를 들어 100개 중 25개를 처리하는 데 1분이 걸렸다면, 나머지 75개는 약 3분이 걸릴 것으로 예상할 수 있습니다. 이런 예측은 완벽하지 않지만, 사용자에게 대략적인 대기 시간을 알려주는 것만으로도 경험이 크게 향상됩니다.
마지막으로, 모든 작업이 완료되면 progress: 100, done: true를 포함한 완료 메시지를 보내고 연결을 종료합니다. 클라이언트는 이 신호를 받아 로딩 UI를 숨기고 결과 화면을 표시하거나 다음 단계로 진행할 수 있습니다.
여러분이 이 코드를 사용하면 시간이 오래 걸리는 작업도 사용자가 인내심을 가지고 기다릴 수 있는 경험을 제공할 수 있습니다. 진행 상황이 보이면 사용자는 작업이 멈춘 것이 아니라 진행 중임을 알 수 있고, 예상 시간을 보고 커피를 마시러 갈지 기다릴지 판단할 수 있습니다.
이는 사용자 만족도와 작업 완료율을 크게 높입니다.
실전 팁
💡 진행 상황 업데이트를 너무 자주 보내면 네트워크와 렌더링 부하가 커집니다. 최소 100ms 간격을 두거나, 퍼센티지가 실제로 변경되었을 때만 전송하세요(예: 정수 단위로 변경될 때).
💡 예상 시간을 계산할 때는 최근 몇 개 항목의 평균 처리 시간을 사용하면 더 정확합니다. 초반 항목과 후반 항목의 처리 시간이 다를 수 있기 때문입니다.
💡 에러가 발생했을 때도 진행 상황 스트림에 에러 정보를 포함시켜, 어느 항목에서 실패했는지 알려주고 계속 진행할지 중단할지 선택하게 할 수 있습니다.
💡 UI에서는 프로그레스 바와 함께 현재 처리 중인 항목 이름, 처리 속도(초당 항목 수), 예상 완료 시간을 모두 표시하면 사용자에게 최대한의 정보를 제공할 수 있습니다.
💡 장시간 작업의 경우 서버 타임아웃을 피하기 위해 reverse proxy 설정을 확인하고, 주기적으로 heartbeat 메시지를 보내는 것이 좋습니다.
8. 인증과 보안 - SSE 연결 보호하기
시작하며
여러분이 실시간 알림 시스템을 만들었는데, 누구나 다른 사용자의 알림을 볼 수 있다면 어떻게 될까요? 민감한 개인 정보, 금융 데이터, 비즈니스 정보가 노출되어 심각한 보안 문제가 발생합니다.
이런 문제는 SSE 연결에 적절한 인증과 권한 검증을 구현하지 않아서 발생합니다. 일반 HTTP API는 각 요청마다 토큰을 확인하지만, SSE는 장시간 유지되는 연결이므로 보안을 더 신중하게 설계해야 합니다.
바로 이럴 때 필요한 것이 SSE 전용 인증 및 보안 패턴입니다. 연결 시점의 인증, 토큰 만료 처리, CORS 설정 등을 올바르게 구현하여 안전한 실시간 통신을 보장할 수 있습니다.
개요
간단히 말해서, SSE 보안은 연결을 생성하는 사용자의 신원을 확인하고, 권한이 있는 데이터만 전송하며, 보안 위협으로부터 연결을 보호하는 것입니다. 이 패턴이 필요한 이유는 실시간 데이터가 종종 민감한 정보를 포함하며, 무단 접근이나 데이터 유출은 심각한 법적, 비즈니스 문제를 야기하기 때문입니다.
금융 앱의 거래 알림, 의료 앱의 환자 데이터, 기업 대시보드의 실시간 지표 등에서 필수적입니다. 기존에는 단순히 URL로 SSE에 연결했다면, 보안이 적용된 SSE는 JWT 토큰 검증, 사용자별 권한 확인, 토큰 갱신 메커니즘 등을 포함합니다.
이 패턴의 핵심 특징은 첫째, EventSource가 쿠키를 자동으로 전송하므로 HttpOnly 쿠키 기반 인증이 가장 안전합니다. 둘째, URL 쿼리 파라미터로 토큰을 전달할 수도 있지만 로그에 노출될 위험이 있습니다.
셋째, 토큰 만료 시 연결을 종료하고 재인증을 요구해야 합니다. 이러한 특징들이 안전한 SSE 통신을 만듭니다.
코드 예제
// 서버: JWT 기반 SSE 인증
import jwt from 'jsonwebtoken';
app.get('/api/notifications/stream', (req, res) => {
// 쿼리 파라미터 또는 쿠키에서 토큰 추출
const token = req.query.token || req.cookies.auth_token;
try {
// 토큰 검증
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const userId = decoded.userId;
// SSE 헤더 설정
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 연결 확인 메시지
res.write(`data: ${JSON.stringify({ status: 'authenticated' })}\n\n`);
// 사용자별 알림 리스너 등록
const listener = (notification) => {
if (notification.userId === userId) {
res.write(`data: ${JSON.stringify(notification)}\n\n`);
}
};
notificationEmitter.on('notification', listener);
// 토큰 만료 시간 체크
const expiresIn = (decoded.exp * 1000) - Date.now();
const timeout = setTimeout(() => {
res.write(`data: ${JSON.stringify({
error: 'token_expired',
message: '토큰이 만료되었습니다. 다시 로그인하세요.'
})}\n\n`);
res.end();
}, expiresIn);
// 연결 종료 시 정리
req.on('close', () => {
notificationEmitter.off('notification', listener);
clearTimeout(timeout);
});
} catch (error) {
res.status(401).json({ error: '인증 실패' });
}
});
설명
이것이 하는 일은 SSE 연결이 생성될 때부터 종료될 때까지 보안을 유지하고, 권한이 있는 사용자만 자신의 데이터를 받을 수 있도록 보장하는 것입니다. 첫 번째로, 토큰을 추출하고 검증합니다.
EventSource는 커스텀 헤더를 지원하지 않으므로, 토큰을 전달하는 방법은 두 가지입니다. URL 쿼리 파라미터(/api/stream?token=xxx)로 전달하거나, 쿠키로 전달하는 것입니다.
쿠키 방식이 더 안전한데, HttpOnly 속성을 설정하면 JavaScript로 접근할 수 없어 XSS 공격으로부터 보호되고, 로그 파일에 토큰이 기록될 위험도 없습니다. jwt.verify()로 토큰의 서명을 검증하여, 위조된 토큰은 즉시 거부됩니다.
그 다음으로, 토큰에서 추출한 userId를 사용하여 사용자별 데이터 필터링을 구현합니다. 알림 이벤트가 발생할 때마다 listener 함수가 호출되지만, notification.userId === userId 조건으로 현재 연결한 사용자의 데이터만 전송합니다.
이는 매우 중요한 보안 조치로, 다른 사용자의 데이터가 실수로 노출되는 것을 방지합니다. 예를 들어 사용자 A가 연결했을 때 사용자 B의 알림이 전송되면 안 됩니다.
토큰 만료 처리는 SSE의 독특한 보안 요구사항입니다. 일반 API 요청은 매번 토큰을 확인하지만, SSE는 연결이 수 시간 동안 유지될 수 있습니다.
따라서 토큰의 만료 시간(decoded.exp)을 확인하여, 만료 시점에 자동으로 연결을 종료해야 합니다. setTimeout()을 사용하여 만료 시점에 에러 메시지를 전송하고 연결을 닫습니다.
클라이언트는 이 메시지를 받고 사용자에게 재로그인을 요청할 수 있습니다. notificationEmitter는 서버 내부의 이벤트 시스템으로, 새로운 알림이 생성되면 모든 리스너에게 알려줍니다.
이는 Node.js의 EventEmitter 패턴을 사용하거나, Redis Pub/Sub, RabbitMQ 같은 메시지 브로커를 사용할 수 있습니다. 여러 서버 인스턴스가 있는 경우에는 Redis Pub/Sub을 사용하여 모든 서버가 알림을 받을 수 있도록 해야 합니다.
마지막으로, 연결 종료 시 리스너를 제거하고 타임아웃을 정리합니다. 이는 메모리 누수를 방지하는 필수 단계입니다.
사용자가 페이지를 닫거나 로그아웃하면 close 이벤트가 발생하며, 이때 등록했던 모든 리소스를 정리해야 합니다. 리스너를 제거하지 않으면 이벤트가 발생할 때마다 이미 끊긴 연결에 쓰기를 시도하여 에러가 발생하고 메모리가 누적됩니다.
여러분이 이 코드를 사용하면 안전한 실시간 통신 시스템을 구축할 수 있습니다. 인증되지 않은 사용자는 연결조차 할 수 없고, 인증된 사용자도 자신의 데이터만 받을 수 있으며, 토큰이 만료되면 자동으로 보안이 유지됩니다.
이는 GDPR, HIPAA 같은 개인정보 보호 규정을 준수하는 데도 필수적입니다.
실전 팁
💡 CORS를 설정할 때는 Access-Control-Allow-Credentials: true를 포함하고, Access-Control-Allow-Origin을 와일드카드(*) 대신 구체적인 도메인으로 설정하여 쿠키 기반 인증이 작동하도록 하세요.
💡 토큰 갱신을 지원하려면, 토큰 만료 5분 전에 클라이언트에게 경고 메시지를 보내고, 클라이언트가 새 토큰을 받아서 연결을 재생성하도록 안내하세요.
💡 Rate limiting을 구현하여 한 사용자가 너무 많은 SSE 연결을 생성하지 못하도록 제한하세요. 악의적인 사용자가 서버 리소스를 고갈시키는 것을 방지할 수 있습니다.
💡 민감한 데이터를 전송할 때는 반드시 HTTPS를 사용하세요. SSE는 기본적으로 암호화되지 않으므로, HTTP로 전송하면 중간자 공격에 취약합니다.
💡 프로덕션 환경에서는 토큰을 로그에 기록하지 않도록 주의하세요. 쿼리 파라미터는 자동으로 로그에 남을 수 있으므로, 로깅 미들웨어에서 토큰을 마스킹하는 처리가 필요합니다.
9. 스케일링과 성능 최적화 - 다중 서버 환경에서의 SSE
시작하며
여러분이 SSE 기반 실시간 기능을 성공적으로 런칭했는데, 사용자가 늘어나면서 단일 서버로는 감당할 수 없는 상황이 온 적 있나요? 로드 밸런서 뒤에 여러 서버를 두었는데, 사용자가 특정 이벤트를 받지 못하는 문제가 발생합니다.
이런 문제는 SSE 연결이 특정 서버 인스턴스에 종속되고, 이벤트가 다른 서버에서 발생하면 연결된 클라이언트에게 전달되지 않기 때문입니다. 단일 서버 환경에서는 문제없지만, 수평 확장(horizontal scaling)할 때는 추가 아키텍처가 필요합니다.
바로 이럴 때 필요한 것이 Redis Pub/Sub, Message Queue, Sticky Session 같은 스케일링 패턴입니다. 이를 통해 여러 서버 간에 실시간 이벤트를 공유하고, 수천 명의 동시 접속자를 안정적으로 처리할 수 있습니다.
개요
간단히 말해서, SSE 스케일링은 여러 서버 인스턴스가 실시간 이벤트를 공유하여, 어느 서버에 연결된 사용자든 모든 이벤트를 받을 수 있도록 하는 아키텍처입니다. 이 패턴이 필요한 이유는 실제 프로덕션 환경에서는 고가용성과 부하 분산을 위해 여러 서버를 운영하기 때문입니다.
사용자가 수백, 수천 명으로 늘어나면 단일 서버로는 CPU, 메모리, 네트워크 대역폭이 부족하며, 서버 장애 시 모든 연결이 끊어지는 위험도 있습니다. 기존에는 단일 서버의 EventEmitter로 이벤트를 관리했다면, 다중 서버 환경에서는 Redis Pub/Sub 같은 중앙화된 메시지 브로커를 사용하여 모든 서버가 이벤트를 공유합니다.
이 패턴의 핵심 특징은 첫째, Redis Pub/Sub으로 서버 간 이벤트를 브로드캐스트합니다. 둘째, 각 서버는 Redis를 구독하여 다른 서버에서 발생한 이벤트도 받습니다.
셋째, 로드 밸런서는 SSE 연결을 여러 서버에 분산시킵니다. 넷째, 연결 상태를 추적하여 메모리 사용량을 최적화합니다.
이러한 특징들이 대규모 실시간 서비스를 가능하게 합니다.
코드 예제
// Redis Pub/Sub으로 다중 서버 간 이벤트 공유
import Redis from 'ioredis';
const publisher = new Redis();
const subscriber = new Redis();
// 활성 SSE 연결 저장
const connections = new Map();
// Redis에서 이벤트 구독
subscriber.subscribe('notifications');
subscriber.on('message', (channel, message) => {
const notification = JSON.parse(message);
// 해당 사용자와 연결된 모든 SSE에 전송
const userConnections = connections.get(notification.userId) || [];
userConnections.forEach(res => {
if (!res.finished) {
res.write(`data: ${message}\n\n`);
}
});
});
app.get('/api/notifications/stream', authenticateToken, (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const userId = req.user.userId;
// 연결 저장
if (!connections.has(userId)) {
connections.set(userId, []);
}
connections.get(userId).push(res);
// 연결 종료 시 정리
req.on('close', () => {
const userConns = connections.get(userId);
const index = userConns.indexOf(res);
if (index > -1) userConns.splice(index, 1);
if (userConns.length === 0) connections.delete(userId);
});
});
// 다른 API에서 알림 발생 시
app.post('/api/notifications', async (req, res) => {
const notification = { userId: req.body.userId, message: req.body.message };
// Redis에 발행 (모든 서버가 받음)
await publisher.publish('notifications', JSON.stringify(notification));
res.json({ success: true });
});
설명
이것이 하는 일은 여러 서버가 하나의 이벤트 버스를 공유하여, 어느 서버에서 이벤트가 발생하든 모든 연결된 클라이언트가 받을 수 있도록 하는 것입니다. 첫 번째로, Redis 클라이언트를 두 개 생성합니다.
publisher는 이벤트를 발행하는 용도이고, subscriber는 이벤트를 구독하는 용도입니다. Redis의 Pub/Sub 모드에서는 구독 중인 클라이언트는 다른 명령을 실행할 수 없으므로, 발행과 구독을 별도 클라이언트로 분리해야 합니다.
이는 Redis Pub/Sub의 중요한 제약사항입니다. 그 다음으로, subscriber.subscribe('notifications')로 'notifications' 채널을 구독합니다.
이제 어떤 서버에서든 이 채널에 메시지를 발행하면, 모든 서버의 subscriber가 메시지를 받습니다. 예를 들어 서버 A, B, C가 있고 사용자 1이 서버 A에 연결되어 있을 때, 서버 B에서 사용자 1의 알림을 생성하면 다음과 같이 작동합니다: 서버 B가 Redis에 발행 → 모든 서버(A, B, C)의 subscriber가 메시지를 받음 → 서버 A가 사용자 1의 SSE 연결을 찾아 전송합니다.
connections Map은 사용자 ID를 키로, 해당 사용자의 모든 SSE 연결(response 객체)을 배열로 저장합니다. 한 사용자가 여러 탭이나 기기에서 접속할 수 있으므로 배열로 관리합니다.
Redis에서 메시지를 받으면, 해당 userId의 모든 연결을 찾아 메시지를 전송합니다. res.finished 체크는 연결이 이미 종료되었는지 확인하여, 끊긴 연결에 쓰기를 시도하는 에러를 방지합니다.
SSE 엔드포인트에서는 새 연결이 생성될 때마다 connections Map에 추가합니다. connections.get(userId)로 기존 연결 배열을 가져오거나, 없으면 새 배열을 생성합니다.
연결이 종료되면(req.on('close')) 배열에서 제거하고, 사용자의 연결이 모두 없어지면 Map에서 키를 삭제하여 메모리를 정리합니다. 이런 세심한 메모리 관리가 없으면 장시간 운영 시 메모리 누수가 발생합니다.
알림을 생성하는 POST 엔드포인트에서는 publisher.publish()로 Redis에 메시지를 발행합니다. 이 메시지는 모든 서버의 subscriber에게 전달되고, 각 서버는 자신에게 연결된 클라이언트 중 해당하는 사용자를 찾아 전송합니다.
이렇게 하면 알림을 생성한 서버와 사용자가 연결된 서버가 달라도 문제없이 작동합니다. 여러분이 이 코드를 사용하면 수평 확장이 가능한 실시간 시스템을 구축할 수 있습니다.
사용자가 늘어나면 서버를 추가하기만 하면 되고, 한 서버가 다운되어도 다른 서버들이 계속 서비스를 제공합니다. Redis Pub/Sub은 메시지를 메모리에만 보관하고 영구 저장하지 않으므로 매우 빠르며, 수만 개의 메시지를 초당 처리할 수 있습니다.
실전 팁
💡 Redis Pub/Sub은 메시지를 저장하지 않으므로, subscriber가 일시적으로 다운되면 그동안의 메시지를 놓칩니다. 중요한 이벤트는 데이터베이스에도 저장하고, 재연결 시 놓친 메시지를 조회하는 로직을 추가하세요.
💡 연결 수를 모니터링하여 서버당 최대 연결 수를 제한하고, 초과 시 새 서버 인스턴스를 자동으로 추가하는 오토 스케일링을 구현하세요.
💡 로드 밸런서에서 Sticky Session(세션 고정)을 설정하면 같은 사용자의 요청이 항상 같은 서버로 가므로, 연결 관리가 단순해질 수 있습니다. 하지만 서버 간 부하 불균형이 발생할 수 있으니 신중히 선택하세요.
💡 대규모 환경에서는 Redis Cluster나 Sentinel을 사용하여 Redis 자체의 고가용성을 보장하세요. Redis가 다운되면 모든 실시간 기능이 멈추므로 매우 중요합니다.
💡 메모리 사용량을 줄이려면 연결 정보를 최소한으로 저장하고(full response 객체 대신 socket ID만), 주기적으로 끊긴 연결을 정리하는 가비지 컬렉션 로직을 실행하세요.
10. 테스트와 디버깅 - SSE 개발 효율 높이기
시작하며
여러분이 SSE 기능을 개발하면서 "메시지가 왜 안 오지?", "연결이 끊어졌는지 어떻게 알지?"처럼 디버깅에 시간을 너무 많이 쓴 적 있나요? 실시간 스트리밍은 일반 API보다 디버깅이 어렵고, 자동화된 테스트 작성도 까다롭습니다.
이런 문제는 SSE의 비동기적이고 장시간 지속되는 특성 때문에 발생합니다. 일반 HTTP 요청은 즉시 응답이 오지만, SSE는 언제 메시지가 올지 모르고, 여러 메시지가 순차적으로 오며, 네트워크 상태에 따라 동작이 달라집니다.
이런 복잡성을 효과적으로 다루지 못하면 개발 생산성이 크게 떨어집니다. 바로 이럴 때 필요한 것이 체계적인 테스트 및 디버깅 전략입니다.
브라우저 DevTools 활용, 자동화된 통합 테스트, 로깅 전략 등을 통해 SSE 개발을 훨씬 빠르고 안정적으로 할 수 있습니다.
개요
간단히 말해서, SSE 테스트는 스트리밍 연결의 생성, 메시지 수신, 에러 처리, 재연결 등을 자동으로 검증하는 것이고, 디버깅은 실시간으로 전송되는 메시지와 연결 상태를 시각화하는 것입니다. 이 방법이 필요한 이유는 수동으로 테스트하기 어려운 엣지 케이스(네트워크 끊김, 타임아웃, 동시 접속 등)를 자동으로 검증하고, 버그를 빠르게 발견하여 개발 속도를 높이기 위함입니다.
CI/CD 파이프라인에 통합하면 배포 전에 회귀 버그를 자동으로 잡을 수 있습니다. 기존에는 브라우저를 열어서 수동으로 테스트하고 콘솔 로그만 보았다면, 체계적인 테스트와 디버깅 도구를 사용하면 자동화되고 반복 가능한 검증이 가능합니다.
이 방법의 핵심 특징은 첫째, Chrome DevTools의 Network 탭에서 EventStream을 실시간으로 모니터링합니다. 둘째, Jest나 Vitest 같은 테스트 프레임워크로 SSE 연결을 시뮬레이션합니다.
셋째, 구조화된 로깅으로 각 단계의 상태를 추적합니다. 넷째, Mock 서버로 다양한 시나리오를 재현합니다.
이러한 특징들이 안정적인 SSE 개발을 지원합니다.
코드 예제
// Jest로 SSE 통합 테스트 작성
import { EventSource } from 'eventsource'; // Node.js용 polyfill
describe('SSE Notifications', () => {
let eventSource;
afterEach(() => {
if (eventSource) eventSource.close();
});
test('연결 성공 시 초기 메시지를 받는다', (done) => {
eventSource = new EventSource('http://localhost:3000/api/stream');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
expect(data.status).toBe('connected');
done(); // 비동기 테스트 완료
};
eventSource.onerror = () => {
done(new Error('연결 실패'));
};
}, 10000); // 타임아웃 10초
test('여러 메시지를 순서대로 받는다', (done) => {
eventSource = new EventSource('http://localhost:3000/api/stream');
const messages = [];
eventSource.onmessage = (event) => {
messages.push(JSON.parse(event.data));
if (messages.length === 3) {
expect(messages[0].id).toBe(1);
expect(messages[1].id).toBe(2);
expect(messages[2].id).toBe(3);
done();
}
};
}, 10000);
test('에러 발생 시 적절히 처리한다', (done) => {
eventSource = new EventSource('http://localhost:3000/api/stream/invalid');
eventSource.onerror = (error) => {
expect(eventSource.readyState).toBe(EventSource.CLOSED);
done();
};
}, 10000);
});
설명
이것이 하는 일은 실제 SSE 서버에 연결하여 예상대로 동작하는지 자동으로 검증하는 것입니다. 첫 번째로, Node.js 환경에서 테스트하려면 eventsource 패키지를 설치해야 합니다.
브라우저와 달리 Node.js는 기본적으로 EventSource API를 제공하지 않기 때문입니다. npm install eventsource --save-dev로 설치하면, 브라우저의 EventSource와 거의 동일한 API를 사용할 수 있습니다.
이 polyfill은 테스트 환경에서만 사용되므로 devDependencies에 추가합니다. 그 다음으로, 각 테스트 케이스는 실제 시나리오를 시뮬레이션합니다.
첫 번째 테스트는 연결이 성공하면 서버가 초기 메시지를 보내는지 확인합니다. done 콜백을 사용하는 이유는 SSE가 비동기적이기 때문입니다.
Jest는 done()이 호출될 때까지 기다리며, 타임아웃 시간 내에 호출되지 않으면 테스트가 실패합니다. 10초 타임아웃을 설정한 이유는 서버 시작, 연결 확립, 첫 메시지 전송에 시간이 걸릴 수 있기 때문입니다.
두 번째 테스트는 여러 메시지를 순서대로 받는지 검증합니다. messages 배열에 받은 메시지를 누적하고, 3개가 모이면 각 메시지의 ID가 순서대로인지 확인합니다.
이는 메시지 순서가 중요한 애플리케이션(채팅, 이벤트 로그 등)에서 필수적인 테스트입니다. 네트워크 지연이나 서버 부하로 인해 순서가 뒤바뀌지 않는지 검증할 수 있습니다.
세 번째 테스트는 에러 처리를 검증합니다. 잘못된 URL로 연결을 시도하면 onerror 핸들러가 호출되고, readyState가 CLOSED가 되는지 확인합니다.
이는 네트워크 에러, 서버 에러, 인증 실패 등 다양한 에러 상황에서 앱이 올바르게 반응하는지 테스트하는 데 중요합니다. afterEach 훅에서 모든 테스트 후에 연결을 닫습니다.
이는 매우 중요한데, 연결을 닫지 않으면 테스트가 끝나도 연결이 유지되어 다음 테스트에 영향을 주거나, 테스트 프로세스가 종료되지 않을 수 있습니다. 각 테스트는 독립적이어야 하므로, 테스트 간 상태를 공유하지 않도록 정리하는 것이 필수입니다.
실제 프로덕션 테스트에서는 Mock 서버를 사용할 수도 있습니다. 예를 들어 msw (Mock Service Worker) 라이브러리를 사용하면, 실제 서버 없이도 SSE 응답을 시뮬레이션하여 더 빠르고 안정적인 테스트를 작성할 수 있습니다.
네트워크 끊김, 느린 응답, 특정 에러 코드 등 다양한 시나리오를 쉽게 재현할 수 있습니다. 여러분이 이런 테스트를 작성하면 코드 변경 시 실시간 기능이 깨졌는지 즉시 알 수 있습니다.
CI/CD 파이프라인에 통합하면 배포 전에 자동으로 검증되어, 프로덕션에 버그가 배포될 위험이 크게 줄어듭니다. 또한 새로운 팀원이 코드를 수정할 때도 테스트가 안전망 역할을 해줍니다.
실전 팁
💡 Chrome DevTools의 Network 탭에서 "EventStream" 타입으로 필터링하면 SSE 연결만 볼 수 있고, Messages 탭에서 실시간으로 전송되는 메시지를 확인할 수 있어 디버깅이 매우 쉬워집니다.
💡 서버 로그에 연결 ID, 사용자 ID, 타임스탬프를 포함시켜 어떤 연결에서 문제가 발생했는지 추적할 수 있도록 하세요. 구조화된 로깅(JSON 형식)을 사용하면 ELK 스택 같은 도구로 분석하기 쉽습니다.
💡 개발 환경에서는 curl 명령으로 SSE를 테스트할 수 있습니다: curl -N http://localhost:3000/api/stream (-N 옵션은 버퍼링을 비활성화하여 실시간으로 출력)
💡 부하 테스트를 위해 Artillery나 k6 같은 도구를 사용하여 수천 개의 동시 SSE 연결을 시뮬레이션하고, 서버가 얼마나 많은 연결을 처리할 수 있는지 측정하세요.
💡 에러 발생 시 사용자에게 명확한 메시지를 보여주고, 개발자 콘솔에는 상세한 디버깅 정보를 로깅하는 이중 전략을 사용하면 사용자 경험과 디버깅 효율을 모두 높일 수 있습니다.