서버 전송 이벤트로 업데이트 스트리밍

서버 전송 이벤트(SSE)는 HTTP 연결을 통해 서버에서 클라이언트로 자동 업데이트를 전송합니다. 연결이 설정되면 서버가 데이터 전송을 시작할 수 있습니다.

SSE를 사용하여 웹 앱에서 푸시 알림을 보낼 수 있습니다. SSE는 한 방향으로 정보를 전송하므로 클라이언트로부터 업데이트를 받지 못합니다.

SSE의 개념은 익숙할 것입니다. 웹 앱은 서버에서 생성한 업데이트 스트림을 '구독'하고 새 이벤트가 발생할 때마다 클라이언트에 알림을 전송합니다. 하지만 서버 전송 이벤트를 제대로 이해하려면 이전 AJAX 기술의 한계를 이해해야 합니다. 여기에는 다음과 같은 콘텐츠가 포함됩니다.

  • 폴링: 애플리케이션이 반복적으로 서버에 데이터를 폴링합니다. 이 기법은 대부분의 AJAX 애플리케이션에서 사용됩니다. HTTP 프로토콜에서는 데이터 가져오기가 요청 및 응답 형식을 따릅니다. 클라이언트는 요청을 하고 서버가 데이터로 응답할 때까지 기다립니다. 사용할 수 있는 항목이 없으면 빈 응답이 반환됩니다. 추가 폴링은 더 많은 HTTP 오버헤드를 생성합니다.

  • 장기 폴링 (GET / COMET): 서버에 사용 가능한 데이터가 없는 경우 서버는 새 데이터가 제공될 때까지 요청을 열어 둡니다. 따라서 이 기법을 흔히 'Hanging GET'이라고 합니다. 정보를 사용할 수 있게 되면 서버가 응답하고 연결을 종료하고 프로세스가 반복됩니다. 따라서 서버는 지속적으로 새 데이터로 응답합니다. 이를 설정하기 위해 개발자는 일반적으로 '무한' iframe에 스크립트 태그를 추가하는 등의 방법을 사용합니다.

서버 전송 이벤트는 처음부터 효율적으로 설계되었습니다. SSE와 통신할 때 서버는 초기 요청을 하지 않고도 원할 때마다 데이터를 앱에 푸시할 수 있습니다. 즉, 업데이트가 발생할 때 서버에서 클라이언트로 스트리밍할 수 있습니다. SSE는 서버와 클라이언트 간에 단일 단방향 채널을 엽니다.

서버 전송 이벤트와 장기 폴링의 주요 차이점은 SSE는 브라우저에서 직접 처리되며 사용자는 메시지를 수신 대기하면 된다는 점입니다.

서버 전송 이벤트와 WebSocket 비교

WebSocket 대신 서버 전송 이벤트를 선택하는 이유는 무엇일까요? 좋은 질문입니다.

WebSockets에는 양방향 전이중 통신을 할 수 있는 다양한 프로토콜이 있습니다. 양방향 채널은 게임과 메시지 앱, 양쪽 방향에서 거의 실시간에 가까운 업데이트가 필요한 모든 사용 사례에 더 적합합니다.

그러나 서버에서의 단방향 통신만 필요한 경우도 있습니다. 예를 들어 친구가 자신의 상태, 주식 시세 표시기, 뉴스 피드 또는 기타 자동화된 데이터 푸시 메커니즘을 업데이트하는 경우가 있습니다. 즉, 클라이언트 측 웹 SQL 데이터베이스 또는 IndexedDB 객체 저장소에 대한 업데이트입니다. 서버에 데이터를 보내야 하는 경우 XMLHttpRequest는 항상 친구입니다.

SSE는 HTTP를 통해 전송됩니다. 작업하기 위해 특별한 프로토콜이나 서버 구현이 필요하지 않습니다. WebSocket에는 프로토콜을 처리하기 위한 전이중 연결과 새로운 WebSocket 서버가 필요합니다.

또한 서버 전송 이벤트에는 자동 재연결, 이벤트 ID, 임의 이벤트 전송 기능 등 WebSocket에 설계되지 않은 다양한 기능이 있습니다.

JavaScript로 EventSource 만들기

이벤트 스트림을 구독하려면 EventSource 객체를 만들어 스트림의 URL을 전달합니다.

const source = new EventSource('stream.php');

다음으로 message 이벤트의 핸들러를 설정합니다. 선택적으로 openerror를 수신 대기할 수 있습니다.

source.addEventListener('message', (e) => {
  console.log(e.data);
});

source.addEventListener('open', (e) => {
  // Connection was opened.
});

source.addEventListener('error', (e) => {
  if (e.readyState == EventSource.CLOSED) {
    // Connection was closed.
  }
});

서버에서 업데이트가 푸시되면 onmessage 핸들러가 실행되고 새 데이터를 e.data 속성에서 사용할 수 있습니다. 놀라운 점은 연결이 닫힐 때마다 3초 후에 자동으로 소스에 다시 연결된다는 것입니다. 서버 구현에서 재연결 제한 시간을 제어할 수도 있습니다.

이제 모두 완료되었습니다. 이제 클라이언트가 stream.php의 일정을 처리할 수 있습니다.

이벤트 스트림 형식

