Backend/학습내용 정리

Server Sent Event 정리

jinmook 2024. 5. 16. 13:46

글 목차

1. Server Sent Event 개념
2. Spring 에서 SseEmitter 클래스 확인하기
3. SseEmitter 클래스 동작 방식
4. Spring 코드 흐름 확인
5. 마무리
6. 참고 블로그

 

 


 

Server Sent Event 개념

 

전통적으로 웹 페이지는 새로운 데이터를 얻기 위해 서버로 요청을 보내야 하는 구조입니다. 즉, 서버로 데이터를 요청해야 합니다. 하지만 Server-Sent Events 방식으로 웹페이지의 요청 없이도 언제든지 서버가 새로운 데이터를 보내는 것이 가능합니다. 이렇게 보내진 메시지는 웹페이지 안에서 이벤트와 데이터로 다룰 수 있습니다.

 

Server-Sent Events 방식을 사용할 때 주의할 점이 Http/2 프로토콜을 사용하지 않는다면 최대 커넥션 개수가 부족할 수 있다는 점입니다.

 

이러한 제한 사항은 브라우저와 도메인의 결합 기준으로 적용됩니다. 기본적으로 6개의 커넥션을 열 수 있는데, 같은 브라우저에서 www.example1.com 페이지를 최대 6개까지 다른 탭에서 열 수 있으면 동일한 브라우저에서 www.example2.com 페이지 또한 최대 6개까지 다른 탭에서 열 수 있다는 의미입니다.

 

이벤트를 송신하는 서버에서는 text/event-stream 형식을 사용해 응답해야 합니다. 메시지 형식은 이름이 있는 메시지 혹은 데이터만 있는 메시지 형식이 가능합니다.

 

이벤트 포멧은 아래 예시를 통해 확인할 수 있습니다.
마지막에 줄바꿈 2개로 끝나는 형식입니다.

 

  echo "event: ping\n";
  $curDate = date(DATE_ISO8601);
  echo 'data: {"time": "' . $curDate . '"}';
  echo "\n\n";

 

 

위에서는 event, data 만 존재하지만 실제로 id, reconnectTime, comment 데이터를 추가할 수 있습니다.

다음으로 클라이언트에서는 이름이 없는 메시지의 경우 아래 코드처럼 이벤트를 받을 수 있습니다.

 

evtSource.onmessage = function (e) {
  var newElement = document.createElement("li");
  var eventList = document.getElementById("list");

  newElement.innerHTML = "message: " + e.data;
  eventList.appendChild(newElement);
};

 

 

아래 코드를 통해 클라이언트에서는 ping 이라는 이름의 이벤트를 받을 수 있습니다.

evtSource.addEventListener("ping", function (event) {
  const newElement = document.createElement("li");
  const time = JSON.parse(event.data).time;
  newElement.textContent = "ping at " + time;
  eventList.appendChild(newElement);
});

 

 


 

Spring Web MVC 에서 SseEmitter 클래스 살펴보기

 

위에서 얘기한 데이터 형식은 Spring MVC 에서 SseEmitter 클래스에서도 확인 가능합니다.
id, name, data 등을 추가하는 코드를 보면 위 형식과 동일한 것을 확인할 수 있습니다.
마지막으로 build 메서드에서 \n 을 한번 더 추가하면 이벤트 포멧을 맞춘것을 확인할 수 있습니다.

 

private static class SseEventBuilderImpl implements SseEventBuilder {  

   private final Set<DataWithMediaType> dataToSend = new LinkedHashSet<>(4);  

   @Nullable  
   private StringBuilder sb;  

   @Override  
   public SseEventBuilder id(String id) {  
      append("id:").append(id).append('\n');  
      return this;   }  

   @Override  
   public SseEventBuilder name(String name) {  
      append("event:").append(name).append('\n');  
      return this;   }  

   @Override  
   public SseEventBuilder reconnectTime(long reconnectTimeMillis) {  
      append("retry:").append(String.valueOf(reconnectTimeMillis)).append('\n');  
      return this;   }  

   @Override  
   public SseEventBuilder comment(String comment) {  
      append(':').append(comment).append('\n');  
      return this;   }  

   @Override  
   public SseEventBuilder data(Object object) {  
      return data(object, null);  
   }  

   @Override  
   public SseEventBuilder data(Object object, @Nullable MediaType mediaType) {  
      append("data:");  
      saveAppendedText();  
      this.dataToSend.add(new DataWithMediaType(object, mediaType));  
      append('\n');  
      return this;   }  

   SseEventBuilderImpl append(String text) {  
      if (this.sb == null) {  
         this.sb = new StringBuilder();  
      }  
      this.sb.append(text);  
      return this;   }  

   SseEventBuilderImpl append(char ch) {  
      if (this.sb == null) {  
         this.sb = new StringBuilder();  
      }  
      this.sb.append(ch);  
      return this;   }  

