만들어보기/Spring

[SpringBoot] SSE를 이용한 실시간 알림 전송 구현기 (feat. Redis Pub/Sub)

다섯자두 2025. 4. 8. 11:33

1. 들어가며

음식점 웨이팅 프로젝트를 진행하면서, 사용자의 웨이팅이 호출되었을 때 실시간 알림을 전송해야 했다.

알림을 전송하는 기능은 처음 구현해보았기에 기록해본다.

2. 기술 선택 배경

▶ SSE + Redis Pub/Sub 조합을 선택했다, 그 이유는?

다음과 같이 적용 가능한 다양한 기술들을 비교해보았다.

기술 설명 장점 단점 프로젝트 기준 평가
SSE 서버 → 클라이언트 단방향 스트리밍 (HTTP 기반) 구현 간단, 브라우저 지원, HTTP 기반이라 인프라 변경 최소 양방향 불가, 커넥션 관리 필요 (브라우저마다 약 6개) ✅ 현재 단방향 알림 구현 중이므로 적합함
웹소켓 서버 ↔ 클라이언트 양방향 실시간 통신 강력한 실시간성, 양방향 가능, 낮은 지연 인프라 구성 부담 (로드밸런서 설정 필요 등), 연결 유지 비용 ↑ 단방향 알림 구현 중이므로 양방향 연결은 오버스펙
Redis
Pub/Sub
Redis 채널 기반 브로드캐스트 단순한 구조, 빠른 전파 속도, 멀티 인스턴스 대응 가능 메시지 내구성 없음 (Consumer가 구독 중이 아닐 경우 유실), 영속성 부족 ✅ DB 저장으로 보완하여 사용 가능하며 가장 간단히 멀티 인스턴스 대응 가능
Kafka 내구성 강한 분산 메시징 시스템 메세지 유실 방지 가능, 전송 실패시 재처리 가능, 확장성 뛰어남 운영 복잡, 설정 많음, 초기 진입 장벽이 크다 예약 자동취소, 푸시/SMS 알림 등 신뢰성/확장성 요구 시 적합, 지금은 오버스펙
MQ
(SQS 등)
큐 기반 비동기 처리 메세지 유실 방지 가능, 안정성 높음, 다양한 연동 가능 실시간성이 떨어짐, polling비용 발생 주문 내역 저장, 이메일 전송 같은 비실시간 대량 작업에 적합, 현재 요구사항에는 맞지 않음
Polling 일정 주기로 상태 확인 구현이 단순함 실시간성 낮고 서버 부하 ↑ 실시간 알림 요구되는 경우 부적합

현재 우리 프로젝트 내 요구사항은 다음과 같았다.

  • 클라이언트는 웹 브라우저 기반으로 서버 → 사용자 (단방향)알림만 필요하다.
  • 서버는 멀티 인스턴스로 배포될 가능성이 있다.
  • 웨이팅 어플에서 알림은 중요한 리소스이므로 반드시 DB에 저장되어야 하고, 조회 가능하여야 한다.
  • 초기엔 구현이 간단해야 하지만, 장기적으로 확장 가능하여야 한다.

위 조건들을 종합한 결과

  • 실시간 통신 방식으로는 SSE를,
  • 멀티 인스턴스 대응 방식으로는 Redis Pub/Sub를 선택한 조합으로 설계하였다.

예약 자동취소 기능이 추후에 추가되어 Kafka 도입 가능성이 있는 상황이었기에 Kafka 기반 아키텍처로 무리 없이 전환 가능하도록 설계하였다.

이벤트 기반 설계를 도입했다, 그 이유는?

알림은 대부분 도메인 로직의 후처리 작업에 해당하며, 동기 처리 시 다음과 같은 문제가 발생할 수 있다.

  • 알림 전송 실패가 도메인 트랜잭션 전체의 실패로 이어질 수 있다.
    • ex) 사용자 A가 현재 알림 연결이 안 되어있다는 이유로 호출 자체가 불가능하게 됨
  • 핵심 비즈니스 로직과 알림 로직이 강하게 결합되어 코드의 복잡도가 상승한다.

이에 따라 이벤트 기반 구조를 통해 도메인 로직과 알림 처리를 분리함으로써 다음과 같은 이점을 얻고자 하였다.

  1. 핵심 도메인 정합성 유지
    • 도메인 로직은 핵심 상태 변경에만 집중하도록 분리한다. (ex. DB 저장, 상태 변경 등)
    • 알림 로직은 도메인 이벤트 발행 이후 별도 핸들러에서 수행한다.
  2. 알림 실패 시 재처리 구조 설계 가능
    • 알림 전송 실패는 언제든 발생할 수 있다. (ex. SSE 연결 끊김)
    • 알림 로직을 따로 빼서 재처리 구조 설계가 가능하다.
  3. 유지보수성과 테스트 용이성 향상
    • 알림 로직을 도메인 로직에서 완전히 분리함으로써, 각 로직을 독립적으로 테스트할 수 있다.
    • 알림 채널 추가시 (ex. email, SMS) 도메인 로직 수정 없이 확장 가능하다.

