1. SSE란?
- Server-Sent Events
- 웹소켓과 마찬가지로 명시적으로 연결을 끊지 않는 한 서버와 클라이언트의 연결은 쭉 유지되는 통신
- 클라이언트는 서버로부터 정보를 실시간으로 받을 수 있을 뿐 요청을 보내지는 않음 (보낼거면 별도 요청)
- 알람 기능 구현, 뉴스피드 받기 등등. 클라이언트의 동작이 서버에 등록될 필요는 없는 기능에서 활용
- 연결 방법 (CS적으로)
- 기본적으로 HTTP 연결을 열어둔 상태로 유지
- 클라이언트가 서버에 요청을 처음 보낸다 (연결 요청)
- 서버는 응답을 완료하지 않고 해당 HTTP 연결을 계속 열어둠 -> 해당 연결을 통해 데이터를 지속적으로 보냄
- 각 데이터는 "data:"형식으로 전성 됨
- 위의 연결 방법으로 유지가 가능한 이유
- HTTP 의 Content-Length 헤더를 사용하지 않거나 ( 길이가 정해져있다면 그만큼 받으면 끝이구나를 알 수 있음 )
- Transfer-Encoding: chunked를 사용하기 때문 ( 응답을 청크 단위로 나눠 보내면 클라이언트는 다음 청크가 또 올지 종료 청크가 올지 알 수 없어서 계속 유지 )
- 위의 방식으로 응답이 언제 끝날지 클라이언트는 미리 알 수 없으므로, 연결이 닫힐 때까지 데이터를 계속 받을 수 있음.
- TCP 4way-handshake로 연결 종료 하는 방법이랑 연관이 된건 아님. (처음엔 해당 과정에서 close-wait을 무한정 두는 줄 알았음)
- 연결을 유지한다는 것은 TCP 연결 자체를 ESTABLISHED 상태(3way 마지막단계 - 연결 확정된)로 유지하는 것.
2. 서버에서 SSE 연결을 위한 동작
- 응답을 스트리밍 방식으로 보낸다
- 일반적인 HTTP 응답은 서버가 모든 로직 처리 후 한번에 응답값으로 보내줌
- SSE 연결에선 서버가 나중에 발생할 이벤트를 미리 알 수 없음 -> 스트리밍 방식으로 응답을 만들게 되면, 서버는 응답을 완료하지 않은 상태에서 필요할 때마다 데이터를 보낼 수 있게 됨. (계속 열려있는 파이프라인처럼)
const stream = new ReadableStream
- 응답 헤더 옵션 중 Content-Type 을 text/event-stream으로 지정해준다.
- 위에서 언급했듯 데이터 형식은 스트리밍 방식임.
- 이것을 클라이언트에게 "이 응답은 스트리밍 이벤트 데이터" 라고 알려주기 위한 옵션 -> 클라이언트는 text / JSON이 아닌 이벤트 스트림이라고 인식. + 데이터가 계속 받을 것이라고 예측 가능 + 스트림의 라인들을 자동으로 파싱 처리
- 만약 이 헤더가 없다면 클라이언트 측 브라우저는 응답 데이터를 평범한 텍스트라 생각하고, 연결이 유지 되어 있는 것을 차단할 수 있음
3. 클라이언트에서 SSE 연결을 위한 동작
- fetch/axios 요청이 아닌 EventSource 인스턴스를 통해 서버에 요청 보내기
- EventSouce 는 브라우저 내장 API로 서버의 스트림 형태의 응답값을 쉽게 받기 위해 사용
- EventSource 가 하는 일
- 자동 재연결 : 연결 끊어져도 다시 연결 시도 ( 연결 끊김 감지 -> 3초(default)마다 자동으로 같은 URL에 GET 요청 보냄 )
- 자동 파싱 : 응답값 형식인 "data : "을 파싱해서 event.data로 추출 =>JSON 형태임
- 메모리 관리 : 연결 닫힐 때까지 자동으로 관리
- SSE 전용 기능들을 미리 구현 해놓은 API 라는 의미
- EventSource 대신 fetch / axios 등을 사용해도 되지만 이럴 경우 스트림 처리 및 재연결 로직, 파싱 로직을 구현해야함.
const es = new EventSource('/api/sse');
4. Next.js 에서 구현해보기
방금 위에서 EventSource가 재연결 시도할 때 타겟에 GET 요청을 보낸다는 것을 통해 알 수 있듯이 서버는 sse 초기 연결을 GET으로 받아와야 한다는 것을 알 수 있다.
Server ( /api/sse/route.ts)
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// controller.enqueue(): 스트림에 데이터 청크를 추가하여 클라이언트로 전송
// SSE 형식: "data: {JSON}\n\n" - 반드시 'data: '로 시작하고 두 개의 줄바꿈으로 끝나야 함
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'connected',
message: 'SSE 연결',
})}\n\n`
)
);
// 클라이언트의 연결 상태를 추적하는 AbortSignal
const abortSignal = request.signal;
let count = 0;
const interval = setInterval(() => {
// abortSignal.aborted: 클라이언트가 연결을 끊었는지 확인
if (abortSignal.aborted) {
clearInterval(interval);
// controller.close(): 스트림을 정상적으로 종료하고 더 이상 데이터를 전송하지 않음
controller.close();
return;
}
count++;
const data = {
type: 'update',
count,
timestamp: new Date().toISOString(),
};
// 주기적으로 클라이언트에게 업데이트 데이터 전송
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
}, 1000);
// 클라이언트 연결 종료 이벤트 리스너
// 브라우저 탭 닫기, EventSource.close() 호출 등의 상황 처리
abortSignal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
- 서버에서 SSE 연결 법
- 이전에 서버에서는 스트리밍 형태로 데이터를 보내야 한다는 것을 언급
- 이를 위해 ReadableStream 형태로 보냄.
- 이따 내부에 start 콜백 함수는 클라이언트가 연결을 요청했을 때 실행 됨.
- ReadableStream 내부에서 연결 관리 및 상태 감시 하는 메서드
- controller 객체 -> 연결 관리
- request 객체의 값인 signal은 요청자와의 연결 상태를 추적할 수 있게 함. 이것을 abortSignal로 괸리 -> 끊기면 true가 됨
- 데이터를 클라이언트에게 보내는 형식
- 연결을 관리 하는 controller 객체의 enqueue 메서드를 호출해서 데이터를 스트림에 추가함. (큐이니 선입선출)
- 데이터 형식은 무조건 "data: { 내용 }\n\n" 줄바꿈행 두개는 무조건 붙어있어야 한다함.
- TextEncoder는 문자열을 바이트 배열로 변환해줌. ReadableStream이 Uint8Array형태의 데이터를 요구하기 때문에 데이터를 이 형식으로 인코딩 해준뒤 큐에 넣어줘야 함.
- 클라이언트가 데이터를 감지하는 로직
- 위에서 언급 된 컨트롤러를 통해 내부 큐에 데이터가 추가된다.
- 브라우저는 이를 감지 후 큐에서 데이터를 꺼내서 자동으로 클라이언트 EventSource에게 전송
- 이 같은 로직이 완성되는 이유느 ReadableStream은 브라우저의 스트림 API 이기 때문
- 서버가 데이터를 추가하면, 스트림의 내부 버퍼(큐)에 들어감
- 클라이언트 측의 EventSource는 이 스트림을 리슨 중이라, 변화 감지되면 자동으로 꺼내와서 파싱함.
- 브라우저는 message 이벤트를 발생함.
Client
'use client';
import { useEffect, useState } from 'react';
export default function Page() {
const [data, setData] = useState('');
useEffect(() => {
const es = new EventSource('/api/sse');
es.onmessage = (e) => {
const a = JSON.parse(e.data).count;
setData(a);
};
return () => es.close();
}, []);
return <div>Time: {data}</div>;
}
마운트 되면 서버에 sse 연결 요청 보낸다.
es는 onmessage 메서드를 통해 응답을 리슨 함
(서버에선 현재 interval 마다 카운트를 1씩 더한 것을 보내고 있어서 이것을 캐치 중임)
언마운트시 연결 종료.
만약 DOM으로 종료 로직을 실행시키고 싶다면, useRef<EventSource>에 es 인스턴스를 담은 뒤 종료 함수를 생성하면 된다.
***커스텀 이벤트 생성하는 방법 및 클라이언트 쪽에서 이를 감지하는 방법***
서버에서 이벤트 생성
[ 형식: event: 이벤트명\ndata: 데이터\n\n ]
// 기본 메시지 (event 필드 없음)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
);
// 특정 이벤트 전송 (event 필드 추가)
controller.enqueue(
encoder.encode(`event: userJoined\ndata: ${JSON.stringify({ userId: 123, name: '홍길동' })}\n\n`)
);
controller.enqueue(
encoder.encode(`event: notification\ndata: ${JSON.stringify({ message: '새 알림' })}\n\n`)
);
controller.enqueue(
encoder.encode(`event: error\ndata: ${JSON.stringify({ code: 500, message: '서버 오류' })}\n\n`)
);
클라이언트에서 이벤트 감지
[ 핵심: 서버의 event: 값과 클라이언트의 addEventListener 첫 번째 인자가 일치해야 함 ]
const eventSource = new EventSource('/api/sse');
// 기본 메시지 수신 (event 필드가 없는 경우)
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('기본 메시지:', data);
};
// 특정 이벤트 수신
eventSource.addEventListener('userJoined', (event) => {
const data = JSON.parse(event.data);
console.log('유저 입장:', data.name);
});
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
console.log('알림:', data.message);
});
eventSource.addEventListener('error', (event) => {
const data = JSON.parse(event.data);
console.log('에러:', data.code, data.message);
});
*** app router에서 better-sse를 사용할 수 없는 이유***
server에서 ReadableStream의 controller를 통해 버퍼에 데이터를 추가할려면 \n\n 같은 형식을 지켜야함.
이러한 규약을 항상 기억하는 것을 피하고 싶어서 라이브러리가 없나 찾아봤더니 better-see 를 알게 됨.
server 내에서 session을 생성한 후 위에선 언급한 규약을 고려 안하고 데이터 넣을 수 있는 장점이 있었지만, api route 에서 사용할 경우 클라이언트 쪽에서 EventSource를 사용함에도 fetch API 쓰냐면서 에러를 띄움.
클로드에 물어본 결과 아래와 같이 띄워줌
better-sse는 Node.js의 http.IncomingMessage와 http.ServerResponse를 필요로 하는데, Next.js App Router의 Route Handler는 Web API 기반이라 호환되지 않습니다.
better-sse를 사용하려면 Pages Router의 API Routes를 사용하거나, 기존 ReadableStream 방식을 사용해야 합니다.
정리하자면 아래와 같다.
Next.js App Router는 Web Fetch API 기반 (Request/Response)
better-sse는 Node.js HTTP API 기반 (IncomingMessage/ServerResponse)
두 API가 호환되지 않음
Next 문서에도
NextRequest
NextRequest extends the Web Request API with additional convenience methods.
이렇게 나와서 사용 불가라는데 Web Fetch API와 Node.js HTTP API 기반의 차이에 대해 한 번 공부해봐야겠다.
'FrontEnd > Next.js' 카테고리의 다른 글
| app router 기준 렌더링 로직을 비교해보기 (0) | 2025.12.02 |
|---|---|
| revalidatePath로 캐시 무효화 시 마주칠 수 있는 상황 (0) | 2025.10.02 |
| Next.js 로 블로그 개발하면서 겪었던 트러블 슈팅 모음 (0) | 2025.10.01 |
| 아이템 리스트 페이지의 서버 컴포넌트를 최적화 시켜보기(fetch 캐싱 옵션) (0) | 2025.09.19 |
| 서버 컴포넌트에서 fetch와 캐싱 동작: SSR, SSG, ISR 관점에서 정리 (0) | 2025.09.13 |