본문 바로가기
FrontEnd/Next.js

Next.js로 SSE 환경 만들기 (app router에서 better-sse 사용 불가 이유)

by 위그든씨 2025. 11. 23.

1. SSE란?

  • Server-Sent Events
  • 웹소켓과 마찬가지로 명시적으로 연결을 끊지 않는 한 서버와 클라이언트의 연결은 쭉 유지되는 통신
  • 클라이언트는 서버로부터 정보를 실시간으로 받을 수 있을 뿐 요청을 보내지는 않음 (보낼거면 별도 요청)
  • 알람 기능 구현, 뉴스피드 받기 등등. 클라이언트의 동작이 서버에 등록될 필요는 없는 기능에서 활용
  • 연결 방법 (CS적으로)
    • 기본적으로 HTTP 연결을 열어둔 상태로 유지
    • 클라이언트가 서버에 요청을 처음 보낸다 (연결 요청)
    • 서버는 응답을 완료하지 않고 해당 HTTP 연결을 계속 열어둠 -> 해당 연결을 통해 데이터를 지속적으로 보냄
    • 각 데이터는 "data:"형식으로 전성 됨
  • 위의 연결 방법으로 유지가 가능한 이유
    • HTTP 의 Content-Length 헤더를 사용하지 않거나 ( 길이가 정해져있다면 그만큼 받으면 끝이구나를 알 수 있음 ) 
    • Transfer-Encoding: chunked를 사용하기 때문  ( 응답을 청크 단위로 나눠 보내면 클라이언트는 다음 청크가 또 올지 종료 청크가 올지 알 수 없어서 계속 유지 )
    • 위의 방식으로 응답이 언제 끝날지 클라이언트는 미리 알 수 없으므로, 연결이 닫힐 때까지 데이터를 계속 받을 수 있음. 
      1.  TCP 4way-handshake로 연결 종료 하는 방법이랑 연관이 된건 아님. (처음엔 해당 과정에서 close-wait을 무한정 두는 줄 알았음) 
      2. 연결을 유지한다는 것은 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 기반의 차이에 대해 한 번 공부해봐야겠다.