Argano のジェソンです。 server sent eventを実装する方法について紹介します。


Server Sent Event(SSE)란?

서버에서 클라이언트 단방향 통신으로써 서버가 원할 때 클라이언트에게 이벤트를 사용해 데이터를 송신할 수 있는 기술입니다.

Express에서 구현

express 환경에서 구현해보겠습니다.

서버에서는 클라이언트에게 데이터를 보내줄 API를 만듭니다. 여기에서 주의할 점은

  1. response 헤더에 Content-Type 을’text/event-stream"으로 설정하여 응답해야 클라이언트에서 SSE 이벤트 수신을 할 수 있습니다.
  2. 각 이벤트는 "\n"으로 끝나는 텍스트 블록으로 전송됩니다.
  3. SSE 표준은 오직 GET만을 허용하기 때문에 GET으로 API를 작성해줍니다.

mdn-using server-sent events

app.js

const express = require("express");

const app = express();

app.get('/sse', (req, res) => {
    res
        .setHeader("Content-Type", "text/event-stream") // 이벤트 스트림 컨텐츠 타입 선언
        .setHeader("Connection", "keep-alive") // 업데이트를 가져오는데 사용할 커넥션 설정
        .setHeader("Cache-Control", "no-cache") // 캐시를 비활성화

    setInterval(() => {
        res
            .status(200)
            .write(
                'event: message\n'+
                'data: hello Argano\n\n'
            );
    }, 2000);
});

app.listen(3000, () => {
    console.log("start express server on port 3000");
});

클라이언트에서는 EventSource 라는 객체를 이용하여 서버에서 송신한 이벤트 데이터를 수신합니다. onopen 이벤트는 처음 1회만 발화하고 그 이후 onmessage로 서버로부터 온 데이터를 수신합니다. IE 초기버전 이외에는 모든 브라우저에서 SSE를 지원하지만 만일의 상황을 대비해 SSE를 지원하는지 체크 해주는것이 좋습니다.

index.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>index.html</title>
    </head>
    <body>
        <h1>index page</h1>

        <div>
            <button id="request-button">Request!</button>
            <div id="sse-data"></div>
        </div>
    </body>
    <script>
        // SSE 지원여부 체크
        if(typeof(EventSource) !== "undefined") {
            console.log("sse available");
        } else {
            console.log("sse not available");
        }

        const requestButton = document.getElementById("request-button");
        requestButton.onclick = () => {
            const sseDataElement = document.getElementById("sse-data");
            const eventSource = new EventSource("/sse");
            eventSource.onopen = () => {
                console.info("open");
            }
            eventSource.onmessage = (event) => {
                const p = document.createElement("p");
                p.textContent = event.data;
                sseDataElement.append(p);
            }
            eventSource.onerror = () => {
                console.error("error");
                eventSource.close();
            }
        }
    </script>
</html>

nextjs14 app router에서 구현

nextjs 14버전 app router를 사용하는 환경에서 구현해보겠습니다.

Express와 마찬가지로 Content-Type에는 반드시 "text/event-stream"를 설정해주도록 합니다.

app/api/route.ts

export const dynamic = "force-dynamic";

export async function GET() {
    return new Response(
        "event: message\n" +
        "data: hello Argano\n\n",
        {
            headers: {
                Connection: "keep-alive",
                "Content-type": "text/event-stream",
                "Cache-Control": "no-cache"
            }
        }
    );
}

클라이언트컴포넌트에선 useState를이용해 서버컴포넌트로부터 수신한 데이터를 화면에 표시해줍니다.

app/home.tsx

"use client"

import styles from "./page.module.css";
import { useState } from "react";

export default function Home() {
    const [data, setData] = useState("");
    const handleClick = async () => {
        setString("");
        const eventSource = new EventSource("/api");
        eventSource.onopen = () => {
            console.info("Open EventSource");
        };
        eventSource.onmessage = (event: MessageEvent<any>) => {
            console.info("Message: ", event.data);
            setData(event.data);
        }
        eventSource.onerror = (event: Event) => {
            console.error("error");
            eventSource.close();
        }
    }

    return (
        <main className={styles.main}>
            <div className={styles.description}>
                <button
                    onClick={handleClick}
                >
                    server sent event call
                </button>
                <p className={styles.card}>{data}</p>
            </div>
        </main>
    );
}

ReadableStream 오브젝트를 이용하여 구현하기

이번에는 ReadableStream 객체를 이용하여 구현해보겠습니다.

서버컴포넌트쪽에서 readableStream을 사용하여 body에 담아 전송하면 클라이언트 쪽에서 EventSource 객체로 수신이 가능합니다.

app/api/route.ts

export const dynamic = "force-dynamic";

function sleep(time: number) {
    return new Promise((resolve) => {
        setTimeout(resolve, time);
    });
}

export async function GET() {
    const datas = ["H", "e", "l", "l", "o", "A", "r", "g", "a", "n", "o"];
    const readableStream = new ReadableStream({
        start(controller) {
            (async () => {
                for (const d of datas) {
                    controller.enqueue(`data: ${d}\n\n`);
                    await sleep(300);
                }
                controller.close();
            })();
        }
    });
    return new Response(readableStream, {
            headers: {
                Connection: "keep-alive",
                "Content-type": "text/event-stream",
                "Cache-Control": "no-cache",
            }
        });
}

클라이언트컴포넌트에선 마찬가지로 useState를이용해 서버컴포넌트로부터 수신한 데이터를 화면에 표시해줍니다. 여기서 주의할 점은 nextjs 13.4부터 react.StrictMode가 default로 true가 되어있기 때문에 next dev로 개발할 때 setState나 useEffect가 두 번 실행되는 것을 조심할 필요가 있습니다.

app/home.tsx

"use client"

import styles from "./page.module.css";
import { useState } from "react";

export default function Home() {
    const [content, setContent] = useState<string>("");
    const handleClick = async () => {
        const eventSource = new EventSource("/api");
        eventSource.onopen = () => {
            setContent("");
            console.info("Open EventSource");
        };
        eventSource.onmessage = (event: MessageEvent<any>) => {
            console.info("Message: ", event.data);
            setContent((prevState) => {
                return prevState + event.data;
            });
        }
        eventSource.onerror = (event: Event) => {
            console.error("error");
            eventSource.close();
        }
    }

    return (
        <main className={styles.main}>
            <div className={styles.description}>
                <button
                    onClick={handleClick}
                >
                    server sent event call
                </button>
                <p className={styles.card}>{content}</p>
            </div>
        </main>
    );
}

이상으로 Server Sent Event를 구현하는 방법에 대해 알아보았습니다. 읽어주셔서 감사합니다.