Fitpass 웹서비스에서 실시간 알림 시스템 구현을 위해 Redis Pub/Sub 패턴을 도입한 기술적 의사결정에 대한 분석 현재 구현된 시스템들과 다른 대안들을 비교하여 Redis Pub/Sub 선택의 타당성을 검증
User와 Trainer 구분 : 사용자와 트레이너 각각에 대한 별도 알림 채널 필요
즉시 전달 : SSE(Server-Sent Events)를 통한 즉시 알림 전송
오프라인 처리 : 사용자가 오프라인일 때 알림 저장 및 후속 전송
다중 서버 지원 : 서버 확장 시에도 일관된 알림 전달
알림 타입 관리 다양한 Notification Type에 따른 알림 분류
public enum NotificationType {
YATA, REVIEW, CHAT, RESERVATION, MEMBERSHIP
}
로 수정 가능성 있음
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 비즈니스 │ │ NotificationPub │ │ Redis Pub/Sub │
│ 로직 │───▶│ lisher │───▶│ Channel │
│ (Service) │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ SSE Emitter │◀───│ NotificationSub │◀───│ Message │
│ (실시간 전송) │ │ scriber │ │ Listener │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Publisher (알림 발송)
@Service
@RequiredArgsConstructor
public class NotificationPublisher {
private final RedisTemplate<String, Object> redisTemplate;
private static final String NOTIFICATION_CHANNEL = "notification:";
// User 알림: notification:USER:{userId}
public void publishNotificationToUser(Long userId, NotificationType type,
String content, String url) {
NotificationEvent event = new NotificationEvent(
userId, "USER", type, content, url, LocalDateTime.now()
);
String channel = NOTIFICATION_CHANNEL + "USER:" + userId;
redisTemplate.convertAndSend(channel, event);
}
// Trainer 알림: notification:TRAINER:{trainerId}
public void publishNotificationToTrainer(Long trainerId, NotificationType type,
String content, String url) {
NotificationEvent event = new NotificationEvent(
trainerId, "TRAINER", type, content, url, LocalDateTime.now()
);
String channel = NOTIFICATION_CHANNEL + "TRAINER:" + trainerId;
redisTemplate.convertAndSend(channel, event);
}
}
Subscriber (알림 수신 및 처리)
@Component
@RequiredArgsConstructor
public class NotificationSubscriber implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
try {
// Redis 메시지를 NotificationEvent로 역직렬화
NotificationEvent event = mapper.readValue(message.getBody(),
NotificationEvent.class);
// 1. DB에 알림 저장 (영속성)
Notify notification = createAndSaveNotification(event);
// 2. SSE를 통한 실시간 전송
sendToSseEmitters(event.getReceiverId(),
event.getReceiverType(), notification);
} catch (Exception e) {
// 에러 처리
}
}
}
채널 구독 설정
@Configuration
public class NotificationConfig implements ApplicationListener<ApplicationReadyEvent> {
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
// 패턴 매칭으로 모든 알림 채널 구독: notification:*
redisMessageListenerContainer.addMessageListener(notificationSubscriber,
new PatternTopic("notification:*"));
}
}
| 구분 | 기본 구현 | 현재 Redis 구현 |
|---|---|---|
| 다중 서버 지원 | 불가능 | 완벽 지원 |
| 메시지 전파 | 서버 내부만 | 모든 서버에 전파 |
| 오프라인 처리 | 별도 구현 필요 | RedisDao로 저장 |
| 채널 관리 | 복잡한 구현 | 패턴 매칭 지원 |
| 구현 복잡도 | 높음 | 낮음 |
Apache Kafka
장점 :
단점 :
현재 Reids 구현 우위 :
// 간단한 알림 발송 - Redis
notificationPublisher.publishNotificationToUser(userId, RESERVATION_COMPLETE,
"예약이 완료되었습니다", "/reservations");
// vs Kafka의 경우 필요한 설정
// - Producer 설정
// - Topic 관리
// - Partition 설정
// - Consumer Group 관리
// - Offset 관리
RabbitMQ
장점 :
단점 :
// 현재 Redis 패턴 매칭 - 매우 간단
new PatternTopic("notification:*") // 모든 알림 구독
// vs RabbitMQ Exchange/Routing 설정
// - Exchange 타입 선택
// - Binding Key 설정
// - Queue 관리
// - Dead Letter Queue 설정
// 명확한 채널 분리로 권한 관리 용이
"notification:USER:" + userId // 사용자 전용 채널
"notification:TRAINER:" + trainerId // 트레이너 전용 채널
// RedisConfig.java에서 이미 설정된 인프라 활용
- Cache 용도로 이미 Redis 사용 중
- 별도 메시지 브로커 불필요
- 운영 복잡성 최소화
// NotificationSubscriber에서 Redis → SSE 연결
private void sendToSseEmitters(Long receiverId, String receiverType, Notify notification) {
Map<String, SseEmitter> emitters = emitterRepository.findAllEmittersById(receiverId);
emitters.forEach((key, emitter) -> {
emitter.send(SseEmitter.event()
.id(key)
.name("notification")
.data(NotifyDto.Response.createResponse(notification)));
});
}
// NotifyService.java - 오프라인 사용자 대응
if (emitters.isEmpty()) {
redisDao.saveNotifyToRedis(receiverId, notification); // Redis에 저장
return;
}
// 예약 서비스에서 알림 발송
@Service
public class ReservationService {
public void completeReservation(Long userId, ReservationData reservation) {
// 1. 예약 처리 로직
// ...
// 2. 사용자에게 예약 완료 알림
notificationPublisher.publishNotificationToUser(
userId,
NotificationType.RESERVATION_COMPLETE,
"예약이 완료되었습니다: " + reservation.getClassName(),
"/reservations/" + reservation.getId()
);
// 3. 트레이너에게 새 예약 알림
notificationPublisher.publishNotificationToTrainer(
reservation.getTrainerId(),
NotificationType.NEW_RESERVATION,
"새로운 예약이 접수되었습니다",
"/trainer/reservations/" + reservation.getId()
);
}
}
// 결제 서비스 연동
public void processPayment(PaymentData payment) {
if (payment.isSuccess()) {
notificationPublisher.publishNotificationToUser(
payment.getUserId(),
NotificationType.PAYMENT_SUCCESS,
"결제가 완료되었습니다: " + payment.getAmount() + "원",
"/payments/" + payment.getId()
);
} else {
notificationPublisher.publishNotificationToUser(
payment.getUserId(),
NotificationType.PAYMENT_FAILED,
"결제에 실패했습니다. 다시 시도해주세요.",
"/payments/retry"
);
}
}
지연시간 : Redis 평균 1ms 미만으로 즉시 알림에 최적
처리량 : Fitpass 규모에서 충분한 성능 (초당 수만 건 처리 가능)
메모리 효율성 : 패턴 매칭으로 효율적인 구독 관리
// 현재 구조의 확장 가능성
- Redis Cluster로 수평 확장 가능
- 서버 인스턴스 추가 시 자동으로 알림 분산
- 채널 패턴으로 새로운 알림 타입 쉽게 추가
// Redis CLI로 실시간 모니터링 가능
redis-cli monitor // 모든 명령어 실시간 확인
redis-cli psubscribe "notification:*" // 알림 메시지 실시간 확인
// 테스트 환경에서 쉬운 알림 확인
@Test
public void testNotification() {
// Given
Long userId = 1L;
// When
notificationPublisher.publishNotificationToUser(userId, TEST_TYPE, "test", "/test");
// Then
// Redis 메시지 직접 확인 가능
}
현재 상화 :
구현된 보완책 :
/ NotifyService.java에서 DB 영속성 보장
Notify notification = notifyRepository.save(createNotification(...));
// 오프라인 사용자를 위한 Redis 저장
redisDao.saveNotifyToRedis(receiverId, notification);
// 향후 개선 방안 예시
public void publishCriticalNotification(Long userId, NotificationType type,
String content, String url) {
// 1. 일반 Pub/Sub 발송
publishNotificationToUser(userId, type, content, url);
// 2. 중요 알림은 추가로 백업 저장
redisDao.saveCriticalNotification(userId, type, content, url);
// 3. 필요시 외부 알림 (SMS, 이메일) 연동
}
현재 우리 프로젝트에서 Redis Pub/Sub이 최적인 이유 :
기존 인프라 활용 : 이미 캐시용으로 사용 중인 Redis 인프라 활용
개발 생산성 : 간단한 API로 빠른 개발과 안정적인 운영
완벽한 통합 : SSE, DB 저장, 오프라인 처리가 하나의 시스템으로 통합
적정 기술 : Fitpass 규모와 요구사항에 적합한 기술 선택