Spring + React SSE (Server Sent Events)

박지찬·2023년 8월 7일

개발하는 도중 실시간 알림 기능이 필요했다.
이러한 실시간으로 사용자에게 알림을 보여줘야 하는 경우 처음에는 Web Socket을 사용해야 된다고 생각했다.
서버에서 클라이언트로의 전송만을 허용하는 SSE를 알게 되고 이번 프로젝트에 더 적합하다고 생각하여 적용해보기로 했다.

Polling, Web Socket, SSE 비교

우선 기존에 알고 있던 Polling, Web Socket 방식과 SSE는 어떤 차이점이 있는지 알아보겠다.

Polling

폴링 방식은 클라이언트가 주기적으로 서버에 알림을 요청하는 방식이다. 하지만 이 방식은 지속적인 HTTP Request를 발생시키기 때문에 리소스 낭비가 발생한다.

Web Socket

웹 소켓을 이용하는 방식은 클라이언트와 서버의 Handshake 이후 양방향 통신이 가능한 구조이다. 웹 소켓은 별도의 WebSocket 프로토콜을 이용하여 동작한다.

SSE (Server Sent Events)

SSE의 경우 이벤트가 서버에서 클라이언트로의 단방향으로만 전송된다. 또한, 클라이언트가 polling과 같이 주기적인 HTTP Request를 보낼 필요 없이, 서버에서 연결을 통해 데이터를 보낼 수 있다.

SSE와 Web Socket의 차이점

SSE가 할 수 있는 일은 Web Socket에서도 모두 할 수 있다. 하지만 둘은 몇가지 차이점이 있다.

SSE의 장점

  • SSE는 HTTP 위에서 동작하고, Web Socket은 WebSocket 프로토콜 위에서 동작한다
  • SSE는 Polyfill (구형 브라우저에 지원되지 않는 API를 이용할 수 있도록 하는 기술)을 이용할 수 있다
  • SSE 브라우저에서 자동으로 재연결을 시도한다

Web Socket의 장점

  • 양방향 통신이 가능하다
  • 더 많은 브라우저에서 Native하게 지원한다 (하지만 SSE는 Polyfill을 사용하면 된다)
  • UTF-8 뿐만 아니라 Binary 형태의 데이터도 전송이 가능하다

이번 프로젝트에서는 단방향으로 알림만 전송하면 돼서 SSE를 사용하게 되었다.


Spring + React에서 SSE 사용법

위의 SSE 동작 방식을 다시 한번 보자.

맨 처음 SSE 연결을 시작하기 위해서 Client에서 Server로 SSE에 대한 요청이 있어야 한다.

Client 요청:

HTTP/1.1 GET
Accept: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

이벤트의 미디어 타입은 text/event-stream으로 표준이 정해져 있다.

서버 응답:

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

Client에서 요청한 것과 같이 Content-Typetext/event-stream 형태이다
서버에서 동적으로 생성된 컨텐츠를 스트리밍 하기 때문에, 크기를 미리 알 수 없어 transfer-encoding: chunked 로 설정해준다


Server-side (Spring)

private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
    
