프로젝트를 진행하면서 알림 기능을 구현해야 하는 상황이 발생하였다. 알림 기능을 구현하기 위해서는 대표적으로 폴링(polling), 웹훅(webhook), 그리도 SSE(Server-Sent Event)를 사용한다고 한다.
그래서 내 프로젝트에 적용하기 전, 각각의 방식들의 기초적인 원리와 장단점을 살펴보았다.
API 폴링은 클라이언트에서 지속적으로 서버 API를 호출하여 이벤트가 발생했는지 확인하여 알림을 구현할 수 있다. 하지만 지속적으로 서버 API를 호출하는 방식 때문에 서버에 지나치게 많은 요청을 보내게 되고, 클라이언트 및 서버에 큰 리소스를 사용하게 된다.
폴링으로 인한 리소스 낭비를 방지하기 위해 폴링의 주기를 길게 한다면, 실시간으로 이벤트 데이터를 받기 어려워지는 문제도 발생한다.
일반적으로 클라이언트와 서버 간 통신을 위해서는 Rest API가 사용된다. Rest API는 하나의 요청에 대해 하나의 응답을 제공한다. 위에 폴링이 Rest API를 사용하여 클라이언트가 서버를 호출하는 방식이다.
하지만 웹훅은 서버에서 특정 이벤트가 발생했을 때 서버가 클라이언트에게 HTTP POST로 호출하는 방식으로 역방향 API라고도 불린다.
웹훅은 기본적으로 이벤트 기반 동작(Event driven) 방식으로 특정 이벤트가 발생할 때 트리거되어 요청을 보내도록 설계되어있다.
웹훅은 단발성 HTTP 요청으로 한번의 이벤트에 대해 한번의 HTTP 요청을 보낸다. 즉, 클라이언트와 지속적인 연결을 유지하지 않아 이벤트가 발생할 때마다 새로운 HTTP 요청을 생성해야 한다. 따라서 알림이 지속적으로 발생한다면, 지속적인 연결하는 유지하는 방식에 비해 더 많은 오버헤드가 발생한다. 또한 이벤트가 발생할 때마다 새로운 Http 요청을 생성해야 하기 때문에 실시간성이 조금은 떨어져 보일 수 있다.
또한 서버에서 요청을 보내는 것이기 때문에 클라이언트가 요청을 처리할 수 있는 엔드표인트를 설정해야 한다. 이는 구현이 매우 복잡해질 우려가 있다.
따라서 웹훅은 주로 서버 간 통신이나 일회성 이벤트에 적합하다.
웹훅 작동 방식
SSE 또한 서버에서 클라이언트에게 실시간으로 데이터를 전송하는 기술이다. HTTP connection을 길게 유지하면서 서버에서 클라이언트에게 데이터를 전송한다.
SSE도 위 기술들 처럼 HTTP를 통해 통신한다.
SSE는 서버가 클라이언트에 대해 단방향으로 메세지를 전송하는 방식으로, 서버에서 사용자에게 알림을 보내는 방식에 적합하다.
또한 클라이언트가 서버와의 연결이 끊어질 경우, 자동으로 재연결을 시도하기 때문에 클라이언트와의 연결이 끊어질 걱정 없이 지속적으로 데이터 수신을 유지할 수 있다.
polling, webhook, sse를 살펴본 결과, 웹어플리케이션을 통해 클라이언트와 통신을 해야하는 우리 프로젝트의 경우 SSE를 통해 알림 서비스를 구현하는 것이 좋을 것 같다고 생각했다. polling은 너무 잦은 http 호출로 인해 리소스 낭비가 우려되고, webhook같은 경우에는 클라이언트에서 post 요청을 수용할 엔드포인트가 필요하기 때문이다.
SSE를 적용하기에 앞서 SSE에 대해서 조금 더 자세히 알아보았다. SSE는 앞서 설명했듯 HTTP의 connection 헤더를 사용한다.
HTTP/1.1 부터 기본적으로 Persistent Connection(연결 유지)이 활성화된다.
GET /sse-endpoint HTTP/1.1
Content-Type: text/event-stream
Content-Type: text/event-stream
: 데이터 스트림을 열어두어 지속적으로 이벤트를 수신하겠다.
http/1.1에서는 Persistent Connection(연결 유지)
이 기본으로 설정되어있어 연결을 유지하도록 되어있다. 따라서 클라이언트가 EventSource
를 통해 서버에 SSE 요청을 보내면, 서버는 해당 연결을 유지하면서 필요한 데이터를 스트림 방식으로 계속 클라이언트에 전송한다.
클라이언트에서 text/event-stream
으로 요청을 보냈기 때문에 서버에서도 해당 타입으로 데이터를 전송해주어야 한다.
이를 위해 @RequestMapping
에 produces = MediaType.TEXT_EVENT_STREAM_VALUE
를 설정하여 MIME 타입을 SSE를 위한 스트림 데이터로 지정해주었다.
또 Spring에는 SSE의 구현을 위한 SseEmitter
클래스가 제공된다.
SseEmitter
를 통해 Spring에서 단방향 통신, 지속적인 연결 유지, 타임 아웃 설정 기능을 쉽게 구현할 수 있다.
private final ConcurrentHashMap<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
@GetMapping(value = "", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents(@Authenticate Long userId) {
SseEmitter emitter = new SseEmitter(60000L);
emitters.put(userId, emitter);
// SseEmitter가 complate 됐을 경우
emitter.onCompletion(() -> emitters.remove(userId));
// SseEmitter가 timeout 됐을 경우
emitter.onTimeout(() -> emitter.complete());;
// Error 발생
emitter.onError((e) -> emitters.remove(userId));
return emitter;
}
new SseEmitter(60000L)
: 타임아웃을 60초로 지정하여 SseEmitter 객체를 생성
ConcurrentHashMap<Long, SseEmitter> emitters = new ConcurrentHashMap<>()
: 멀티스레드 환경에서 동시에 HashMap에 데이터를 읽고 쓸 수 있도록 동시성 문제를 해결하도록 설계된 ConcurrentHashMap
emitters.put(userId, emitter)
: 생성한 SseEmitter 객체 저장
emitter.onTimeout(() -> emitter.complete());
: SseEmitter에 지정된 타임아웃이 만료될 경우 발생할 이벤트로, 해당 SseEmitter를 완료시킨다. 이를 통해 emitter.onCompletion()이 호출될 것이다.
emitter.onCompletion(() -> emitters.remove(userId))
: Sse가 완료되었을 때 발생할 이벤트로, 타임아웃이 만료되거나 클라이언트에서 EventSource.close()
를 통해 연결을 끊었을 때 호출된다. 콜백으로 서버에 저장중인 SseEmitter를 삭제한다.
위 과정을 통해 SseEmitter를 생성하여 클라이언트에게 응답하면 서버와 클라이언트가 설정한 시간만큼 연결을 유지하고, 그 시간이 지나면 클라이언트에서 재연결을 시도할 것이다.
서버는 비동기적으로 SseEmitter에서 발생한 콜백 함수를 실행한다.
연결을 유지중인 클라이언트에게 메세지를 발행하고 싶을 때는 enitters
에서 보내고 싶은 클라이언트에 해당하는 SseEmitter 객체에 send()
해 주면 된다.
emitter.send("클라이언트에게 발행할 메세지");
이렇게 서버와 클라이언트 간 SSE를 구현하였는데, 실제 배포 환경에서는 정상적으로 이벤트 발행이 동작하지 않는 문제가 발생하였다.
이는 우리 프로젝트의 서버가 Nginx를 통해 배포되었기 때문인데, Nginx의 기본 설정으로 Http 버전이나 타임아웃을 다르게 처리하고 있었기 때문이다.
server {
# ... 다른 설정들 ...
location /sse-endpoint {
proxy_pass http://backend_server; # Spring 서버
proxy_http_version 1.1; # HTTP/1.1을 사용
proxy_read_timeout 3600s; # 연결 타임아웃을 늘림
client_body_timeout 3600s; # 클라이언트의 타임아웃을 늘림
send_timeout 3600s; # 서버 측 타임아웃을 늘림
proxy_buffering off; # 버퍼링 비활성화
proxy_cache off; # 캐시 비활성화
}
}
해당 이슈는 /sse-endpoint
에 대한 Nginx 설정을 변경하여 해결하였다.
http_version
을 HTTP/1.1을 사용하도록 설정
timeout
설정 변경
proxy_buffering
와 proxy_cache
비활성화
이렇게 변경해 주었다. 다른 부분보다 proxy_buffering off
하는 부분에서 어려움을 겪었다.
Nginx에서는 클라이언트에게 응답을 보내기 전에 일정 양의 데이터를 모을 수 있도록 응답을 버퍼링한다. 하지만 이렇게 응답을 버퍼링한다면 우리가 원하는 실시간 알림이 아니게 된다. 그래서 이를 방지하기 위해서는 proxy_buffering off
를 해주어야 한다.