현재 수강 서비스는 신청, 수락/거절 처리를 하고
알림 메시지는 알림 서비스로 요청 처리를 넘겨 줍니다.
수강 서비스 --> 알림 서비스(접속 중이라면 sse 서비스 호출, 미접속 중이라면 fcm 서비스 호출)
그림으로 보면 다음과 같습니다.
개선 전 기존 구조

1차 개선 후 구조

최초 구조에서 1차 구조로 개선할 때의 차이점은 다음과 같습니다.
| 최초 구조 | 1차 개선 구조 | |
| redis storage | 그때그때마다 사용자의 온라인 접속 확인 | 접속 중인 사용자를 redis에 저장 |
| redis pub/sub | SSE 알림 요청을 즉시 수행 | SSE 알림 요청을 redis pub/sub으로 수행 |
| FCM token | 알림 로직 수행 시 미리 FCM token을 조회 | FCM 알림 수행 시에만 FCM token 조회 |
- 알림 수행 시마다 온라인 접속 여부를 Request하기 때문에 시간이 지연될 수 있던 것을
접속 중인 사용자 ID를 저장 후 이후에는 연결되어 있는 사용자라면 그대로 SSE 알림 전송을 수행하도록 개선했습니다. - SSE 알림 수행 시 즉각적으로 알림을 전송하던 것을 pub/sub 방식으로 변경하여
추후 애플리케이션 서버의 수평 확장(Scale-out)이 가능해질 수 있도록 개선했습니다. - SSE 알림과 FCM 알림을 하나의 로직에서 수행하느라 불필요하게 토큰을 조회하던 것을 분리하여
FCM 알림 전송 시에만 토큰을 조회하도록 개선했습니다.
여기서 2번째인 redis pub/sub에 대해서 조금만 더 알아 보겠습니다.
## Before: 직접 전송 방식의 한계
먼저 Redis Pub/Sub가 없는 상황을 생각해 보겠습니다. 이 구조는 서버가 1대일 때는 정상적으로 동작합니다.
- 동작 방식:
- 사용자 A가 서버 1에 접속하여 SSE 연결을 맺습니다.
- 서버 1은 SseEmitter 객체를 자신의 메모리(예: ConcurrentHashMap)에 저장합니다. ("사용자 A 저장")
- 이후, 사용자 A에게 보낼 알림 이벤트가 발생하여 서버 1로 요청이 들어옵니다.
- 서버 1은 자신의 메모리를 뒤져 사용자 A의 SseEmitter를 찾아 즉시 알림을 전송합니다.
- 문제점: 서버가 2대 이상으로 늘어날 때
로드 밸런서 뒤에 서버가 2대(서버 1, 서버 2)가 있다고 가정해 보겠습니다.- 사용자 A는 서버 1에 접속하여 SSE 연결을 맺었고, SseEmitter는 서버 1의 메모리에 저장됩니다.
- 그런데 사용자 A에게 보낼 알림 이벤트 요청이 로드 밸런서를 통해 서버 2로 전달됩니다.
- 서버 2는 알림을 보내기 위해 자신의 메모리에서 사용자 A의 SseEmitter를 찾습니다.
- 하지만 SseEmitter는 서버 1에 있기 때문에 찾을 수 없습니다.
- 서버 2에도 사용자 접속 중을 저장하여 저장 공간을 낭비하게 될 수 있습니다.
이처럼 직접 전송 방식은 "SSE 연결을 관리하는 서버"와 "알림 이벤트를 처리하는 서버"가 반드시 동일해야 한다는 제약을 가집니다.
이 때문에 서버를 자우롭게 늘릴 수 없게 됩니다.
## After: Redis Pub/Sub 방식의 장점 -> 추후 클러스터링 확장
이 문제를 해결하기 위해 Redis Pub/Sub이 중간 다리 역할을 합니다.
- 동작 방식:
- 사용자 A는 서버 1에 접속하고, SseEmitter는 서버 1의 메모리에 저장됩니다.
- 사용자 A에게 보낼 알림 이벤트 요청이 로드 밸런서를 통해 서버 2로 전달됩니다.
- 서버 2는 SseEmitter를 직접 찾지 않습니다. 대신, "사용자 A에게 이 메시지를 보내 줘"라는 내용의 메시지를 "Redis의 채널(Topic)에 발행(Publish)"합니다.
- 해당 채널을 구독(Subscribe)하고 있던 모든 서버(서버 1, 서버 2 등등)가 이 메시지를 수신합니다.
- 그 중 서버 1은 메시지를 받고 사용자 A의 SseEmitter를 가지고 있으니 클라이언트(사용자 A)에게 알림을 전송합니다.
- 서버 2도 메시지를 받지만, 사용자 A의 SseEmitter가 없으니 아무것도 하지 않습니다.
- 결과적으로 알림 전송에 성공합니다.
💡 Redis Pub/Sub 도입의 핵심
- 수평 확장성 (Scalability) 어떤 서버가 알림 이벤트를 받든 상관없이, Redis를 통해 여러 서버가 알림을 전달할 수 있습니다. 따라서 서버를 늘려도 알림 시스템은 안정적으로 동작합니다.
- 관심사의 분리 (Decoupling)
- 알림 발행자(Publisher): 알림 이벤트를 만드는 쪽(예: 강의 정보 수정 API)은 더 이상 클라이언트의 SSE 연결 상태를 알 필요가 없습니다. 그냥 Redis에 메시지를 던지기만 하면 됩니다.
- 알림 구독자(Subscriber): 실제 SSE 연결을 관리하고 메시지를 전송하는 쪽은 오직 Redis에서 오는 메시지만 신경 쓰면 됩니다.
- 시스템 탄력성 (Resilience) 만약 서버 1이 다운되더라도, 서버 1에 연결되어 있던 사용자들만 접속이 끊길 뿐, 다른 서버들은 정상적으로 Redis 메시지를 수신하여 각자 담당하는 클라이언트에게 알림을 보낼 수 있습니다.
## 실시간(Real-time) vs. 연성 실시간(Soft Real-time)
이 개념은 아키텍처 선택에 따른 "성능과 확장성의 Trade-Off"를 설명합니다.
- 실시간 (Hard Real-time) 🚀
- 개념: 작업 처리의 시간 제약이 절대적인 시스템입니다.
- 직접 전송 방식: 이 방식은 Redis를 거치지 않고 서버 메모리에서 바로 클라이언트로 전송하므로, 지연 시간이 매우 짧아 개념적으로는 '경성 실시간'에 더 가깝습니다. (물론 웹 환경이라 진정한 의미의 경성 실시간은 아님)
- 연성 실시간 (Soft Real-time) ⏳
- 개념: 작업 처리가 빠를수록 좋지만, 시간이 약간 지연되더라도 안정성을 확보하는 시스템입니다.
- Redis Pub/Sub 방식: [서버 -> Redis -> 다른 서버 -> 클라이언트]로 한 단계를 더 거치므로, 직접 전송 방식에 비해 약간의 네트워크 지연(수 ms 정도)이 추가됩니다. 이것이 바로 연성 실시간 시스템의 특징입니다.
2차 개선 후 구조