3. 전체 구조 요약

 주요 구현 코드

두 이벤트 리스너의 내용이다. 각각의 TransactionPhase 선택 시 다음과 같은 사항들을 고려했다.

    /**
     * WaitingCalledEventHandler 내의 내용입니다.
     */
    @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
    public void handle(WaitingCalledEvent event) {
        Notification notification = notificationService.create(
                event.getUserId(),
                NotificationType.WAITING_CALLED
        );
        eventPublisher.publishEvent(NotificationEvent.from(notification));
    }
  • 왜 ``TransactionPhase.BEFORE_COMMIT`` 인가?
    • AFTER_COMMIT으로 설정 시 Notification 저장에 실패했지만 Waiting의 상태는 '호출'로 변경될 수 있다.
      • (ex. `사장님` : 저는 호출했는데요? `사용자`: 저는 알림 못 받았는데요? 알림 목록에도 없는데요?)
    • 현재 웨이팅 프로젝트에서 호출 알림은 반드시 사용자에게 도달해야 하는 중요한 로직이다.
    • 따라서 Waiting 상태 변경과 Notification 저장을 같은 트랜잭션으로 묶어 하나가 잘못될 경우 모두 롤백되도록 보장한다.
    • → 알림 저장 실패시 사장님께 오류를 알려 다시 호출을 시도하게 유도할 수 있다.
    • 단, 저장된 알림을 기반으로 하는 실제 알림 전송은 트랜잭션 밖에서 안전하게 처리한다.
    /**
     * NotificationEventHandler 내의 내용입니다.
     */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(NotificationEvent event) {
        notificationPublisher.publish(event);
    }
  • 왜 ``TransactionPhase.AFTER_COMMIT`` 인가?
    • 실제 전송은 외부 시스템과의 연결이 필요해 실패 가능성이 더 높다.
    • 알림 전송 실패로 인해 전체 트랜잭션이 롤백되어서는 안 되며, 전송 실패는 재시도 로직이나 로그로 대응해야 한다.

다음은 Redis Publisher & Subscriber의 내용이다.

@Slf4j
@Component
@RequiredArgsConstructor
public class RedisNotificationPublisher implements NotificationPublisher {

    private final RedisTemplate<String, String> redisTemplate;
    private final ObjectMapper objectMapper;
    private final ChannelTopic channelTopic;

    @Override
    public void publish(NotificationEvent event) {
        try {
            String message = objectMapper.writeValueAsString(event);
            redisTemplate.convertAndSend(channelTopic.getTopic(), message);
        } catch (JsonProcessingException e) {
            log.error("Redis publish 실패: {}", e.getMessage(), e);
        }
    }
}
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisSubscriber implements MessageListener {

    private final ObjectMapper objectMapper;
    private final SseEmitterManager sseEmitterManager;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            String json = new String(message.getBody());
            NotificationEvent event = objectMapper.readValue(json, NotificationEvent.class);
            log.debug("Redis 메시지 수신: userId={}, type={}", event.getUserId(), event.getType());

            sseEmitterManager.send(event.getUserId(), event);
        } catch (JsonProcessingException e) {
            log.error("Redis 메시지 파싱 실패", e);
        } catch (Exception e) {
            log.error("SSE 알림 전송 실패", e);
        }
    }
}

Redis를 통해 퍼블리시된 NotificationEvent는 RedisSubscriber에 의해 수신되고, 해당 사용자의 SSE 연결을 통해 실시간으로 전달된다.

다음은 실제 전송을 담당하는 SseEmitterManager의 내용이다.

@Slf4j
@Component
public class SseEmitterManager {

    private final static Long SSE_TIMEOUT_MS = 60 * 1000L * 5; // 5분

    private final Map<Long, SseEmitter> emitterMap = new ConcurrentHashMap<>();


    public SseEmitter connect(Long userId) {
        SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
        emitterMap.put(userId, emitter);

        emitter.onCompletion(() -> emitterMap.remove(userId));
        emitter.onTimeout(() -> emitterMap.remove(userId));
        emitter.onError(e -> emitterMap.remove(userId));

        return emitter;
    }

