티스토리 뷰

CS/Web

[Web] SSE(Server Sent Events) 란?

다음김 2024. 9. 1. 19:38

 
웹 서비스를 사용하다 보면 화면이 깜빡이지 않아도(클라이언트가 자체적으로 새로고침하지 않아도) 웹 페이지가 자동 업데이트되는 경우를 볼 수 있다. 유트브에서 영상 시청 중 다른 사람이 좋아요 버튼을 눌렀음에도 좋아요 수가 내 화면에 실시간으로 반영된다거나, 내가 구독한 뉴스 피드가 업로드된 경우 나에게 바로 알림이 오는 경우를 예로 들 수 있다. 사실 HTTP 프로토콜 기반의 웹 어플리케이션에서는 클라이언트의 요청이 있어야 서버의 응답이 가능하다. 하지만 앞선 경우는 클라이언트의 요청 없이도 서버로부터 데이터가 전달된 것이다. 이렇게 뉴스 피드 또는 댓글 알림 등 실시간으로 서버의 변경 사항을 클라이언트에게 전달해줘야 하는 경우, 이를 구현하기 위한 클라이언트 - 서버 간 실시간 소통 방법에는 여러가지가 있다. 이 글의 주제는 SSE이지만 자세히 알아보기 전 다른 방법들도 간단히 살펴보자.


 

클라이언트 서버 간 실시간 소통 방법

(Short) Polling

클라이언트가 주기적으로 요청을 보내 응답을 받는 방법으로, 즉 클라이언트가 일정 시간마다 서버의 데이터가 갱신되었는지 확인하는 것이다. 클라이언트와 서버 모두 구현이 단순하지만, 매 요청마다 TCP 연결을 열고 닫아야 하는 등(매번 3-way handshaking 수행) 서버의 부담이 크기 때문에, 요청 주기를 넉넉히 잡아도 될 정도로 실시간성이 중요하지 않은 경우에 적절하다.
 
 

Long Polling

클라이언트가 요청을 보내고 서버에서 변경이 일어날 때까지 대기한 후, 응답이 도착하면 재요청을 보내는 방식이다. 이 방식은 서버로부터 응답으로 받고 나면 다시 연결을 요청하기 때문에, 데이터가 빈번하게 바뀐다면 클라이언트 → 서버로의 연결 요청도 증가하게 된다. 따라서 서버의 상태가 자주 바뀌지 않는 경우에 적합하다. 앞선 일반적인 폴링 방식보다는 실시간성이 증가하였지만, 요청과 응답이 여전히 1:1이기 때문에 서버 부하가 발생한다.
 
 

SSE(Server Sents Event)

한번 클라이언트 - 서버 연결 후 일정 시간 동안 서버에서 변경이 발생할 때마다 데이터를 전송받는 단방향 통신 방법이다. 서버 응답마다 다시 요청해야 하는 Long Polling 방식보다는 통신 횟수가 적어 효율적이며(하나의 TCP 연결을 유지), 서버에서 실시간으로 이벤트 전송이 가능하여 앞선 두 방식보다 실시간성이 더 높다. 또한 HTTP의 Persistent Connections를 기반으로 하기 때문에 새로운 프로토콜을 도입할 필요가 없다는 장점이 있다.
 
 

WebSocket

Restful API, SOAP, GraphQL 등 HTTP를 활용한 방식들은 클라이언트가 서버에게 요청을 보내고 서버는 요청이 있을 때에만 응답을 전달할 수 있다. 이러한 점을 극복한 클라이언트 - 서버 간의 양방향 통신 방법이 바로 WebSocket이다. 즉 ws:// 또는 wss:// 웹 소캣 프로토콜을 기반으로 클라이언트 - 서버 간에 동등하게 메시지를 주고 받을 수 있다. 따라서 온라인 게임, 주식 관련 앱, 채팅 앱과 같이 실시간으로 서버로부터의 업데이트가 필요한 서비스에서 주로 사용하는 방식이다. 



