안녕하세요! 현재 개발 중인 PMS(Project Management Service)에서 사용자에게 실시간으로 변경 사항이나 새로운 정보를 전달하는 것은 매우 중요한 기능입니다. 저희는 이 실시간 알림 기능을 구현하기 위해 Spring Boot 환경에서 SSE(Server-Sent Events) 방식을 선택했고, SseEmitter 클래스를 핵심적으로 활용했습니다.
이 글에서는 왜 SSE와 SseEmitter를 선택했는지, 그리고 많은 분들이 우려하시는 실시간 연결 유지에 따른 자원 소모 문제와 이를 어떻게 비동기 처리 등을 통해 완화했는지 공유하고자 합니다.
알림 기능을 구현하는 데 SseEmitter를 사용한 주된 이유는 다음과 같습니다.
SseEmitter는 Spring에서 SSE 프로토콜을 표준적으로 지원하는 클래스입니다. 이를 통해 서버에서 클라이언트로 단방향 이벤트를 실시간으로 푸시(push)하는 기능을 쉽게 구현할 수 있습니다. 알림처럼 서버 주도로 발생하는 정보를 전달하는 데 매우 적합합니다.SseEmitter는 내부적으로 비동기/논블로킹(Non-blocking) I/O를 기반으로 동작합니다. 즉, 서버에서 이벤트가 발생했을 때, 다른 작업의 완료를 기다리지 않고 해당 이벤트를 연결된 클라이언트에게 즉시 전송할 수 있습니다. 이는 실시간성이 중요한 알림 기능에 필수적이며, 사용자 경험을 크게 향상시킵니다.Last-Event-ID 헤더)를 서버에 보내 유실된 이벤트를 다시 요청할 수 있는 메커니즘을 표준으로 정의하고 있습니다. SseEmitter는 이러한 표준 프로토콜을 따르므로, 클라이언트 측 라이브러리(EventSource API)와 함께 사용하면 연결 안정성을 높일 수 있습니다. (물론 서버 측에서도 재전송 로직 등 추가 구현이 필요할 수 있습니다.)SseEmitter는 여러 클라이언트와의 동시 연결을 효율적으로 관리할 수 있습니다. 즉, 동일한 유형의 알림(예: 공지)이나 각기 다른 알림을 다수의 클라이언트에게 동시에 전송하는 것이 가능합니다.SSE의 가장 큰 특징 중 하나는 클라이언트와의 연결을 지속적으로 유지한다는 점입니다. 여기서 많은 개발자들이 "로그인한 사용자가 많아지면 서버 자원을 너무 많이 소모하는 것 아닌가?", "병목 현상이 발생하지 않을까?" 하는 합리적인 우려를 하게 됩니다.
네, 그 우려는 타당합니다. 활성 SSE 연결은 다음과 같은 서버 자원을 분명히 사용합니다.
SseEmitter 객체와 관련 정보(사용자 ID, 버퍼 등)는 메모리를 차지합니다. 동시 사용자 증가는 메모리 사용량 증가로 이어집니다.SseEmitter도 이 모델 위에서 동작합니다.이러한 자원 소모 문제를 인지하고, 저희는 다음과 같은 방법으로 시스템의 안정성과 성능을 확보하고자 노력했습니다.
@Async):
EmitterService의 sendNotification 메서드에 @Async 어노테이션을 적용했습니다.emitter.send()) 자체는 이미 논블로킹 방식으로 동작하지만, 그 전후 과정(예: DB에서 알림 내용 조회, 데이터 가공, 대상 Emitter 객체 찾기 등) 이나 sendNotification 메서드 호출 자체가 시간이 걸릴 수 있습니다. @Async를 사용하면 이 sendNotification 메서드 호출이 별도의 스레드 풀에서 실행됩니다.sendNotification을 호출하는 원래의 스레드(예: HTTP 요청 처리 스레드, 다른 이벤트 리스너 스레드)는 알림 전송 작업이 완료될 때까지 기다리지 않고 즉시 다음 작업을 수행할 수 있습니다. 이는 애플리케이션 전체의 응답성을 향상시키고, 특정 작업이 다른 작업에 영향을 미치는 것을 최소화합니다.AsyncConfig를 통해 별도의 스레드 풀(ThreadPoolTaskExecutor)을 설정하여, 매번 새 스레드를 생성하는 오버헤드를 줄이고 스레드 자원을 효율적으로 관리하도록 했습니다. 로그에 찍히는 스레드 이름(Async-X)을 통해 비동기 실행을 확인할 수 있습니다.SseEmitter의 onCompletion, onTimeout, onError 콜백을 적극적으로 활용했습니다.EmitterService 내부의 Map에서 해당 SseEmitter 객체를 제거하도록 구현했습니다 (removeEmitterInternal 호출).AuthorizationDeniedException이 발생하는 문제를 발견했습니다. 이는 비동기 작업 스레드에 보안 컨텍스트가 제대로 전파되지 않아 발생하는 문제였습니다.SecurityConfig에서 .securityMatcher(request -> request.getDispatcherType().equals(DispatcherType.REQUEST)) 설정을 추가하여, 보안 필터가 ASYNC, ERROR 등의 디스패치 유형에는 적용되지 않도록 조정하여 문제를 해결했습니다.