알림 서비스 기능 (Notification Service) - fitpassTeam/fitpass GitHub Wiki
실시간 Server-Sent Events(SSE)와 Redis Pub/Sub을 활용한 알림 시스템입니다. 사용자(User)와 트레이너(Trainer) 모두에게 실시간 알림을 전송하며, 다양한 알림 타입을 지원합니다.
domain/notify/
├── controller/
│ └── NotifyController.java # SSE 구독 API
├── service/
│ ├── NotifyService.java # SSE 연결 및 알림 전송
│ ├── NotificationPublisher.java # Redis 메시지 발행
│ └── NotificationSubscriber.java # Redis 메시지 구독
├── entity/
│ └── Notify.java # 알림 엔티티
├── dto/
│ ├── NotifyDto.java # 응답 DTO
│ └── NotificationEvent.java # 이벤트 DTO
├── repository/
│ ├── NotifyRepository.java # 알림 JPA 레포지토리
│ └── EmitterRepository.java # SSE Emitter 메모리 저장소
├── enums/
│ ├── NotificationType.java # 알림 유형
│ └── ReceiverType.java # 수신자 유형
└── config/
└── NotificationConfig.java # Redis 구독 설정
SSE 연결 수립 → 알림 발생 → Redis 발행 → 구독자 수신 → DB 저장 → 실시간 전송 → 클라이언트 수신
- SSE(Server-Sent Events)를 통한 실시간 통신
- Redis Pub/Sub을 활용한 분산 환경 지원
- 사용자별 다중 연결 지원
- 데이터베이스 저장을 통한 알림 히스토리 관리
- 오프라인 사용자를 위한 Redis 임시 저장
- 연결 끊김 시 미전송 알림 복구
- YATA, REVIEW, CHAT, RESERVATION, MEMBERSHIP
- User와 Trainer 모두 지원
- 수신자 타입별 독립적인 알림 처리
타입 | 설명 | 주요 용도 |
---|---|---|
YATA | 야타 알림 | 특별 이벤트, 공지 사항 등 |
REVIEW | 리뷰 알림 | 리뷰 작성 요청, 리뷰 응답 알림 등 |
CHAT | 채팅 알림 | 새 메시지 수신 알림 |
RESERVATION | 예약 알림 | 예약 확정, 취소, 변경 알림 |
MEMBERSHIP | 멤버십 알림 | 구독 시작, 만료, 갱신 관련 알림 |
타입 | 설명 | 대상 설명 |
---|---|---|
USER | 일반 사용자 | 피트니스 이용자 |
TRAINER | 트레이너 | 헬스장 소속 트레이너 |
GET /notify/subscribe
처리 과정 :
- Controller(NotifyController.subscribe)
- 사용자 인증 확인
- Last-Event-ID 헤더 확인 (연결 복구용)
- NotifyService 호출
- Service (NotifyService.subscribe)
- SseEmitter 생성 (타임아웃: 60분)
- 고유 키 생성: {userId}:{timestamp}
- EmitterRepository에 연결 저장
- 연결 완료/타임아웃 시 자동 정리
- 초기 연결 메시지 전송
- 끊어진 연결 복구 처리
notifyService.send(user, NotificationType.REVIEW, "새 리뷰가 작성되었습니다", "/reviews/123")
처리 과정 :
- 데이터베이스 저장: Notify 엔티티 생성 및 저장
- 실시간 전송: 해당 사용자의 모든 SSE 연결에 전송
- 오프라인 처리: 연결이 없으면 Redis에 임시 저장
notificationPublisher.publishNotificationToUser(userId, NotificationType.CHAT, "새 메시지", "/chat/456")
처리 과정 :
- Publisher (NotificationPublisher)
- NotificationEvent 생성
- Redis 채널에 메시지 발행
- 채널명: notification:USER:{userId} 또는 notification:TRAINER:{trainerId}
- Subscriber (NotificationSubscriber)
- Redis 메시지 수신
- JSON 파싱 후 NotificationEvent 추출
- 데이터베이스에 Notify 엔티티 저장
- 해당 수신자의 SSE 연결에 실시간 전송
@Entity
public class Notify {
private Long id; // 알림 ID
private String content; // 알림 내용
private String url; // 관련 URL
private Boolean isRead; // 읽음 여부
private User receiver; // 사용자 수신자
private Trainer trainer; // 트레이너 수신자
private NotificationType type; // 알림 타입
private ReceiverType receiverType; // 수신자 타입
private LocalDateTime createdAt; // 생성일시
}
// 생성자 오버로딩으로 수신자 타입 자동 설정
public Notify(User receiver, ...) {
this.receiver = receiver;
this.receiverType = ReceiverType.USER;
}
public Notify(Trainer trainer, ...) {
this.trainer = trainer;
this.receiverType = ReceiverType.TRAINER;
}
// 헬퍼 메소드로 통합 접근
public Long getReceiverId() {
return receiverType == ReceiverType.USER ?
receiver.getId() : trainer.getId();
}
// 연결 저장
Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
String key = userId + ":" + timestamp;
emitters.put(key, sseEmitter);
// 사용자별 모든 연결 조회
public Map<String, SseEmitter> findAllEmittersById(Long userId) {
String keyPrefix = userId + ":";
return emitters.stream()
.filter(key -> key.startsWith(keyPrefix))
.collect(toMap());
}
- 연결 끊김 시 복구를 위한 이벤트 임시 저장
- Last-Event-ID 기반 미전송 이벤트 재선송
// 발행
String channel = "notification:USER:" + userId;
redisTemplate.convertAndSend(channel, notificationEvent);
// 구독 (ApplicationReadyEvent 시 자동 설정)
redisMessageListenerContainer.addMessageListener(
notificationSubscriber,
new PatternTopic("notification:*")
);
// Redis에 임시 저장
public void saveNotifyToRedis(Long userId, Notify notify) {
String key = "notify:" + userId;
List<Notify> notifications = getValuesForNotification(key);
notifications.add(notify);
redisTemplate.opsForValue().set(key, notifications);
}
const eventSource = new EventSource('/notify/subscribe', {
headers: {
'Authorization': 'Bearer ' + token,
'Last-Event-ID': lastEventId // 연결 복구용
}
});
eventSource.onmessage = function(event) {
const notification = JSON.parse(event.data);
displayNotification(notification);
};
eventSource.onerror = function(event) {
// 재연결 로직
setTimeout(() => {
eventSource = new EventSource('/notify/subscribe');
}, 3000);
};
알림 발생 ─→ Publisher 또는 직접 전송
│
▼
Redis Pub/Sub ─→ Subscriber 수신
│ │
▼ ▼
데이터베이스 저장 ─→ SSE 실시간 전송
│ │
▼ ▼
영구 보존 ─→ 클라이언트 수신
│ │
▼ ▼
읽음 상태 관리 ─→ UI 업데이트
- 타임아웃: 60분 후 자동 연결 해제
- 완료/에러: 자동 정리 및 메모리에서 제거
- 재연결: Last-Event-ID 기반 이벤트 복구
- 전송 실패: IOException 시 연결 자동 제거
- 오프라인: Redis 임시 저장 후 재연결 시 전송
- 메시지 손실: 이벤트 캐시 활용한 복구
@Configuration
@ConditionalOnProperty(name = "spring.cache.type", havingValue = "redis")
public class NotificationConfig implements ApplicationListener<ApplicationReadyEvent> {
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
// 애플리케이션 완전 준비 후 구독 설정
redisMessageListenerContainer.addMessageListener(
notificationSubscriber,
new PatternTopic("notification:*")
);
}
}
- customStringRedisTemplate: 일반 문자열 저장용
- notifyRedisTemplate: Notify 객체 리스트 저장용
Method | Endpoint | 설명 | 권한 |
---|---|---|---|
GET | /notify/subscribe | 실시간 알림 구독 | USER+ / TRAINER+ |
Authorization: Bearer {token}
Last-Event-ID: {lastEventId} // 선택사항, 연결 복구용
// SSE 스트림
data: {
"id": "123",
"name": "사용자명",
"content": "알림 내용",
"type": "REVIEW",
"createdAt": "2024-06-01T10:30:00"
}
- SSE 기반 즉시 알림 전송
- 다중 디바이스 동시 지원
- Redis를 통한 분산 환경 지원
- 연결 끊김 시 자동 복구
- 오프라인 알림 보장
- 새로운 알림 타입 쉽게 추가
- 수신자 타입별 독립적 처리
- Pub/Sub 패턴으로 느슨한 결합
- 데이터베이스 영구 저장
- 알림 히스토리 관리
- 읽음 상태 추적