   // 빌드 할 때 \n 을 한번 더 붙여주어서 데이터의 마지막이 \n\n 형식이 됩니다.
   @Override  
   public Set<DataWithMediaType> build() {  
      if (!StringUtils.hasLength(this.sb) && this.dataToSend.isEmpty()) {  
         return Collections.emptySet();  
      }  
      append('\n');  
      saveAppendedText();  
      return this.dataToSend;  
   }  

   private void saveAppendedText() {  
      if (this.sb != null) {  
         this.dataToSend.add(new DataWithMediaType(this.sb.toString(), TEXT_PLAIN));  
         this.sb = null;  
      }  
   }  
}

 

 

위 사진에서 SseEmitter 클래스에서 send 메서드를 호출하게 되면 메시지를 build() 한 후 데이터 마지막에 \n\n 형식이 보이는 것을 확인할 수 있습니다.

 

 


 

SseEmitter 클래스 동작 방식

 

지금부터 Spring 에서 SseEmitter가 어떻게 동작하는지 확인해보겠습니다. 아래 코드를 예시로 글을 작성했습니다.

@GetMapping(value = "/api/sse/register", produces = MediaType.TEXT_EVENT_STREAM_VALUE)  
    public SseEmitter register() {  
        SseEmitter sseEmitter = new SseEmitter(SSE_TIMEOUT);  
        String sseKey = UUID.randomUUID().toString();  
        log.info("register sseKey = {}", sseKey);  

        emitters.put(sseKey, sseEmitter);  

        sseEmitter.onCompletion(() -> {  
            log.info("onCompletion sseKey = {}", sseKey);  
            emitters.remove(sseKey);  
        });  

        sseEmitter.onTimeout(sseEmitter::complete);  

        try {  
            sseEmitter.send(SseEmitter.event().id(sseKey).data(INITIAL_MESSAGE));  
        } catch (IOException e) {  
            emitters.remove(sseKey);  
            throw new RuntimeException(e);  
        }  
        return sseEmitter;  
    }

 

 

위 상태에서 만약 브라우저 연결을 종료한다면 기존 연결이 끊겨서 바로 onCompletion 메서드가 실행되는 것이 아니라 SSE_TIMEOUT 시간 설정이 지나야 기존 연결이 onCompletion 메서드를 실행합니다.

 

Server Sent Event 로 연결하고 서버쪽에서 한번도 메시지를 전송하지 않으면 503 에러가 발생합니다. 따라서 SseEmitter 의 complete() 메서드가 중요한데, 기본 default 메서드를 사용하게 되면 스트림을 정상적으로 종료하고 Content-Type : text/event-stream 을 전달합니다.

 

이때 onTimeout 메서드에 콜백으로 complete() 메서드를 실행하게 되면 만약 기존에 아무런 메시지를 보내지 않았더라도 마지막에 메시지를 기본으로 전달하기 때문에 클라이언트 쪽에서 다시 등록을 호출하게 됩니다.

 

하지만 onTimeout에서 따로 complete() 메서드를 호출하지 않고, 기존에 연결 후 한번의 메시지도 보내지 않았다면 클라이언트는 서버에서 아무런 메시지도 받지 못했기 때문에 503 Service Unavailable 오류로 인식하고 다시 등록 호출을 보내지 않게 됩니다.

 

따라서 서버에서는 반드시 처음 Server Sent Event 연결 요청을 받은 후 메시지를 보내던가 혹은 complete() 메서드를 호출하도록 설정이 필요합니다.

 

 


 

코드 흐름 확인

 

위 내용을 spring 코드로 직접 확인해보겠습니다.

아래 사진은 complete() 메서드의 구현 부분 입니다. handler로 ResponseBodyEmitterReturnValueHandler를 사용하고 있는것을 알 수 있습니다.

 

 

 

handler의 complete() 메서드를 따라가면 outputMessage.flush() 메서드를 볼 수 있습니다.

 

 

 

마찬가지로 코드를 따라가다보면 ServletServerHttpResponse 객체의 flush() 메서드를 확인할 수 있습니다.

 

 

 

아래는 flush 메서드에서 실행하는 writeHeaders 메서드입니다. 이때 해당 메서드에서 content-type을 text/event-stream으로 설정해주는 것을 알 수 있습니다.

 

 

 

즉 위 흐름을 통해 SseEmitter의 complete() 메서드를 실행하게 되면 해당 메서드에서 헤더를 설정하고 응답을 보내는 것을 알 수 있습니다. 따라서 추가적인 메시지 전송 없이 complete() 메서드만 실행하더라도 메시지를 보내기 때문에 해당 커넥션이 종료 후 클라이언트 쪽에서 추가로 다시 커넥션 연결을 시도하게 되는 것을 알 수 있습니다.

 

 


 

마무리

 