서비스 특성에 따라 양방향 통신이 아닌 서버로부터의 단방향 통신만으로 충분한 경우가 존재한다. 예를 들어 서버의 작업 시간이 긴 경우 프로그래스 바 표시, 실시간으로 내용이 업데이트되는 SNS, 뉴스, 주식 거래 서비스, 기타 실시간 모니터링 서비스 등은 서버에서의 변경 사항을 클라이언트에게 실시간으로 보내기만 하면 된다. 이 경우에는 웹 소캣보다는 SSE가 더 적합하다. 그럼 이 SSE 통신은 어떤 흐름으로 실행될까?
 
 
 

SSE 실행 흐름

SSE 통신의 실행 흐름 크게 '클라이언트의 연결 요청 → 서버의 요청 수락 → 서버의 이벤트 발송 → 이벤트 수신한 클라이언트의 동작 → 연결 종료 시 클라이언트의 재연결 요청'으로 구성된다. 각 단계에서 어떤 일이 발생하는지 자세히 살펴보자.
 
 

1. 클라이언트의 연결 요청

처음 SSE 통신을 위해 클라이언트 → 서버로의 연결 요청이 필요하다. (즉 이벤트 구독 요청) 이때
SSE 통신을 통해 이벤트 스트림이라는 형식의 메시지를 수신하겠다는 의미의 Accept: text/event-stream 헤더가 포함되어야 한다. 또한 이벤트를 구독하기 위해 HTTP의 지속적 연결을 사용하는데, HTTP1.0 이하의 프로토콜은 지속적 연결이 기본 설정이 아니기 때문에, 해당 프로토콜을 사용하는 경우에는 명시적으로 Connection: keep-alive 헤더를 설정해주어야 한다.

GET /sse HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache



2. 서버의 요청 수락

클라이언트의 요청을 수신한 서버는 이벤트 스트림 형식으로 작성된 메시지임을 명시하는 헤더가 포함된 응답을 전달한다. (Content-Type: text/event-stream;charset=UTF-8, Connection: keep-alive, Transfer-Encoding: chunked)

이러한 설정으로 클라이언트가 요청을 보내 만들어진 연결이 서버가 응답한 이후로도 계속 유지되는 것이다. 참고로 서버는 동적으로 생성된 컨텐츠를 실시간으로 스트리밍하기 때문에 본문의 크기를 미리 알 수 없어 Transfer-Encoding 헤더 값을 chunked로 보내는 것이다.

HTTP/1.1 200
Content-Type: text/event-stream;charset=UTF-8
Connection: keep-alive
Transfer-Encoding: chunked

 

  

3. 서버의 이벤트 발송

클라이언트 - 서버 간 하나의 TCP 연결이 설정된 후 서버는 특정 이벤트가 있을 때마다 클라이언트에게 비동기적으로 메시지를 전송할 수 있다. (SSE에서 클라이언트는 서버가 보내는 메시지를 수신만 할 수 있고 메시지를 서버에게 전송할 수는 없는 단방향 통신이다.) 이때 서버가 보내는 데이터는 utf-8로 인코딩된 텍스트로, 서로 다른 이벤트는 다음과 같이 줄바꿈 문자 2개(\n\n)로 구분되며, 다시 각 이벤트는 줄바꿈 문자 하나(\n)로 구분되는 name: value 필드로 구성된다. SSE 메시지(또는 이벤트)의 자세한 형식은 뒤에서 살펴보자.

event: type1
data: An event of type1.

event: type2
data: An event of type2.

 

 

4. 이벤트 수신한 클라이언트의 동작

클라이언트는 서버로부터 메시지가 도착할 때마다 이에 대해 화면을 업데이트하는 등 필요한 작업 수행한다.

 

5. 연결 종료 시 클라이언트의 재연결 요청

이러한 이벤트 발송 및 수신은 하나의 연결 안에서 지속되는 것으로, 만약 연결이 끊기면 클라이언트는 자동으로 재연결을 요청하여 통신을 재개한다. 필요한 작업을 마쳤다면 클라이언트는 서버에게 연결 종료를 통보하는 메시지를 보냄으로써 연결을 끊을 수도 있다. 만약 서버쪽에서 연결을 종료하고자 한다면, 클라이언트에게 합의된 메시지를 보내 통지한 후 클라이언트가 서버에게 연결 종료 요청을 보내면 된다.
 
 
 

