안녕하세요! 현재 개발 중인 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
등의 디스패치 유형에는 적용되지 않도록 조정하여 문제를 해결했습니다.