CS

[네트워크] Server-Sent Event

nkdev 2025. 1. 5. 20:48

🔈 알림 기능 구현 방법 4가지

Short-Polling

  • 클라이언트가 서버에 짧은 주기를 두고 정기적으로 새로운 이벤트가 있는지 요청을 보내는 방법
  • 서버에서 보내줄 새로운 데이터가 없어도 즉시 빈 응답을 보내고 받는 작업을 반복하기 때문에 불필요한 트래픽 발생, 비효율적

Long-Polling

  • Short Polling과 달리 새로운 이벤트가 발생하면 응답하는 방법
  • 불필요한 요청과 응답이 없어지고 보다 실시간성이 보장됨
  • 그러나 커넥션을 유지하는 동안은 서버의 리소스 지속적으로 소모
  • 응답이 없다면 timeout (보통 100~300 seconds로 설정)

SSE(Server Sent Events)

  • 클라이언트가 HTTP Request 헤더로 keep-alive 요청을 하면 일정 시간 동안 연결이 유지됨
  • 연결 유지되는 동안 HTTP 스트리밍을 통해 (서버→클라이언트) 단방향으로만 push notification 전송
  • Spring Framework 버전 4.2부터 SseEmitter 클래스로, 버전 5부터는 WebFlux로 SSE 구현 가능

Web Socket

  • handshake로 연결된 후 ws 프로토콜로 전환되어 메시지를 주고받음
  • 서버가 terminate를 보낼 때까지 연결이 지속됨
  • 실시간 양방향 통신 가능
  • HTTP를 사용하지 않으므로 별도의 라이브러리를 사용해야 함

💭 어떤 방법이 적절한가?

SSE를 선택

  1. Polling 방식은 요청을 보내야 응답을 받을 수 있기 때문에 불필요한 트래픽 발생
  2. Web Socket 방식은 HTTP를 사용하지 않아 학습곡선이 요구되고, 양방향 통신은 필요하지 않음
  3. 현재 Spring Framework를 사용하는 중이고 서버→클라이언트 단방향 전송만 필요하므로 SSE를 선택

🤚🏼 단, 고려 사항

  • SSE는 브라우저 마다 도메인당 허용되는 HTTP 연결 수가 제한되어있음 (HTTP/2를 사용한다면 도메인 당 최대 100개의 연결 가능)
    → 주로 소규모 연결이 보장되는 경우에 SSE를 사용한다고 함 (예: 파일 다운 진행률 표시)
  • 때문에 단방향 알림은 Polling을 쓰기도 하는데, 프로젝트 규모가 작기도 하고 한 사람이 브라우저 당 탭을 100개 이상 열지 않는 한 문제가 되지 않을 것 같아서 사용하기로 함

🙋🏻‍♀️ SSE 통신 구현 방법

1. 클라이언트가 서버에게 SSE 통신 연결 요청

클라이언트가 서버에게 SSE 연결 요청을 할 때 일반적으로 EventSource라는 API를 사용한다.


//사용자 id 확인
let userId = document.getElementById("myName").innerText;
console.log(userId);