@GetMapping("/sse")
public SseEmitter createSseEmitter() {
    SseEmitter emitter = new SseEmitter();
    
	emitters.add(emitter);
    
    //콜백함수 등록
    emitter.onCompletion(() -> {
        this.emitters.remove(emitter);
    });
    emitter.onTimeout(() -> {
    	emitter.complete();
    });
    
    try {
        emitter.send(SseEmitter.event()
               .name("connect")
               .data("connected")); //503 에러 방지를 위한 더미 데이터
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
    return emitter;
}

//다른 Thread
try {
    emitter.send(SseEmitter.event()
           .name("notifications")
           .data(클라이언트에게 보낼 알림));
} catch (IOException e) {
    throw new RuntimeException(e);
}

SSE 연결을 시작하는 서버쪽 코드이다. onCompletion onTimeout은 SseEmitter 를 관리하는 별도의 스레드에서 실행 되는 것에 주의하자.

또한, SSE Emitter와 처음 연결이 되었을 때 아무 데이터도 보내주지 않으면 503 Service Unavailable Error가 나기 때문에 더미 데이터를 보내주어야 한다.



Client-side (React)

const eventSource = new EventSourcePolyfill(`${서버주소}/sse`, {
    headers: {
      Authorization : 'Bearer ' + 토큰
    }
});

eventSource.addEventListener('connect', (event) => {
  console.log('event = ', event.data);
});

위의 코드처럼 Event Source를 선언해주면서 연결을 시작하고, addEventListener를 이용해서 스트리밍된 이벤트의 데이터를 받아올 수 있다.

이 코드에서는 new EventSource 대신 new EventSourcePolyfill을 사용하였다. 위와 같이 헤더에 어떤 값을 같이 넣어주어야 하는 경우 기존의 EventSource에서는 지원을 해주지 않아 event-source-polyfill을 사용하였다.

이와 같은 방식으로 다른 이벤트를 통해 더미 데이터가 아닌 다른 데이터도 보낼 수 있다.

Spring SSE 사용시 주의점

JPA 사용시 Connection 고갈

SSE 통신을 하는 동안에 HTTP Connection이 계속 열려있다. SSE를 사용하는 API에서 JPA를 사용하고, open-in-view 속성이 true로 설정이 되어 있으면, HTTP 연결이 지속되는 동안 DB 연결도 지속된다. 이렇게 되면 DB 커넥션이 클라이언트가 많아지면 고갈될 수 있어 open-in-viewfalse로 설정해줘야 된다.

ConcurrentModificationException

SSE를 등록할 때 등록했던 콜백 함수들은 다른 스레드에서 동작하게 된다. 만약 이 콜백을 통해 thread-safe하지 않은 Collection에 연산을 하면 ConcurrentModificationException이 발생하게 된다.

이 문제를 다른 블로그에서 읽고 "다른 스레드에서 동작하게 된다"는게 어떤 의미인지 궁금했다. Spring 설정시 설정했던 Thread Pool이 있는데, 이 Thread Pool 과 무관한 스레드를 사용한다는 것인지 명확하지 않아 확인해 보았다.

결론부터 말하면 Thread Pool 안에 있는 스레드를 사용한다. 하지만 emitter들이 timeout등과 같은 방식으로 만료될 때 비동기적으로 Thread Pool에 있는 스레드를 꺼내서 콜백 함수를 호출해준다. 그러면 여러 스레드에서 emitters 컬렉션에 접근할 수 있고, 그렇기 때문에 thread-safe한 자료구조를 사용해야 한다.

Nginx 사용시 주의점

Nginx는 Upstream으로 요청을 보낼 때 기본 설정으로 HTTP/1.0을 사용한다. HTTP/1.1은 지속 연결이 기본이기 때문에 헤더를 설정할 필요가 없지만, Nginx가 백엔드의 WAS로 요청을 보낼 때, HTTP/1.0connection: close 헤더를 보내주어 SSE가 제대로 동작하지 않는다.

그래서 nginx 설정에 아래를 추가해주어야 한다.

proxy_set_header Connection '';
proxy_http_version 1.1;

또 Nginx의 proxy buffering이 활성화 되어 있으면 서버의 응답을 버퍼에 저장해두고 버퍼가 차거나 서버가 응답 데이터를 모두 보내면 클라이언트에게 전송한다. 하지만 이렇게 동작하게 되면 SSE를 사용할 때 기대했던 실시간성이 떨어지게 된다.

Nginx 설정 파일에서 버퍼링을 비활성화 할 수 있지만, 이렇게 하면 모든 응답에 대해 버퍼링이 비활성화 된다.

이 문제에 대한 해결책으로 nginx의 X-accel 기능을 사용하면 된다. 백엔드의 응답 헤더에 X-accel로 시작하는 헤더가 있으면 Nginx가 내부적인 처리를 따로 하게 만들 수 있다. SSE를 사용하는 API의 응답에 X-Accel-Buffering: no를 붙여주면 SSE 응답을 버퍼링하지 않게 할 수 있다.

React Proxy (Webpack Dev Server) 사용 시 주의점

React의 Webpack Dev Server 라이브러리에서 포워드 프록시 기능을 제공한다. 먼저 여기서 제공하는 프록시에 대해서 설명하자면 일종의 포워드 프록시 (Forward Proxy)로 프록시 서버가 클라이언트의 뒤에 위치하여 클라이언트가 서버의 주소에 데이터를 요청하면, 프록시 서버가 중간에서 대신 데이터를 요청하고 가져온다. 예를 들어 클라이언트가 google.com 으로 데이터를 요청하면 프록시가 대신 google.com 의 데이터를 받아 클라이언트에게 넘겨(forward)준다. 이 기능을 사용하면 CORS 정책을 우회할 수 있어 별도의 응답 헤더를 받을 필요 없이 브라우저에서 백엔드로 요청을 전달하고 받을 수 있다.

이 프록시를 사용할 때 주의해야 할 점이, compress 라는 옵션이 default 로 켜져 있는데, 서버로부터 오는 모든 응답을 gzip 으로 압축한다. 이는 event stream 을 타고 오는 데이터도 압축을 한다는 것을 의미한다. 이렇게 되면 이상하게 실시간으로 데이터가 전송되지 않고, 데이터가 buffering 되면서, SSE 커넥션이 끊기는 시점에 한 번에 gzip 형태로 데이터를 받게 된다. 개인적인 추측으론 transfer-encoding: chunked 여서 끝나는 시점을 모르기 때문에 SSE 연결이 완료된 시점을 끝난 시점으로 보고 한 번에 compress 를 해서 데이터를 받게 되는 게 아닐까 싶다.

이런 경우 해결책이 두 가지가 있는데, 첫 번째는 compress 옵션을 꺼주는 것이다.

module.exports = {
  //...
  devServer: {
    compress: false,
  },
};

위와 같은 방식, 또는 CLI 에서 아래 커맨드를 입력해주면 끌 수 있다.

npx webpack serve --no-compress

두 번째 방법은 SSE 를 맺는 서버에서 Cache-Control: no-transform 헤더를 추가해주는 것이다. 이 헤더를 proxy 서버가 인식해서 아무런 변환이 가해지지 않도록 하여 compression 이 이루어지지 않는다.

참고자료

0개의 댓글