이번 글에서는 무선 데이터 거래 플랫폼 ‘다챠’를 개발하며 실시간 알림 기능을 구현했던 경험을 공유하고자 합니다. 사용자가 상품을 구매하거나 입찰하는 등 주요 행동을 했을 때, 안정적으로 알림을 보내주는 것은 서비스의 필수적인 기능이었습니다.
처음에는 단일 서버에서 ConcurrentHashMap과 SSE(Server-Sent Events)를 이용한 단순한 방식으로 시작했습니다. 하지만 서비스 확장을 위해 로드밸런서를 도입하고 다중 인스턴스로 전환하면서부터 예상치 못한 문제들이 발생하기 시작했습니다.
이 글에서는 제가 직접 겪은 트러블과, 이를 Redis Pub/Sub과 Kafka를 활용해 해결하며 안정적인 실시간 알림 시스템을 구축한 경험을 소개해드리고자 합니다.
가장 처음 설계했던 알림 시스템의 구조는 매우 단순하고 직관적이었습니다.
Long)를 Key로, 클라이언트와 연결된 SseEmitter 객체를 Value로 갖는 ConcurrentHashMap을 사용했습니다.SseEmitterService가 Map에서 해당 사용자의 Emitter를 찾아 직접 메시지를 전송했습니다.하지만 로드밸런서를 통해 다중 인스턴스 구조로 확장되자 많은 문제가 발생했습니다.
결국 각 서버 인스턴스가 자신의 메모리에만 연결 상태를 가지고 있다는 점이 근본적인 문제였습니다.
SseEmitter 객체 자체는 직렬화가 불가능해 여러 인스턴스가 공유할 수 없었습니다. 그래서 저희는 접근 방식을 바꿔, "이 사용자에게 알림을 보내라"는 신호를 모든 인스턴스에 전파하기로 했습니다. 이 역할을 수행하기 위해 Redis의 Pub/Sub 기능을 도입했습니다.
Redis Pub/Sub은 발행/구독(Publish/Subscribe) 모델을 따르는 메시징 시스템입니다.
이를 통해 어떤 인스턴스든 알림이 필요할 때 Redis 채널에 메시지를 발행하면, 해당 채널을 구독하는 모든 인스턴스가 메시지를 받아 처리할 수 있게 됩니다.
이 구조에서 각 인스턴스는 Redis로부터 메시지를 받으면, 자신의 메모리에 저장된 ConcurrentHashMap을 확인합니다. 그리고 실제 Emitter 연결을 가지고 있는 단 하나의 인스턴스만이 클라이언트에게 알림을 최종 전송합니다.
아래 코드를 통해 동작 흐름을 이해할 수 있습니다. Redis 채널에 메시지가 도착하면 onMessage 메소드가 실행됩니다.
// RedisNotificationSubscriber.java
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisNotificationSubscriber implements MessageListener {
private final ObjectMapper objectMapper;
private final SseEmitterService sseEmitterService;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
// Redis로부터 받은 메시지를 DTO로 변환
String json = new String(message.getBody());
NotificationDto dto = objectMapper.readValue(json, NotificationDto.class);
log.info("[RedisSubscriber] 수신된 알림: {}", dto);
// 자신의 SseEmitterService에게 알림 전송을 위임
sseEmitterService.send(dto.getUserId(), dto);
log.info("[RedisSubscriber] SSE 전송 완료: userId={}", dto.getUserId());
} catch (Exception e) {
log.error("[RedisSubscriber] 메시지 처리 실패", e);
}
}
}
여기서 호출하는 sseEmitterService.send() 메소드는 오직 자신의 메모리에 있는 emitters 맵만 확인합니다.
// SseEmitterService.java
public void send(Long userId, NotificationDto notificationDto) {
// 자신의 메모리에서만 Emitter를 찾는다.
SseEmitter emitter = emitters.get(userId);
if (emitter != null) {
// Emitter가 존재하면 클라이언트로 전송
sendToClient(emitter, userId, "notification", notificationDto);
} else {
// Emitter가 없으면 아무것도 하지 않고 무시
log.warn("[send] 알림을 보낼 Emitter를 찾을 수 없습니다. userId={}", userId);
}
}
이로써 온라인 상태인 사용자에 대한 알림 유실 문제를 해결할 수 있었습니다.
Redis Pub/Sub 도입으로 실시간 알림은 안정화되었지만, 새로운 문제가 드러났습니다. Redis Pub/Sub은 구독자가 없으면 메시지를 그냥 버리는 'Fire-and-Forget' 방식입니다. 만약 사용자가 오프라인 상태라면 활성화된 SseEmitter가 없으므로 Redis로 보낸 알림 신호는 그대로 증발합니다. 사용자가 나중에 접속해도 놓친 알림을 확인할 방법이 없었습니다.
알림 데이터의 영속성(Persistence)을 보장하기 위해, 저희는 Kafka를 도입했습니다. Kafka는 대용량 이벤트를 안정적으로 기록하고 처리할 수 있는 분산 스트리밍 플랫폼입니다.
따라서 전체적인 알림 처리 흐름을 다음과 같이 완성할 수 있었습니다.
notification-group)에 속한 단 하나의 NotificationService 인스턴스가 메시지를 받아, 알림 내역을 데이터베이스에 저장합니다. 이로써 데이터 영속성이 확보됩니다.alarm-channel)에 알림 신호를 발행합니다.// NotificationService.java
@Transactional
@KafkaListener(topics = "notification", groupId = "notification-group")
public void consume(AlarmCreationDto creationDto) {
log.info("[consume] 알림 생성 요청 수신: {}", creationDto.getContent());
// 1. User, Status 등 정보 조회
User user = findUserById(creationDto.getUserId());
Status unReadStatus = statusManager.getStatus("ALARM", "UNREAD");
// 2. 알림 객체를 생성하여 데이터베이스에 저장 (영속성 확보)
Alarm alarm = Alarm.builder() /* ... */ .build();
alarmRepository.save(alarm);
log.info("[consume] 알림 저장 완료: 알림 ID={}, 사용자 ID={}", alarm.getAlarmId(), user.getUserId());
// 3. 실시간 전송을 위한 DTO 생성
NotificationDto notificationDto = NotificationDto.fromEntity(alarm, findTransactionFeedById(creationDto.getTransactionFeedId()));
try {
// 4. Redis 채널에 실시간 전송 신호를 발행
String json = objectMapper.writeValueAsString(notificationDto);
redisTemplate.convertAndSend("alarm-channel", json);
log.info("[consume] Redis Pub 전송 완료: userId={}, alarmId={}", creationDto.getUserId(), alarm.getAlarmId());
} catch (Exception e) {
log.error("[consume] Redis Pub 전송 실패", e);
}
}
Kafka가 데이터의 안정적인 저장을 책임지고, Redis는 저장된 데이터를 실시간으로 전달하는 역할을 분담하도록 시스템을 구축하였습니다.