if(userId.length > 0){
    //SSE 연결
    const eventSource = new EventSource("/sse" + "?userId=" + userId); 
    //사용자에게 알림 팝업 표시
    eventSource.addEventListener("alarm", function(event){      
        let message = event.data;            
        Swal.fire({ 
            position: 'top-end',
            icon: 'success',
            title: message,
            showConfirmButton: false,
            timer: 1500
        });

    })

💡 EventSource

SSE 연결 요청 시 client-side에서 사용되는 Web API이다.

const eventSource = new EventSource("/sse");

EventSource 객체를 생성할 때 특정 url을 지정하면 해당 url에서 발생한 이벤트가 응답으로 온다.

event: alarm
data: 새로운 알림이 있습니다!
id: user123

(서버 응답 형식은 위와 같다.)

eventSource.onmessage = (event) => {
  const newElement = document.createElement("li");
  const eventList = document.getElementById("list");

  newElement.textContent = `message: ${event.data}`;
  eventList.appendChild(newElement);
};

응답이 올 때마다 onmessage()라는 event handler가 실행된다.

위 코드는 새로운

  • 요소를 하나 생성하고 서버에서 받은 응답의 data를 꺼내 작성하고 있다.
eventSource.addEventListener("ping", (event) => {
  const newElement = document.createElement("li");
  const eventList = document.getElementById("list");
  const time = JSON.parse(event.data).time;
  newElement.textContent = `ping at ${time}`;
  eventList.appendChild(newElement);
});

응답의 event필드에 의해 addEventListener()라는 event listener가 실행될 수 있다.

응답의 event필드 값이 'event: ping'이라면 위 event listener가 동작하여 콜백함수를 실행한다.

위 콜백 함수는 event.data를 JSON으로 파싱한 후 화면에 출력하고 있다.

[Exposed=(Window,Worker)]
interface EventSource : EventTarget {
  constructor(USVString url, optional EventSourceInit eventSourceInitDict = {});

  readonly attribute USVString url;
  readonly attribute boolean withCredentials;

  // ready state
  const unsigned short CONNECTING = 0;
  const unsigned short OPEN = 1;
  const unsigned short CLOSED = 2;
  readonly attribute unsigned short readyState;

  // networking
  attribute EventHandler onopen;
  attribute EventHandler onmessage;
  attribute EventHandler onerror;
  undefined close();
};

dictionary EventSourceInit {
  boolean withCredentials = false;
};

EventSource 인터페이스를 확인해보면 위 내용이 구현되어있다.

  • EventTraget을 상속받고 있음
  • 생성자 파라미터로 url을 지정해주어야 함
  • connecting, open, closed 상태가 존재
  • onopen, onmessage, onerror라는 event handler 존재

이외에 EventSource에 대한 내용

  • Get요청을 한다.
  • 연결이 끊기면 클라이언트가 자동으로 재요청한다. (HTTP 301, 307 redirect)
  • 단 HTTP 204 No Content response code가 오면 재요청을 하지 않는다.
  • SSE 연결 요청 시 header을 수정할 수 없다.

(EventSource의 더 자세한 내용은 HTML Standard 문서 참조)

 

2. 서버의 응답

SSE 연결을 관리하는 Spring 객체 SseEmitter를 사용하여 서버에서 클라이언트로 응답을 보내보자.

@RestController
@RequestMapping("/sse")
@Slf4j
public class SseController {
	//멀티 스레드 동시성 보장
    public static Map<String, SseEmitter> sseEmitters = new ConcurrentHashMap<>();

    @GetMapping(value = "", consumes = MediaType.ALL_VALUE)
    public SseEmitter streamSseMvc(@RequestParam String userId) {
        log.info("userId = {}", userId);

        //현재 클라이언트를 위한 SseEmitter 생성
        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
        try {
            //연결
            emitter.send(SseEmitter.event().name("connect"));
        } catch (IOException e) {
            e.printStackTrace();
        }

        //userId를 key값으로 SseEmitter를 저장
        sseEmitters.put(userId, emitter);

        emitter.onCompletion(() -> sseEmitters.remove(userId));
        emitter.onTimeout(() -> sseEmitters.remove(userId));
        emitter.onError((e) -> sseEmitters.remove(userId));

        return emitter;
    }
}

클라이언트가 보낸 Get요청을 처리하는 메서드이다. 파라미터로 쿼리 스트링으로 전달된 userId를 받는다.

 


 

💡 SseEmitter 

SSE 응답을 보내기 위해 server-side에서 사용되는 API이다.

SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);

SseEmitter 객체를 생성할 때 Long타입으로 timeout value(단위:milliseconds)를 지정해준다. 

timeout을 지정하지 않으면 서버의 디폴트 시간으로 설정된다.

emitter.send(event().data(myObject));

send()를 사용해 클라이언트에게 이벤트를 보낸다.

 

 

 

 

 

* references