실시간 데이터 전송이 필요한 환경에서는 WebSocket, Long Polling, Server-Sent Events(SSE) 같은 기술이 활용됩니다. 이번 글에서는 SSE를 실전에서 적용하면서 경험한 것들을 공유하고자 합니다.
SSE를 처음 접한 것은 이전에 진행한 끄적끄적 프로젝트에서 실시간 알림 기능을 구현할 때였습니다. 당시 SSE를 적용하면서 기본적인 동작 방식과 활용 가능성을 경험할 수 있었습니다. 하지만 실제 운영 환경에서는 연결 유지, 다중 서버 대응 등 여러 가지 개선이 필요하다는 점을 알게 되었습니다.
이 블로그에서는 SSE 기반 실시간 알림 기능을 최적화하고 확장하는 과정을 정리하고자 합니다. 1편에서는 SSE의 기본 개념과 프로젝트 적용 과정을 다루고, 2편에서는 Redis를 활용한 SSE 연결 관리 및 Kafka를 이용한 다중 서버 환경에서의 동작 방식을 소개하겠습니다.
SSE(Server-Sent Events)는 서버에서 클라이언트로 단방향 스트리밍 데이터를 전송하는 기술입니다.
HTTP 기반으로 작동하며, 클라이언트가 연결을 맺으면 서버가 지속적으로 데이터를 전송할 수 있습니다.
SSE는 단방향 데이터 전송에 최적화되어 있어 실시간 알림 시스템에 적합합니다.
기본적으로 자동 재연결을 지원하며, 별도의 핸드셰이크 없이 HTTP 기반으로 간편하게 적용할 수 있습니다.
그리고 EventSource API를 활용하면 클라이언트 구현이 간단하고 유지보수가 용이합니다.
WebSocket보다 가벼운 연결 방식으로 리소스 사용이 적으며, 프록시 환경에서도 안정적으로 동작합니다. 특히, WebSocket은 비표준 프로토콜(ws, wss)을 사용하기 때문에 방화벽이나 프록시 서버에서 차단될 가능성이 있으며, 연결이 불안정할 수 있습니다.
따라서, 양방향 통신이 필요하지 않은 실시간 알림 기능에 가장 적합한 기술이라 생각했기에 SSE를 사용하였습니다.
프로젝트에 적용하기 전에 먼저 동작 과정을 살펴보겠습니다.
먼저 SSE를 연결하기 위해서는 SseEmitter 를 알고가야합니다.
SseEmitter는 Spring에서 SSE 기능을 지원하는 클래스로, 서버가 클라이언트에게 비동기적으로 데이터를 지속적으로 전송할 수 있도록 도와줍니다.
SseEmitter의 특징으로는
즉, SseEmitter는 Spring에서 SSE를 구현할 때 핵심 역할을 하는 객체이며, 클라이언트가 구독한 후 비동기적으로 이벤트를 전송하는데 활용됩니다.
SseEmitter는 이와 같이 사용합니다.
객체 생성
SseEmitter sseEmitter = new SseEmitter(timeout)
을 사용하여 인스턴스를 생성합니다.
데이터 전송
sseEmitter.send(data)
를 호출하여 클라이언트로 데이터를 전송합니다.
완료 처리
sseEmitter.complte()
로 스트림을 닫거나, onCompletion()을 활용해 자동으로 정리합니다.
이제 정말로 프로젝트에 적용해보겠습니다.
[SSE 연결]
public class SseEmitterManager {
private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter connect(final Long memberId) {
String emitterId = String.valueOf(memberId);
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
emitters.put(emitterId, emitter);
registerEmitterCallbacks(emitter, emitterId);
log.info("새로운 SSE 연결 생성: {}", emitterId);
return emitter;
}
private void registerEmitterCallbacks(SseEmitter emitter, String emitterId) {
emitter.onCompletion(() -> {
log.info("비동기 요청 완료");
removeEmitter(emitterId);
});
emitter.onTimeout(() -> {
log.info("시간 초과");
emitter.complete();
removeEmitter(emitterId);
});
emitter.onError((e) -> {
removeEmitter(emitterId);
throw new EmitterCallbackException("Emitter 에러발생", e);
});
}
private void removeEmitter(String emitterId) {
emitters.remove(emitterId);
}
}
[데이터 전송]
public class SseEmitterManager {
public void send(Member targetMember, Object data) {
String emitterId = String.valueOf(targetMember.getId());
SseEmitter emitter = emitters.get(emitterId);
try {
emitter.send(SseEmitter.event()
.id(emitterId)
.data(data));
} catch (IOException exception) {
removeEmitter(emitterId);
throw new SendFailedException("전송 실패", exception);
}
}
}
[실행 결과]
이와 같이 SSE 연결이 된 것을 확인할 수 있고, 데이터가 클라이언트에게 전달된 것을 확인할 수 있습니다.
이번 글에서는 SSE를 프로젝트에 적용하는 과정을 다뤘습니다.
다음 2편에서는 운영 환경에서의 SSE 최적화를 중심으로, 연결 유지, 다중 서버 대응 등에 대해 고민하고 해결한 내용을 공유하겠습니다.
해당 내용의 코드와 자세한 구현 방식은 아래 깃허브에서 확인할 수 있습니다.
☺️ 더 나은 개선 방향이나 궁금한 점이 있다면 언제든지 의견 남겨주세요!