    public void send(Long userId, NotificationEvent event) {
        SseEmitter emitter = emitterMap.get(userId);
        if (emitter != null) {
            try {
                emitter.send(SseEmitter.event()
                        .name("notification")
                        .data(event));
            } catch (IOException e) {
                log.error("SSE 전송 실패: userId={}", userId, e);
                emitterMap.remove(userId);
            }
        }
    }
}
  • 사용자가 SSE 연결을 요청하면 emitterMap에 userId와 SseEmitter를 저장한다.
  • 추후 NotificationEvent, userId와 함께 전송 요청이 들어오면 현재 자신의 emitterMap에 해당 userId 키가 있는지 확인하고, 있을 경우 사용자에게 알림을 전송한다.
  • emitterMap은 하나의 서버의 모든 스레드가 공유할 수 있도록 ConcurrentHashMap을 사용하였다.

4. 테스트 결과

사장님이 웨이팅을 호출하는 상황을 가장하여 `WaitingCalledEvent`를 발행하는 코드를 짜고, 실행해보았다.

임의로 html를 만들고 SSE 연결을 한 뒤 확인해보니, 호출 요청을 전송하자 바로 실시간 알림이 추가되는 것을 확인하였다.

데이터베이스에 waitings의 상태와 called_at도 정상적으로 업데이트 되며, notification도 저장됨을 확인하였다.

5. 한계점과 보완할 점

  1. SSE 연결 유지 이슈 대응 필요
    • 한계점 : 네트워크 불안정이나 사용자 이탈 시 SseEmitter가 만료되거나 끊길 수 있다. 이 때 연결 종료 상황에 대한 후처리가 부족하다.
    • Scheduled 작업 등을 통해 emiiterMap을 특정 주기로 정리하는 청소 작업을 추가해볼 수 있을 것 같다.
    • 장기적으로는 WebSocket이나 Push 알림 기반 구조로의 전환도 고려해보아야 할 듯
  2. Redis Pub/Sub의 일회성 한계
    • 한계점 : Redis Pub/Sub은 구독 당시 메시지만 수신 가능하다. 따라서 수신 누락 시 재처리가 불가하다. 만일 알림이 유실되면 사용자는 알림 목록을 들어가보지 않는 이상 알림이 왔음을 알지 못 한다.
    • Kafka나 Redis Stream 같은 내구성이 있는 메세지 큐를 도입해서 메세지 이력을 저장하고, 재처리를 할 수 있다.
    • 최소한 알림 전송 결과 상태 필드를 Notification 엔티티에 추가하고 실패한 메세지는 재전송을 시도하는 재시도 로직을 추가해보아야 할 것 같다.
  3. 단일 알림 채널 구조
    • 한계점 : 현재 모든 알림을 하나의 Redis 채널로 퍼블리시 한다. 향후 알림 종류가 많아지면 코드 복잡도가 상승하고 스케일링이 어려워질 것으로 예상된다.
    • 알림 타입별로 Redis 채널을 분리해서 구성하거나, type별 라우팅 핸들러 구조를 구성해볼 수 있다.
    • 혹은 Kafka의 토픽 기반 설계로 확장하면 다채널 구조로 자연스럽게 대응 가능하다.
  4. 알림 실패 시 재시도 전략 부족
    • 한계점 : 현재 SSE 전송 실패, Redis publish 실패 등은 단순 로그만 기록되고 재시도나 보상 로직은 없다.
    • 실패 이벤트를 DB나 큐에 저장 하고 재시도 작업 스케줄러를 구성해볼 수 있다.
    • Notification 엔티티에 전송 결과 상태 기록해서 실패한 건만 필터링 후 재처리해볼 수 있다.
    • 향후 Dead Letter Queue, 재시도 횟수 제한 등으로 확장 가능하다.
  5. 인증 및 보안 처리 부족
    • 한계점 : 현재 SSE 연결 시 단순히 userId 기반으로 연결하고 있다.
    • 최초 연결 시 JWT(현재 채택 중) 기반 인증 절차를 필수화해야할 것 같다. (EventSource 사용시 커스텀 헤더 설정이 불가능하다던데 흠.. 더 알아봐야할 것 같다.)
    • Spring Security와 연결해서 인증된 사용자만 emitterMap에 등록되도록 구조 개선이 필요하다.

고도화 기능이 추가되면서 Kafka 도입이 확정된 상태이다.

따라서 위 내용 중 1번(SSE 연결 유지 이슈), 5번(보안 및 인증 처리)을 Kafka 도입 전 우선 구현하고,
나머지 항목들은 Kafka 도입 이후 기능에 맞춰 Kafka의 장점을 최대한 활용하는 방향으로 개선해 나갈 예정이다.