소스에서 이벤트 스트림을 전송하려면 SSE 형식을 따르는 text/event-stream Content-Type과 함께 제공되는 일반 텍스트 응답을 구성해야 합니다. 기본 형식으로 응답에는 data: 줄, 메시지, 스트림 종료를 위한 '\n' 문자 두 개가 차례로 포함되어야 합니다.

data: My message\n\n

다선형 데이터

메시지가 더 긴 경우 data: 줄을 여러 개 사용하여 메시지를 나눌 수 있습니다. data:로 시작하는 두 개 이상의 연속된 행은 단일 데이터로 취급됩니다. 즉, 하나의 message 이벤트만 실행됩니다.

각 줄은 하나의 '\n'으로 끝나야 합니다(마지막은 2로 끝나야 함). message 핸들러에 전달되는 결과는 줄바꿈 문자로 연결된 단일 문자열입니다. 예를 들면 다음과 같습니다.

data: first line\n
data: second line\n\n</pre>

그러면 e.data에 '첫 번째 줄\n두 번째 줄'이 생성됩니다. 그런 다음 e.data.split('\n').join('')를 사용하여 '\n' 문자 없이 메시지를 재구성할 수 있습니다.

JSON 데이터 보내기

여러 줄을 사용하면 구문을 중단하지 않고 JSON을 전송할 수 있습니다.

data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n

해당 스트림을 처리할 수 있는 클라이언트 측 코드는 다음과 같습니다.

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.id, data.msg);
});

ID를 이벤트와 연결

id:로 시작하는 줄을 포함하여 스트림 이벤트와 함께 고유 ID를 전송할 수 있습니다.

id: 12345\n
data: GOOG\n
data: 556\n\n

ID를 설정하면 브라우저에서 마지막으로 실행된 이벤트를 추적할 수 있으므로 서버 연결이 끊어지면 특수 HTTP 헤더 (Last-Event-ID)가 새 요청으로 설정됩니다. 그러면 브라우저에서 실행에 적합한 이벤트를 결정할 수 있습니다. message 이벤트는 e.lastEventId 속성을 포함합니다.

재연결 제한 시간 제어

브라우저는 각 연결이 닫히고 약 3초 후에 소스에 다시 연결을 시도합니다. retry:로 시작하는 줄과 다시 연결을 시도하기 전에 기다려야 하는 시간(밀리초)을 포함하여 제한 시간을 변경할 수 있습니다.

다음 예에서는 10초 후에 재연결을 시도합니다.

retry: 10000\n
data: hello world\n\n

이벤트 이름 지정

단일 이벤트 소스는 이벤트 이름을 포함하여 다양한 유형의 이벤트를 생성할 수 있습니다. event:로 시작하는 줄이 있고 그 뒤에 이벤트의 고유한 이름이 있으면 이벤트는 이 이름과 연결됩니다. 클라이언트에서 이벤트 리스너를 설정하여 특정 이벤트를 수신할 수 있습니다.

예를 들어 다음 서버 출력은 일반 '메시지' 이벤트, 'userlogon', 'update' 이벤트라는 세 가지 유형의 이벤트를 전송합니다.

data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n

클라이언트에서 이벤트 리스너를 설정한 경우:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.msg);
});

source.addEventListener('userlogon', (e) => {
  const data = JSON.parse(e.data);
  console.log(`User login: ${data.username}`);
});

source.addEventListener('update', (e) => {
  const data = JSON.parse(e.data);
  console.log(`${data.username} is now ${data.emotion}`);
};

서버 예

다음은 PHP의 기본 서버 구현입니다.

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // recommended to prevent caching of event data.

/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
**/

function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

$serverTime = time();

sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>

다음은 Express 핸들러를 사용하여 Node JS를 유사하게 구현한 것입니다.

app.get('/events', (req, res) => {
    // Send the SSE header.
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Sends an event to the client where the data is the current date,
    // then schedules the event to happen again after 5 seconds.
    const sendEvent = () => {
        const data = (new Date()).toLocaleTimeString();
        res.write("data: " + data + '\n\n');
        setTimeout(sendEvent, 5000);
    };

    // Send the initial event immediately.
    sendEvent();
});

sse-node.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
        const content = document.createElement('div');
        content.textContent = e.data;
        document.body.append(content);
    };
    </script>
  </body>
</html>

이벤트 스트림 취소

일반적으로 브라우저가 닫히면 이벤트 소스에 자동으로 다시 연결되지만 클라이언트 또는 서버에서 이 동작을 취소할 수 있습니다.

클라이언트에서 스트림을 취소하려면 다음을 호출합니다.

source.close();

서버에서 스트림을 취소하려면 text/event-stream가 아닌 Content-Type로 응답하거나 200 OK 이외의 HTTP 상태(예: 404 Not Found)를 반환합니다.

두 방법 모두 브라우저가 연결을 다시 설정하지 못하도록 합니다.

보안에 대한 한마디

EventSource에서 생성된 요청에는 가져오기와 같은 다른 네트워크 API와 동일한 출처 정책이 적용됩니다. 다른 출처에서 서버의 SSE 엔드포인트에 액세스할 수 있도록 하려면 교차 출처 리소스 공유 (CORS)를 사용하여 사용 설정하는 방법을 읽어보세요.