daily

23.05.27. SSE(Server-Sent-Event) 구현해보기

Juhyuck 2023. 5. 27. 15:04
728x90

ChatGPT 사이트를 클론 코딩하기로 했을 때 가장 궁금한 건 두가지였다. 

1. node.js환경에서 openAI API연결하는 것 (두 글에 걸쳐 구현 완료 했다. [0], [1])

2. 실제 타이핑 치듯 글자가 나오는 인터페이스

 

2.는 어떻게 구현하나 했더니, 우선 openAI API에서 stream 옵션을 true로 해서 조각내어 받고,

SSE(Server-Sent-Event)라는 기능으로 구현하는 것이었다. 

 

처음엔 소켓으로 하는가 싶었는데, 그것보다 간단하고, 일방향으로 전송하는 방식의 기능으로 쉽게 구현할 수 있었다.

 

1. 서버

서버에서는 아래와 같이 헤더에 text/event-stream이라는 content-type으로 설정하고, cache는 없게 설정해주고, 데이터를 setInterval을 통해 반복해서 보내주면 된다.

전체 코드는,

const express = require("express");
const app = express();

// CORS 설정
const cors = require("cors");
app.use(
    cors({
        origin: ["http://127.0.0.1:3000"],
        credentials: true,
    })
);

app.get("/events", (req, res) => {
    res.set({
        "Cache-Control": "no-cache",
        "Content-Type": "text/event-stream",
        "Connection": "keep-alive",
    });
    res.flushHeaders();
    const sentence =
        "이것은 긴 문장입니다. 한 단어씩 조각내어 클라이언트로 보낼 예정입니다.";
    let index = 0;

    // Send a character every second
    const intervalId = setInterval(() => {
        res.write(`data: ${sentence[index]}\n\n`);
        index++;
        console.log(index, sentence.length);
        if (index === sentence.length) {
            res.write("data: EOS\n\n");
            clearInterval(intervalId);
            return res.end()            
        }
    }, 100);
    // Stop sending events when the sentence has been fully sent
});

app.listen(4000, () => {
    console.log("Server is running on port 4000");
});

이때 보내는 값(즉, res.write()에 들어가는 값)은 "data: 보낼 값\n\n"으로 보내야 "보낼 값"이 인식된다.

 

2. 클라이언트

이걸 받는 클라이언트는 EventSource 객체를 생성해서 다룰 수 있다. 전체 코드는,

<!DOCTYPE html>
<html>
    <body>
        <div id="result"></div>

        <script>
            const source = new EventSource("http://localhost:4000/events", {
                withCredentials: true,
            });

            source.onmessage = function (event) {
                console.log(event);
                if (event.data === "EOS") {
                    source.close();
                } else {
                    document.getElementById("result").innerHTML += event.data;
                }
            };

            source.onopen = function (event) {
                console.log(event);
                console.log("Connection opened");
            };

            source.onerror = function (event) {
                if (event.target.readyState === EventSource.CLOSED) {
                    console.log("Connection closed");
                } else if (event.target.readyState === EventSource.CONNECTING) {
                    console.log("Reconnecting...");
                }
            };
        </script>
    </body>
</html>

여기서, onmessage 메서드는 메세지를 받았을 때 동작하고, onopen은 연결되었을 때, onerror는 에러가 났을 때 실행된다. 

 

실행시켜 보면,

 

한글자씩 잘 온다.

 

이때, SSE가 종료되는 것을 판단하는 것은 event.target.readyState 값인데, 이게 0이면 연결이 끊어진 경우, 1이면 연결되어 있고, 메세지 전달이 가능한 상태, 2면 연결이 종료된 것을 의미한다.

이 값은 EventSource.CONNECTING = 0, EventSource.OPEN = 1, EventSource.CLOSED = 2 로 정의되어 있다.

콘솔 로그를 보면, 

여기에 EventSource로부터 상속받은 내용을 확인할 수 있다.

 

 

 

3. 전송 종료 문제

처음엔 코드를 다르게 짰다. 서버에서 res.end()를 하면, 클라이언트의 event.target.readyState가 2가되면서 종료되도록 구현했는데 아무리해도 끝나지 않고 계속해서 재연결을 시도해서 무한 반복을 하는 문제가 발생했다.

 

이해가 안되는 점은... 콘솔 로그로 객체 전체를 표시해봤을 때는 readyState가 2로 나오는데, 실제 해당 값만 출력해보면 0으로 나오는데 이게.....참. 답답했다. 우선 이해한 바를 정리하면...

 

console.log로 객체를 로깅할 때는 당시의 객체 값을 찍어내는 것이 아니라, 참조하고 있다가 객체를 펼쳐보면 그 때의 값을 보여준다는 점이다. 그러니까 실제 해당 값 즉, event.target.readyState 값을 출력했을 때의 값이 진짜 당시의 값인 것.

 

아래는 당췌 이해가 되지 않던 상황의 콘솔로그와 코드

<!DOCTYPE html>
<html>
    <body>
        <div id="result"></div>

        <script>
            const source = new EventSource("http://localhost:4000/events", {
                withCredentials: true,
            });

            source.onmessage = function (event) {
                console.log(event);
                if (event.data === "EOS") {
                    //source.close();
                } else {
                    document.getElementById("result").innerHTML += event.data;
                }
            };

            source.onopen = function (event) {
                console.log(event);
                console.log("Connection opened");
            };

            source.onerror = function (event) {
                console.log("event.target object", event.target);
                console.log("event.target.readyState", event.target.readyState)
                if (event.target.readyState === EventSource.CLOSED) {
                    console.log("Connection closed");
                } else if (event.target.readyState === EventSource.CONNECTING) {
                    console.log("Reconnecting...");

                    source.close()
                }
            };
        </script>
    </body>
</html>

즉, source.close()가 실행되야 event.target.readyState값이 2로 바뀌고, 서버에서 res.end()를 해주는 것으로는 그 값이 2가 아니라 0으로 바뀐다는 것을 확인했다. 브라우저에 따라 다르다는데 크롬과 엣지 모두 res.end()로는 readyState가 2가 되지 않고 0으로 바뀌었다. 

 

그래서, 전부 전송이 끝난 다음 EOS라는 문자열(End of String)을 보내주고, onmessage 메서드에서 해당 문자열을 만나면 source.close()를 통해 연결을 종료하는 방법으로 구현했다. 

 

결과적으로 console.log를 철석같이 믿으면 안된다는 점도 함께 배웠다.