이전 1편에서는 SSE(Server-Sent Events)의 개념과 단일 서버 환경에서의 적용 과정을 다루었습니다.
이번글에서는 운영환경에서의 확장성과 안전성 확보를 중심으로, Redis를 통한 SSE 연결 관리와 Kafka를 통한 다중 서버 메시지 브로드캐스트 방식을 다루겠습니다.
무중단 배포, 스케일 아웃, 서버 장애 대응 등 실전 상황에서 고려할 요소들이 많아지면서, 단일 서버에서 관리되던 SSE 연결 방식에는 한계가 있습니다. 이를 해결하기 위해 저는 Redis와 Kafka를 도입하였습니다.
단일 서버 환경에서는 각 사용자와의 SSE 연결을 SseEmitter를 통해 서버 메모리에서 직접 관리하면 충분합니다.
하지만 서비스가 확장되어 서버가 여러 대로 분산되면, 상황이 달라집니다.
결과적으로, 서버 간 연결 상태 공유와 메시지 전달이 보장되지 않기 때문에 확장성과 안정성 모두에 제약이 생기게 됩니다.
이러한 문제를 해결하기 위해, 저는 다음과 같은 구조로 접근했습니다.
다중 서버 환경에서 각 사용자의 연결 상태를 공유하기 위해, Redis를 캐시 저장소로 활용하여 SSE 연결 상태를 공유하기로 했습니다.
SSE 연결 정보를 저장하기 위해 Redis Hash 구조를 사용합니다.
Hash는 하나의 키 아래 여러 필드와 값이 저장할 수 있기 때문에,
sse_connections라는 키에 emitterId와 해당 사용자가 연결된 serverId를 쌍으로 저장합니다.
Hash 자료구조를 사용하기 때문에 특정 사용자의 연결 상태를 빠르게 조회할 수 있습니다.
public SseEmitter connect(final Long memberId) {
String emitterId = String.valueOf(memberId);
SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));
String serverId = serverIdProvider.getServerId();
redisTemplate.opsForHash().put("sse_connections", emitterId, serverId);
registerEmitterCallbacks(emitter, emitterId);
sendToClient(emitter, emitterId, "이벤트 스트림 생성 memberId: " + memberId);
return emitter;
}
이 코드와 같이 SSE 연결 메소드인 connect 가 호출될 때, 캐시 스토리지에 저장해줍니다.

Key: sse_connections
Field (Key): emitterId ("7")
Value: SERVER_ID ("399a6ff8-e8ba-478f-8136-81a9699bf841")
이를 통해 특정 사용자의 SSE 연결이 어느 서버에 존재하는지를 쉽게 알 수 있습니다.
Redis에 연결 상태를 저장한다고 해서 알림 전송 문제가 해결되지 않습니다.
예를 들어, 사용자가 A 서버에 연결된 상태에서 B서버에서 알림이 발생했다면, B 서버는 해당 사용자가 어느 서버에 연결되어 있는지를 Redis를 통해 알 수는 있어도, 실제로 해당 알림을 직접 전송할 수는 없습니다.
이 문제를 해결하기 위해서 Kafka를 도입합니다.
앞서 Redis를 활용해 각 사용자의 SSE 연결 상태와 서버 ID를 저장했습니다.
하지만 메시지를 다른 서버에 있는 사용자에게 알림으로 전달하는 것까지는 해결되지 않았습니다.
이를 해결하기 위해 Kafka 메시지 브로커를 도입합니다.
분산 환경에서 안정적인 데이터 스트리밍을 지원하는 메시지 브로커입니다. 이를 활용하면 멀티 서버 환경에서도 효율적인 이벤트 전파와 메시지 처리가 가능합니다.
Producer는 메시지를 생성해서 Kafka에 전달하는 역할을 합니다.
SSE 알림 시스템에서는 특정 사용자에게 전송할 메시지를 생성하고,
해당 사용자가 연결된 서버(serverId)로 메시지를 Kafka 토픽에 전송합니다.
즉, 누구에게 어떤 메시지를 보낼지 결정하고 Kafka로 보낸다는 점에서 ‘생산자’ 역할을 합니다.
아래는 Producer의 예시 코드입니다.
public void send(Member targetMember, String message) {
String emitterId = String.valueOf(targetMember.getId());
String serverId = (String) redisTemplate.opsForHash().get("sse_connections", emitterId);
if (serverId != null) {
String data = emitterId + "|" + message;
kafkaTemplate.send("sse_connections", emitterId, data);
}
}
send 메소드는 메시지를 targetMember에게 전송하는 메소드입니다.
이 메소드는 Kafka의 sse_connections 토픽에 메시지를 보내는 Producer 역할을 합니다.
Kafka 메시지를 받은 각 서버는 자신이 담당하는 serverId에 해당하는 메시지만 소비하고, 그 안에 포함된 emitterId를 기준으로 실제 SSE 연결에 메시지를 전송합니다.
Consumer는 Kafka에 저장된 메시지를 받아서 처리하는 역할을 합니다.
Kafka를 통해 전달된 메시지 중 자신이 담당하는 서버(serverId)에 해당하는 메시지만 받아서,
해당 사용자(emitterId)의 SSE 연결을 찾아 메시지를 전송합니다.
즉, Kafka로부터 메시지를 소비하고, 적절한 사용자에게 전달하는 ‘소비자’ 역할을 수행합니다.
아래는 Consumer의 예시 코드입니다.
@KafkaListener(topics = "sse_notifications", groupId = "#{serverIdProvider.getServerId()}")
public void listen(String message) {
String[] parts = message.split("\\|");
if (parts.length < 2) {
return;
}
String emitterId = parts[0];
String content = parts[1];
SseEmitter emitter = emitterRepository.findById(emitterId);
if (emitter != null) {
sseEmitterManager.sendToClient(emitter, emitterId, content);
}
}
이처럼 각 서버는 자신이 관리하는 emitterId에 해당하는 메시지만 처리하고, 클라이언트와의 SSE 연결이 유지되는 동안 실시간 알림을 안정적으로 전달할 수 있습니다.
Kafka는 일반적으로 여러 서버에서 운영이 되지만, 저는 단일 서버 환경에서 포트를 나누어 동작을 검증했습니다.
위의 예시 코드의 동작 과정을 살펴보겠습니다.

이러한 구조 덕분에, 어떤 서버에서든 사용자가 연결된 서버를 찾아 메시지를 전송할 수 있는 확장 가능하고 안정적인 아키텍처가 완성됩니다.
이번 글에서는 SSE 연결 상태를 Redis로 공유하고, Kafka를 통해 메시지를 안정적으로 전달하는 구조를 정리해보았습니다.
멀티 서버 환경에서도 유실 없이 알림을 전송할 수 있는 방법을 고민 중이라면, 위와 같은 방식이 하나의 좋은 선택지가 될 수 있습니다.
해당 내용의 코드와 자세한 구현 방식과 실제 동작은 아래 깃허브에서 확인할 수 있습니다.
☺️ 더 나은 개선 방향이나 궁금한 점이 있다면 언제든지 의견 남겨주세요!