이번에 개인 프로젝트를 진행하며 사용자에게 알림을 보내야 하는 요구사항이 있어 Server Sent Event 를 사용해보았습니다. 예전에도 간단하게 한번 구현한적이 있는데, 사실 그때는 블로그 적당히 참고하고 정확한 원리를 이해하지 못한채로 코드를 작성했습니다.

 

이번에 그래도 한번 원리를 알아보자 라는 마음으로 프로젝트를 진행하게 되었는데, spring 코드들도 직접 살펴보며 예전에 비해 조금씩 이해가 되는 코드들이 보이니 나름 재밌게 진행할 수 있었던것 같습니다.

 

앞으로도 단순히 구현만 하는 것이 아니라 내부 원리를 알고 구현할 수 있도록 개인 프로젝트를 진행하면 좋을것 같다고 많이 느끼게 되었습니다.

 

위 글은 개인적으로 공부하고 작성한 글이다보니 제가 코드를 잘못 이해하거나 한 부분이 있을 수 있습니다. 혹시 잘못된 정보가 있거나 혹은 피드백은 편하게 댓글 달아주시면 감사하겠습니다!!

 

마지막으로 학습을 위해 사용했던 코드입니다.

@Slf4j  
@RestController  
public class Controller {  
    private static final long SSE_TIMEOUT = 10 * 1000L;  
    private static final String EVENT_NAME = "article update";  
    private static final String NEW_ARTICLE_ALERT_MESSAGE = "new articles are registered";  
    private static final String INITIAL_MESSAGE = "sse connected";  

    private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();  

    @GetMapping(value = "/api/sse/register", produces = MediaType.TEXT_EVENT_STREAM_VALUE)  
    public SseEmitter register() {  
        SseEmitter sseEmitter = new SseEmitter(SSE_TIMEOUT);  
        String sseKey = UUID.randomUUID().toString();  
        log.info("register sseKey = {}", sseKey);  

        emitters.put(sseKey, sseEmitter);  

        sseEmitter.onCompletion(() -> {  
            log.info("onCompletion sseKey = {}", sseKey);  
            emitters.remove(sseKey);  
        });  

        sseEmitter.onTimeout(sseEmitter::complete);  
        return sseEmitter;  
    }  

    @GetMapping("/api/sse/send")  
    public String send() {  
        emitters.keySet()  
                .forEach(sseKey -> {  
                    SseEmitter sseEmitter = emitters.get(sseKey);  
                    try {  
                        sseEmitter.send(SseEmitter.event().name(EVENT_NAME).data(NEW_ARTICLE_ALERT_MESSAGE));  
                    } catch (IOException e) {  
                        log.error("sseKey 에러 입니다. = {}", sseKey);  
                    }  
                });  

        return "ok";  
    }  
}

 

 

<!DOCTYPE html>  
<html lang="en">  
<head>  
    <meta charset="UTF-8">  
    <meta name="viewport" content="width=device-width, initial-scale=1.0">  
    <title>SSE Test Client</title>  
    <style>        body {  
            font-family: Arial, sans-serif;  
            padding: 20px;  
        }  
        #events {  
            margin-top: 20px;  
            border: 1px solid #ccc;  
            padding: 10px;  
            width: 300px;  
            height: 200px;  
            overflow-y: auto;  
            background-color: #f9f9f9;  
        }  
        button {  
            margin-top: 10px;  
            padding: 8px 16px;  
            font-size: 16px;  
            cursor: pointer;  
        }  
    </style>  
</head>  
<body>  
<h1>Server Sent Events Test Client</h1>  
<button onclick="sendRequest()">Trigger Event on Server</button>  
<div id="events"></div>  

<script>  
    const eventSource = new EventSource('http://localhost:8084/api/sse/register');  

    eventSource.addEventListener("article update", function(event) {  
        console.log(event)  
        console.log('New event from server:', event.data);  
        const eventsDiv = document.getElementById('events');  
        const message = document.createElement('div');  
        message.textContent = event.data;  
        eventsDiv.appendChild(message);  
    })  

    eventSource.onmessage = function(event) {  
        console.log(event)  
        console.log('New event from server:', event.data);  
        const eventsDiv = document.getElementById('events');  
        const message = document.createElement('div');  
        message.textContent = event.data;  
        eventsDiv.appendChild(message);  
    };  

    eventSource.onerror = function(error) {  
        console.error('EventSource failed:', error);  
        // eventSource.close(); // 연결 문제 발생 시 연결을 종료합니다.  
    };  

    // eventSource.  

    function sendRequest() {  
        fetch('http://localhost:8084/api/sse/send', {  
            method: 'GET'  
        }).then(response => {  
            console.log('Server triggered:', response.statusText);  
        }).catch(error => {  
            console.error('Error triggering server:', error);  
        });  
    }  
</script>  
</body>  
</html>

 

 

 


 

참고 블로그 입니다.

 

https://devel-repository.tistory.com/31

https://developer.mozilla.org/ko/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format

https://hamait.tistory.com/792