@Scheduled(fixedDelay = 600000) // 10분마다 실행
public void retryFailedNotifications() {
List<FailedNotification> targets = failedNotificationRepository.findByRetryCountLessThan(MAX_RETRY_COUNT);
List<CompletableFuture<Void>> futures = targets.stream()
.map(failedNotification -> CompletableFuture.runAsync(() -> {
try {
NotificationMessageEvent event = NotificationMessageEvent.from(failedNotification);
fcmService.sendFcmNotification(event);
// 성공 시 재시도 목록에서 삭제
failedNotificationRepository.delete(failedNotification);
} catch (Exception e) {
// 재시도 또 실패 시, 카운트 증가
failedNotification.incrementRetryCount();
failedNotificationRepository.save(failedNotification);
}
}, notificationTaskExecutor))
.toList();
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("알림 재처리 스케줄이 성공적으로 완료되었습니다. 총 {}건 처리.", futures.size());
} catch (Exception e) {
log.error("알림 재처리 작업 중 일부에서 예외가 발생했습니다.", e);
}
}
실패한 알림에 대한 재전송을 수행하는 부분입니다.
각각의 실패한 알림에 대한 재전송은 비동기로 처리합니다.
그러나 각 재전송을 비동기로 처리한 다음 전체가 다 실행되었는지는 join()을 통해 완료 응답을 대기합니다.
즉, 푸시 알림을 재전송하는 것은 비동기로 처리하지만,
실패한 알림 전체를 재실행하는 것은 동기적으로 처리합니다.
그 이유는 현재
@Scheduled(fixedDelay = 600000) // 10분마다 실행
를 통해 주기적으로 재실행을 하도록 되어 있는데 이 또한 비동기로 처리(Fire and Forget)하면 아직 실제 재전송 작업들이 처리 중인데도 메서드가 즉시 끝나므로, 다음 스케줄러가 바로 시작될 수 있습니다. (사실 재전송에 10분이 넘어가도록 걸리기는 쉽지 않지만, 혹시라도 주기를 짧게 할 경우, 아니면 모종의 이유로 재전송 실행에 상당한 시간이 걸릴 경우에 대비함을 위해 ..)
이를 방지하기 위해 각각의 비동기 작업이 끝났는지 대기하고 정상적으로 처리되었는지 로그를 남깁니다.
- 실패한 알림 재처리 진행 시 전체 Failed Notification 조회
- 재처리 성공/실패 여부에 따라 Failed Notification에 쓰기 작업
SSE 알림 같은 경우 상대방이 온라인 접속 중이지 않을 경우 알림 전송에 실패하게 되고 FCM 알림을 전송하게 됩니다.
알림 서버(Notification 모듈)에서는 FCM 서비스인 Firebase에 요청하는 것을 성공하더라도 Firebase 서비스에서 실패하는 것은 어쩔 수 없습니다.
그러나 알림 서버(Notification 모듈) 내에서 FCM 알림을 전송하는 것 자체를 실패하게 될 경우 실패한 알림에 대해 재처리하는 방안을 마련해야 합니다.
따라서, 위와 같은 구조로 개선을 시도하였고 실제로 FCM 알림에 실패 시 FailedNotification에 해당 정보를 저장하고
@Scheduled를 통해 주기적으로 재전송하도록 설계해 봤습니다.
어느 정도 주기로 재처리를 할 것인지 아니면 cron을 사용할 것인지 등은 실제 서비스를 운영하면서 테스트해 보면 좋겠지만,
지금으로서는 불가능하기에 단순히 스케줄링을 사용하는 방향만 잡고 가겠습니다.
여기서 Failed Notification 조회 후 재처리에 따라 쓰기 작업을 하는데
Kafka에서 알림 로직 수행하는 도중 실패한 메시지를 Failed Notification에 쓰기 작업을 할 때는 어떻게 할까요 ?
이때도 실패한 작업이 있으면 Failed Notification에 쓰기 작업을 하는데
경합이 일어나지 않을까 고민이 되긴 하는데 ..
--> 서버가 1대라면 괜찮다고 생각합니다.
왜냐하면,
다음의 2가지 경우를 생각해 볼 수 있는데
1. 같은 사용자가 서로 다른 요청을 할 경우
2. 같은 사용자가 또 다시 같은 요청을 할 경우
우선 1번의 경우
- 00:00 - 사용자 1과 사용자 2가 강의 수강신청 요청
- 00:01 - 사용자 1과 사용자 2의 강의 수강신청 요청에 대한 알림 실패
- 00:02 - 사용자 1의 실패한 알림과 사용자 2의 실패한 알림 저장 (커밋)
- 00:03 - 스케줄러가 사용자 1과 사용자 2의 실패한 알림 재처리 수행 (조회)
- 00:04 - 사용자 1과 사용자 2가 강의 수강취소 요청
- 00:05 - 사용자 1과 사용자 2의 강의 수강취소 요청에 대한 알림 실패
- 00:06 - 사용자 1의 실패한 알림과 사용자 2의 실패한 알림 저장(커밋)
- (아직 스케줄러가 사용자 1과 사용자 2의 실패한 알림 재처리 수행 중 ..)
- 00:07 - 스케줄러가 사용자 1과 사용자 2의 실패한 알림 성공/실패 여부 처리 (커밋)
이 경우 스케줄러는 이미 저장된 실패한 알림을 조회해 간 상태이고, 해당하는 실패한 알림의 ID에만 쓰기 작업을 합니다.
또한, 새롭게 조회하는 로직이 없기 때문에 Non-Repeatable Read, Lost Update 또는 Phantom Read도 발생하지 않을 것 같습니다.
다음 2번의 경우
- 00:00 - 사용자 1과 사용자 2가 강의 수강신청 요청
- 00:01 - 사용자 1과 사용자 2의 강의 수강신청 요청에 대한 알림 실패
- 00:02 - 사용자 1의 실패한 알림과 사용자 2의 실패한 알림 저장 (커밋)
- 00:03 - 스케줄러가 사용자 1과 사용자 2의 실패한 알림 재처리 수행 (조회)
- 00:04 - 사용자 1과 사용자 2가 강의 수강신청 요청
- 00:05 - 사용자 1과 사용자 2의 강의 수강신청 요청에 대한 알림 실패
- 00:06 - 사용자 1의 실패한 알림과 사용자 2의 실패한 알림 저장(커밋)
- (아직 스케줄러가 사용자 1과 사용자 2의 실패한 알림 재처리 수행 중 ..)
- 00:07 - 스케줄러가 사용자 1과 사용자 2의 실패한 알림 성공/실패 여부 처리 (커밋)
고민 사항은 같은 강의에 대한 또 다시 같은 '수강신청 요청'인 경우입니다.
- 같은 강의에 대한 중복 요청인 경우 미리 에러 처리를 해 놓았기 때문에 괜찮음
- 만약 같은 강의에 대한 또 다시 같은 '수강신청 요청'이 수행되면 이미 저장된 실패한 알림에 쓰기 작업을 해야 되는 것 아닌가 ?
- 하는 우려에는 제 생각에 비즈니스 로직은 같더라도 요청은 엄연히 2개입니다.
- 따라서 새로운 요청은 새롭게 저장되어야 하고 이전 요청에 덮어 쓸 필요는 없다고 생각합니다.
결과적으로 실패한 알림 재처리 로직이든 실패한 알림 저장 로직이든
FailedNotification 테이블에 Lock을 걸어서 트랜잭션이 커밋될 때까지 기다릴 필요가 없다고 생각합니다.
따라서 알림 모듈이 1개(즉, 알림 서버가 1대로 동작하는 경우)인 지금으로서는 Lock이 필요 없습니다.
## 그러나 문제는 서버가 2대 이상일 경우입니다.
"스케줄러는 이미 저장된 실패한 알림의 ID에만 쓰기 작업을 합니다."라는 부분에서 동시성 문제가 발생할 수도 있습니다.
문제는 스케줄러의 "읽기"와 "쓰기" 사이에 시간 간격이 존재한다는 것입니다. 이 시간 동안 다른 트랜잭션이 개입할 수 있습니다.
위험 시나리오 (Lost Update):
- 00:03 - 스케줄러 (트랜잭션 A): retryFailedNotifications가 시작됩니다. DB에서 retryCount < 3인 ID 100번 레코드를 조회(SELECT)하여 메모리에 로드합니다. 아직 락은 없습니다.
- 00:04 - 다른 스케줄러 (트랜잭션 B): 만약 애플리케이션이 2대 이상 떠 있다면(Scale-out 시), 다른 서버의 스케줄러도 동시에 ID 100번 레코드를 조회하여 메모리에 로드할 수 있습니다.
- 00:05 - 스케줄러 (트랜잭션 A): ID 100번 재처리에 실패합니다. 메모리에서 retryCount를 1 증가시키고(1 -> 2), repository.save()를 호출하여 DB에 UPDATE ... SET retry_count = 2 쿼리를 실행하고 커밋합니다.
- 00:06 - 다른 스케줄러 (트랜잭션 B): ID 100번 재처리에 마찬가지로 실패합니다. 하지만 트랜잭션 B가 메모리에 가지고 있는 retryCount는 여전히 1입니다. retryCount를 1 증가시키고(1 -> 2), repository.save()를 호출하여 DB에 UPDATE ... SET retry_count = 2 쿼리를 실행하고 커밋합니다.
- 결과: 두 개의 스케줄러가 모두 실행되었음에도 불구하고, retryCount는 3이 아닌 2가 됩니다. 트랜잭션 A의 UPDATE 작업이 트랜잭션 B에 의해 덮어쓰기(Lost Update) 되었습니다.
Soft Delete도 마찬가지입니다. 스케줄러 A가 재처리에 성공하여 delete()(즉, UPDATE ... SET deleted_at = NOW())를 호출하는 동안, 스케줄러 B가 재처리에 실패하여 save()(즉, UPDATE ... SET retry_count = ...)를 호출하면, 두 UPDATE 중 나중에 커밋된 것만 최종적으로 남게 됩니다.
## 개선 방향: 비관적 락(Pessimistic Lock) 적용
이러한 문제를 해결하기 위해, "읽는 즉시 락을 걸어 다른 트랜잭션의 접근을 막는" 비관적 락이 필요합니다.
여기서는 간단하게 @Lock 이라는 어노테이션을 사용해서 해결해 볼 수 있기 때문에 이렇게 해 보겠습니다.
Lock과 관련해서는 "특강 개설 시 제한된 인원만 신청되도록" 하는 이슈에서 더 깊이 있게 학습해 볼 예정입니다.
// FailedNotificationRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE) // 조회된 행에 쓰기 락(FOR UPDATE)을 걺
List<FailedNotification> findByRetryCountLessThan(int maxRetryCount);
// NotificationRetryScheduler.java
@Scheduled(fixedDelay = 600000) // 10분마다 실행
@Transactional // 트랜잭션 범위를 보장하기 위해 추가
public void retryFailedNotifications() {
// 이 메서드가 호출되면 SELECT ... FOR UPDATE 쿼리가 실행됨
List<FailedNotification> targets = failedNotificationRepository.findByRetryCountLessThan(MAX_RETRY_COUNT);
List<CompletableFuture<Void>> futures = targets.stream()
.map(failedNotification -> CompletableFuture.runAsync(() -> {
try {
NotificationMessageEvent event = NotificationMessageEvent.from(failedNotification);
fcmService.sendFcmNotification(event);
// 성공 시 재시도 목록에서 삭제
failedNotificationRepository.delete(failedNotification);
} catch (Exception e) {
// 재시도 또 실패 시, 카운트 증가
failedNotification.incrementRetryCount();
failedNotificationRepository.save(failedNotification);
}
}, notificationTaskExecutor))
.toList();
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("알림 재처리 스케줄이 성공적으로 완료되었습니다. 총 {}건 처리.", futures.size());
} catch (Exception e) {
log.error("알림 재처리 작업 중 일부에서 예외가 발생했습니다.", e);
}
}
- 동작 방식: 스케줄러 A가 findByRetryCountLessThan을 호출하면, 조회된 행들에 락이 걸립니다. 스케줄러 B가 동시에 같은 메서드를 호출하면, 스케줄러 A의 트랜잭션이 끝날 때까지 대기하게 됩니다. 스케줄러 A가 처리를 마치고 커밋하면 락이 해제되고, 그제서야 스케줄러 B가 조회(이때는 A가 처리한 데이터는 제외됨)를 시작합니다.
- 효과: 이를 통해 재처리 대상 데이터는 한 번에 하나의 스케줄러만 처리하도록 보장하여 데이터 정합성을 지킬 수 있습니다.
이와 별개로 스케줄러는 이미 저장된 실패한 알림에 대해 재처리를 또 실패할 경우 해당 ID의 Failed Notification은 재처리 카운트를 올립니다. 재처리에도 여러 번 실패할 경우 해당 사용자가 탈퇴했거나, 해당 사용자의 FCM token에 문제가 생기는 등 따로 작업이 필요한 내용일 수 있기 때문에 적당히 3번 정도 실패하면 조회하지 않도록 합니다.
'SSA > Back' 카테고리의 다른 글
| [SSA] 이벤트 발행에 트랜잭셔널 아웃박스 패턴을 적용 (1) | 2025.09.08 |
|---|---|
| [SSA] 비동기 이벤트 발행기 테스트 (1) | 2025.09.08 |
| [SSA] 새로운 트랜잭션을 만들면 데드락이 발생하는 것을 테스트해 보자 (0) | 2025.08.29 |
| [SSA] 트랜잭션을 새로 만들어서 사용하면 ? (0) | 2025.08.29 |
| [SSA] 500명 알림 발송 6.4초 -> 0.27초, 카프카 컨슈머 병렬 처리 적용기 (3) | 2025.08.28 |