애플리케이션 아키텍처 개선 후에도 실제 배포 환경에서는 인프라단, 특히 리버스 프록시인 Nginx와 관련된 문제들이 남아있었습니다.
타임아웃 문제: SSE는 서버와 클라이언트가 긴 시간 연결을 유지해야 합니다. 하지만 일정 시간 데이터 교환이 없으면 Nginx는 유휴(idle) 연결로 판단하고 연결을 끊어버렸습니다.
heartbeat 이벤트를 보내 연결이 살아있음을 Nginx에게 알려주었습니다. SseEmitterService에 스케줄러를 추가하여 30초마다 ping 코멘트를 보내도록 구현했습니다.// SseEmitterService.java
public SseEmitter subscribe(Long userId) {
// ... Emitter 생성 ...
ScheduledFuture<?> heartbeat = scheduler.scheduleAtFixedRate(
() -> sendPing(emitter, userId),
30, 30, TimeUnit.SECONDS
);
// ...
return emitter;
}
private void sendPing(SseEmitter emitter, Long userId) {
try {
emitter.send(SseEmitter.event().comment("ping"));
log.info("[sendPing] Heartbeat 전송 성공: userId={}", userId);
} catch (Exception e) {
log.error("[sendPing] Heartbeat 전송 실패, 연결을 종료합니다. userId={}, error={}", userId, e.getMessage());
emitters.remove(userId);
}
}
버퍼링 문제: 서버에서 이벤트를 보냈음에도 클라이언트에서 한참 뒤에 수신되는 지연 현상이 있었습니다. 원인은 Nginx의 proxy_buffering 설정 때문이었습니다. Nginx는 백엔드 서버의 응답을 일단 자체 버퍼에 모았다가 보내는 것이 기본 동작이라 실시간 통신을 방해했습니다.
proxy_buffering off; 옵션을 추가하여 버퍼링을 비활성화했습니다.location /api/notifications/subscribe {
proxy_pass http://backend-servers;
proxy_http_version 1.1;
proxy_set_header Connection '';
# 실시간 전송을 위한 핵심 설정
proxy_buffering off;
proxy_cache off;
}
이번 경험을 통해 분산 환경에서의 시스템 설계에 대해 많은 것을 배울 수 있었습니다.
문제를 정의하고, 하나씩 해결하며 시스템을 발전시키는 과정을 통해 많은 것을 배울 수 있었습니다. 특히 백엔드와 프론트엔드의 코드뿐만 아니라, 인프라 환경까지 함께 조율하는 과정을 거치면서 다양한 경험을 할 수 있었던 것 같습니다.