SSE Event Stream 형식 

SSE 통신 시 서버 → 클라이언트로 전달되는 event stream은 UTF-8로 인코딩된 텍스트 기반 데이터로, 스트림의 메시지는 두 개의 줄바꿈 문자(\n\n)로 구분된다.

이때 첫 글자가 콜론(:)인 경우는 주석으로 간주하여 무시되는데, 주석은 timeout으로 클라이언트와의 연결이 끊기는 것을 막기 위해, 즉 연결을 유지하기 위해 서버가 주기적으로 더미 메시지를 보내기 위해 사용된다.

메시지의 각 라인은 하나의 필드로 구성되어 있으며, 각 필드는 '필드 이름: 필드 값' 형식으로 구성되어 있다. 다음은 SSE Event Stream에서 유효한 필드들의 이름이다. (이외의 필드들은 무시됨) 참고로 필드 이름 바로 뒤에 콜론이 없다면 전체 라인이 필드 이름으로 처리되며 이때 필드 값은 빈 문자열이다.
 

event  서버에서 클라이언트로 전달되는 이벤트를 식별하기 위한 문자열로, 클라이언트는 특정 이벤트의 메시지만을 수신하기 위해 이벤트 네임 값을 지정할 수 있다.
data  메시지의 데이터 필드
id  이벤트 id로, 클라이언트 → 서버로 SSE 연결 요청 시 전달되는 Last Event ID(클라이언트가 직전에 전달받은 이벤트 id) 값에 사용
retry  클라이언트 - 서버 간 SSE 연결이 끊겼을 때, 재연결 요청 전 대기 시간 (milliseconds 단위)

 

이벤트 메시지 예시

아래 예시는 총 3개의 메시지가 보내진 것으로(\n\n 기준 3개로 구분됨), 첫번째 메시지는 콜론으로 시작하기 때문에 주석으로 처리되며, 두번째 메시지는 "some text", 세번째 메시지는 "another message\nwith two lines" 데이터를 각각 전달하고 있다. 참고로 세번째 메시지 처럼 연속적인 data 필드는 각 data 필드 값에 줄바꿈 문자(\n)를 추가한 형태의 데이터 하나로 처리된다. 

: this is a test stream

data: some text

data: another message
data: with two lines

 

 

아래 예시는 Named Event, 즉 전송되는 각 이벤트는 event 필드에서 지정한 이름을 가지며, 예를 들어 "userconnect" 이벤트를 구독한 클라이언트는 다른 메시지는 무시하고 해당 이벤트 메시지만을 수신하여 처리할 수 있다. 추가로 data 필드는 다음과 같이 JSON 형식의 문자열일 수 있다.

event: userconnect
data: {"username": "bobby", "time": "02:33:48"}

event: usermessage
data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."}

event: userdisconnect
data: {"username": "bobby", "time": "02:34:23"}

event: usermessage
data: {"username": "sean", "time": "02:34:36", "text": "Bye, bobby."}

 

 

 

자바스크립트, 스프링 이용한 간단 실습 

자바스크립트, 스프링을 이용하여 SSE 통신을 간단하게 구현해보자. 먼저 Controller에서 SSE를 사용하겠다는 헤더를 설정해준 뒤 SSE 이벤트 스트림 형식에 맞게 데이터를 전송해준다. 아래 서버에서 보내는 이벤트 메시지의 의미는, "이벤트 ID는 1, 이벤트 이름은 'Connected', data는 'hello\nworld\n!', 재연결 요청 전 대기 시간은 10초" 라는 것이다.

@RestController
@RequestMapping("/api/v1")
public class SseController {

    @GetMapping(value = "/sse")
    public void sse2(HttpServletResponse response) throws IOException {
        response.setContentType("text/event-stream");
        response.setCharacterEncoding("UTF-8"); //이벤트 스트림 데이터는 항상 UTF-8 인코딩
        PrintWriter writer = response.getWriter();
        writer.write("""
                id: 1\n
                event: Connected\n
                data: hello
                data: world
                data: !
                retry: 10000\n\n
                """);
        writer.close();
    }
}



