[SpringBoot] SSE를 이용한 실시간 알림 전송 구현기 (feat. Redis Pub/Sub)
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가 현재 알림 연결이 안 되어있다는 이유로 호출 자체가 불가능하게 됨
- 핵심 비즈니스 로직과 알림 로직이 강하게 결합되어 코드의 복잡도가 상승한다.
이에 따라 이벤트 기반 구조를 통해 도메인 로직과 알림 처리를 분리함으로써 다음과 같은 이점을 얻고자 하였다.
- 핵심 도메인 정합성 유지
- 도메인 로직은 핵심 상태 변경에만 집중하도록 분리한다. (ex. DB 저장, 상태 변경 등)
- 알림 로직은 도메인 이벤트 발행 이후 별도 핸들러에서 수행한다.
- 알림 실패 시 재처리 구조 설계 가능
- 알림 전송 실패는 언제든 발생할 수 있다. (ex. SSE 연결 끊김)
- 알림 로직을 따로 빼서 재처리 구조 설계가 가능하다.
- 유지보수성과 테스트 용이성 향상
- 알림 로직을 도메인 로직에서 완전히 분리함으로써, 각 로직을 독립적으로 테스트할 수 있다.
- 알림 채널 추가시 (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 저장을 같은 트랜잭션으로 묶어 하나가 잘못될 경우 모두 롤백되도록 보장한다.
- → 알림 저장 실패시 사장님께 오류를 알려 다시 호출을 시도하게 유도할 수 있다.
- 단, 저장된 알림을 기반으로 하는 실제 알림 전송은 트랜잭션 밖에서 안전하게 처리한다.
- AFTER_COMMIT으로 설정 시 Notification 저장에 실패했지만 Waiting의 상태는 '호출'로 변경될 수 있다.
/**
* 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. 한계점과 보완할 점
- SSE 연결 유지 이슈 대응 필요
- 한계점 : 네트워크 불안정이나 사용자 이탈 시 SseEmitter가 만료되거나 끊길 수 있다. 이 때 연결 종료 상황에 대한 후처리가 부족하다.
- Scheduled 작업 등을 통해 emiiterMap을 특정 주기로 정리하는 청소 작업을 추가해볼 수 있을 것 같다.
- 장기적으로는 WebSocket이나 Push 알림 기반 구조로의 전환도 고려해보아야 할 듯
- Redis Pub/Sub의 일회성 한계
- 한계점 : Redis Pub/Sub은 구독 당시 메시지만 수신 가능하다. 따라서 수신 누락 시 재처리가 불가하다. 만일 알림이 유실되면 사용자는 알림 목록을 들어가보지 않는 이상 알림이 왔음을 알지 못 한다.
- Kafka나 Redis Stream 같은 내구성이 있는 메세지 큐를 도입해서 메세지 이력을 저장하고, 재처리를 할 수 있다.
- 최소한 알림 전송 결과 상태 필드를 Notification 엔티티에 추가하고 실패한 메세지는 재전송을 시도하는 재시도 로직을 추가해보아야 할 것 같다.
- 단일 알림 채널 구조
- 한계점 : 현재 모든 알림을 하나의 Redis 채널로 퍼블리시 한다. 향후 알림 종류가 많아지면 코드 복잡도가 상승하고 스케일링이 어려워질 것으로 예상된다.
- 알림 타입별로 Redis 채널을 분리해서 구성하거나, type별 라우팅 핸들러 구조를 구성해볼 수 있다.
- 혹은 Kafka의 토픽 기반 설계로 확장하면 다채널 구조로 자연스럽게 대응 가능하다.
- 알림 실패 시 재시도 전략 부족
- 한계점 : 현재 SSE 전송 실패, Redis publish 실패 등은 단순 로그만 기록되고 재시도나 보상 로직은 없다.
- 실패 이벤트를 DB나 큐에 저장 하고 재시도 작업 스케줄러를 구성해볼 수 있다.
- Notification 엔티티에 전송 결과 상태 기록해서 실패한 건만 필터링 후 재처리해볼 수 있다.
- 향후 Dead Letter Queue, 재시도 횟수 제한 등으로 확장 가능하다.
- 인증 및 보안 처리 부족
- 한계점 : 현재 SSE 연결 시 단순히 userId 기반으로 연결하고 있다.
- 최초 연결 시 JWT(현재 채택 중) 기반 인증 절차를 필수화해야할 것 같다. (EventSource 사용시 커스텀 헤더 설정이 불가능하다던데 흠.. 더 알아봐야할 것 같다.)
- Spring Security와 연결해서 인증된 사용자만 emitterMap에 등록되도록 구조 개선이 필요하다.
고도화 기능이 추가되면서 Kafka 도입이 확정된 상태이다.
따라서 위 내용 중 1번(SSE 연결 유지 이슈), 5번(보안 및 인증 처리)을 Kafka 도입 전 우선 구현하고,
나머지 항목들은 Kafka 도입 이후 기능에 맞춰 Kafka의 장점을 최대한 활용하는 방향으로 개선해 나갈 예정이다.