클라이언트가 웹 브라우저일 경우, 브라우저가 제공하는 JavaScript Web API인 EventSource 객체를 SSE에 통신에 사용할 수 있다. EventSource는 HTML5 웹 표준에 정의되어 있으며, 현대적인 브라우저는 모두 이를 제공한다. 다음과 같이 서버의 SSE 연결 요청 API로 요청을 보내 EventSource 객체를 생성할 수 있으며 onmessage 콜백을 설정하여 클라이언트가 수신한 모든 이벤트를 처리할 수 있다. addEventListener api를 통해서는 특정 이벤트에 대해서만 반응하도록 콜백을 설정할 수 있다. 

let sse = new EventSource("http://localhost:8080/api/v1/sse");
sse.onmessage = (e) => console.log(e.data);
sse.addEventListener("Connected", (e) => console.log(e.data)); //Connected 이벤트에 대해서만 동작

이벤트 스트림에서 수신한 메시지의 data 필드 값 출력

 

 

브라우저의 EventSource 객체는 서버와의 연결이 끊어졌을 때 자동 재접속하는 기능을 내장하고 있다. 따라서 클라이언트 - 서버 간 SSE 연결이 끊긴 경우 앞서 설정한 retry 시간인 10초를 대기한 후 자동 SSE 재연결 요청을 보내고 있다.

 

SSE 연결이 끊긴 경우 자동 재연결 요청

 

 

 

SSE 장단점

SSE의 장단점은 다음과 같다. 

 

장점

- 구현이 간편 (러닝 커브 거의 없음)
- HTTP 기반으로 방화벽에 친화적 (원래 열려있는 80, 443 포트를 그대로 사용하기 때문에 웹소켓 처럼 따로 포트 관련 설정 불필요)

- Polling 방식에 비해 실시간성 증가

단점 

- stateful 하기 때문에 수평적 확장에 취약, 즉 다중 WAS 환경에서 문제 발생

- SSE는 지속적인 연결을 유지 → 많은 클라이언트가 동시에 연결을 유지할 경우 서버 부담이 커짐 

- 연결이 유지되는 동안 서버 → 클라이언트로의 단방향 통신만 가능, 따라서 클라이언트 - 서버 간 양방향 통신이 필요한 경우 부적절

 

 

마무리 

아직 풀리지 않는 의문이 있다면 정확히 어떤 원리로 클라이언트 - 서버 간 연결이 끊기지 않고 지속되는 걸까?이다. 이 부분은 HTTP의 Persistent Connection에 대해 더 공부가 필요할 것 같다. (HTTP 완벽 가이드 책 추천)

 

이런 실시간 처리 방식은 Polling, WebSocket 정도만 알고 있었는데, 개인 프로젝트에서 다른 팀원이 '내가 작성한 글에 댓글이 달렸을 경우 알림' 구현에 SSE를 사용한 것을 보고 처음 'SSE'라는 키워드를 듣게 되었고, 회사에서 '대용량 데이터 처리 완료 알림' 구현에 직접 SSE를 적용해보았다. 이제 웹 좀 알겠다 싶었는데 여전히 내가 모르는 영역이 많다는걸 새삼 깨닫고 자동 겸손해지게 된다. 암튼 스프링 4.2부터는 SSE 통신을 지원하는 SseEmitter API를 제공하는데, 다음 글에서는 이를 이용해 실제 프로젝트에서 활용가능한 SSE 통신을 구현해보고 트러블 슈팅 과정도 정리해보고자 한다. 
 
 
 
끝.
 
 
 

참고

https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
https://developer.mozilla.org/en-US/docs/Web/API/EventSource/message_event
https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/
https://boxfoxs.tistory.com/403
https://www.youtube.com/watch?v=4HlNv1qpZFY
https://www.youtube.com/watch?v=i4-MNzNML_c
https://www.youtube.com/watch?v=Fjj4ZmLlrP8

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함