refactor: 알림 발송 구조 개선, 아웃박스 재시도 및 보관 정책 - #145
Merged
Merged
Conversation
FCM 호출이 알림 저장 트랜잭션 안에 있어, 푸시가 실패하면 방금 저장한 Notification 레코드까지 롤백되고 관리자 알림은 루프 중간에서 끊겼다. 또 HTTP 호출이 끝날 때까지 HikariCP 커넥션을 점유했다. - NotificationService는 알림 저장만 담당하도록 축소 (FCMService, MemberService 의존 제거) - PushNotificationSender를 추가해 트랜잭션 밖에서 푸시 발송, 실패를 예외로 전파하지 않음 - NotificationEventHandler의 REQUIRES_NEW 제거 — 저장은 짧은 트랜잭션에서 커밋 후 푸시 발송 - FCMService 반환 타입을 Boolean에서 PushResult로 교체해 재시도 가능 여부를 구분 (기존에는 네트워크 오류·FCM 5xx도 true로 반환되어 실패가 호출부에 전달되지 않았음) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
자동 설정 실행기(applicationTaskExecutor)는 큐가 무제한이라 FCM 지연 시 태스크가 계속 쌓이고, 종료 대기 설정이 없어 배포 시 큐에 남은 알림이 유실됐다. - notificationTaskExecutor 정의 (코어 4 / 최대 8 / 큐 500) - 큐 포화 시 CallerRunsPolicy로 유실 대신 지연 선택 - 종료 시 최대 20초간 잔여 작업 처리 대기 - AsyncUncaughtExceptionHandler 등록해 실패한 비동기 작업을 식별 가능하도록 기록 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Retryable로 분류된 푸시 실패가 로그로만 남고 유실됐다. FCM 일시 장애나 네트워크 오류로 실패하면 사용자는 대여 승인 알림을 영영 받지 못한다. 발송 대상을 수신자 단위 row로 DB에 남기고 스케줄러가 미발송 건을 재시도한다. 알림과 같은 트랜잭션에서 저장되므로 프로세스가 재시작돼도 발송 대상이 남는다. - notification_push_outbox 테이블 및 엔티티 추가 (상태/재시도 횟수/다음 시도 시각/마지막 오류) - 백오프 30초 → 2분 → 5분 → 15분, 최대 4회. 생성 후 1시간 경과 시 EXPIRED로 포기 - 즉시 발송과 재시도가 같은 경로(PushNotificationSender.dispatch)를 공유 - 새 row의 nextRetryAt을 60초 뒤로 잡아 즉시 시도와 폴러의 중복 발송 방지 - InvalidToken은 재시도 없이 토큰 제거 후 종료 - 상태 전이(백오프/최대 횟수/TTL) 단위 테스트 추가 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
발송이 끝난 아웃박스 row가 계속 남았다. 알림 한 건마다 수신자 수만큼 쌓이므로 방치하면 재시도 대상 조회가 느려진다. - 매일 새벽 4시(KST) 보존 기간이 지난 건 삭제 - SENT 7일, FAILED/EXPIRED 30일(실패 원인 확인용), PENDING은 삭제하지 않음 - 배치(500건) 단위로 나눠 삭제하고 배치마다 트랜잭션을 끊어 락 구간을 짧게 유지 - 한 회 처리량 상한(20배치)에 도달하면 남은 건이 있다는 경고 로그 - 정리 조회용 인덱스 (delivery_status, created_at) 추가 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#️⃣연관된 이슈
🎯 해결하려는 문제가 무엇인가요?
알림 푸시가 실패하면 그대로 유실됩니다. FCM 일시 장애나 네트워크 오류로 실패하면 사용자는 대여 승인 알림을 영영 받지 못합니다.
재시도를 붙이려 했으나 현재 구조로는 붙일 자리가 없어, 선행 정리(#144)와 재시도 구현(#146)을 함께 담았습니다.
1부 — 재시도를 막고 있던 구조 (#144)
트랜잭션이 FCM 네트워크 I/O를 감싸고 있음
NotificationEventHandler의@Transactional(REQUIRES_NEW)가firebaseMessaging.send()완료까지 유지됐습니다. 관리자 알림은 관리자 수만큼 HTTP 호출이 순차 실행되므로, HTTP N+1회 동안 HikariCP 커넥션 1개를 점유합니다. (풀 15개 / async 스레드 8개)푸시 실패가 인앱 알림까지 롤백시킴
FCMService는FirebaseMessagingException만 잡습니다. 그 외 예외가 나오면 트랜잭션이 롤백되면서 방금 저장한Notification레코드까지 사라졌습니다. 관리자 알림 루프 중간에서 실패하면 앞선 관리자는 푸시를 받았는데 알림 목록에는 아무것도 없는 불일치가 생깁니다.FCM 반환값이 성공/실패를 표현하지 못함
sendPushNotification의Boolean은 "토큰 유효 여부"라서, 네트워크 오류·FCM 5xx·타임아웃이 전부true(성공과 동일)로 반환됐습니다.@Async실행기 미설정자동 설정 실행기(코어 8, 큐 무제한)를 쓰고 있었습니다. 종료 대기 설정이 없어 배포·재시작 시 큐에 남은 알림이 유실됩니다.
server.shutdown: graceful은 웹 요청만 보호합니다.2부 — 재시도와 보관 (#146, #147)
위를 정리한 뒤에도 실패한 발송을 다시 시도할 수단이 없었습니다. 그리고 재시도용 대기열은 알림 한 건마다 수신자 수만큼 쌓이므로, 정리하지 않으면 계속 늘어납니다.
❓ 왜 해결해야 하나요?
그리고 알림은 이 서비스에서 사용자가 대여 상태를 아는 유일한 수단이라, 조용히 유실되면 사용자는 승인 여부를 확인할 방법이 없습니다.
⭐ 어떻게 해결했나요?
저장과 발송을 다른 트랜잭션으로 분리
NotificationService— 알림 저장만 담당.FCMService·MemberService의존을 걷어냈습니다PushNotificationSender— 트랜잭션 밖에서 발송하고 실패를 예외로 전파하지 않습니다. 한 수신자의 실패가 다른 수신자나 이미 저장된 알림에 영향을 주면 안 되기 때문입니다REQUIRES_NEW제거 —@Async로 이미 별도 스레드라 필요 없던 설정이기도 합니다FCM 실패를 세 종류로 구분 (
PushResult)SuccessSENTInvalidTokenUNREGISTERED,SENDER_ID_MISMATCHRetryableUNAVAILABLE,INTERNAL,QUOTA_EXCEEDED,THIRD_PARTY_AUTH_ERROR, 에러 코드 없는 전송 계층 오류PermanentINVALID_ARGUMENT등FAILED아웃박스 기반 재시도
발송 대상을
notification_push_outbox에 수신자 단위 row로 남깁니다. 알림과 같은 트랜잭션에서 저장되므로 프로세스가 재시작돼도 발송 대상이 남습니다.EXPIRED로 포기하며, 인앱 알림은 이미 저장돼 있으므로 정보가 사라지는 것은 아닙니다30초 → 2분 → 5분, 최대 3회. 누적 7분 30초로 유효 시간 안에 들어옵니다PushNotificationSender.dispatch)를 탑니다. 폴러는 트리거만 다를 뿐입니다Notification의 status와 formatValues로 재구성합니다보관 정책 (
PushOutboxPurgeScheduler)매일 새벽 4시(KST)에 보존 기간이 지난 건을 정리합니다.
SENTFAILED,EXPIREDlast_error)을 들여다볼 여지를 남긴다PENDING재시도 폴러와 같은 스케줄러 스레드를 쓰기 때문에, 정리가 길어지면 폴러가 밀립니다. 상한을 둔 이유입니다.
알림 전용 실행기 (
AsyncConfig)코어 4 / 최대 8 / 큐 500,
CallerRunsPolicy, 종료 시 최대 20초 대기,AsyncUncaughtExceptionHandler등록.🧩 이 PR의 한계 & 트레이드오프
notifications테이블 자체의 보관 정책은 다루지 않았습니다. 사용자에게 노출되는 알림 이력이라 보존 기간을 따로 논의해야 합니다nextRetryAt을 60초 뒤로 잡았지만, 이건 잠금이 아니라 시간차입니다. 즉시 시도가 60초 넘게 걸리면 폴러가 같은 건을 집어갈 수 있습니다. 실제 잠금은 다중 인스턴스로 갈 때 함께 넣는 게 맞다고 봤습니다FOR UPDATE SKIP LOCKED또는 ShedLock이 필요하며SchedulingConfig에 주석으로 남겼습니다fixedDelay라 중복 실행되지는 않습니다CallerRunsPolicy는 큐가 가득 차면 호출 스레드(= 이벤트를 발행한 HTTP 요청 스레드)에서 처리합니다. 유실 대신 요청 지연을 감수한 선택입니다RentalService까지 손대야 해서 분리했습니다⛓️ 기존 기능에 미치는 영향
notification_push_outbox테이블 추가.ddl-auto: update라 자동 생성되지만, 운영 반영 시 확인용으로 남깁니다@EnableScheduling이 새로 켜집니다. 재시도 폴러(30초 간격)와 정리 스케줄러(매일 04:00 KST) 두 개가 같은 단일 스레드에서 순차 실행됩니다Executor빈을 정의하면서 Boot 자동 설정의applicationTaskExecutor가 물러납니다. Spring MVC 비동기(Callable/DeferredResult/SseEmitter)를 쓰는 곳이 없는 것을 확인했고,@Async는AsyncConfigurer로 명시 지정하므로 동작 차이는 없습니다NotificationService.sendNotification/sendNotificationToAdmin→createNotification/createAdminNotification으로 이름과 역할이 바뀌었습니다. 호출부는NotificationEventHandler뿐이라 영향 범위는 닫혀 있습니다SENDER_ID_MISMATCH를 토큰 제거 대상에 새로 포함했습니다. 다른 Firebase 프로젝트의 토큰이므로 지우는 게 맞다고 봤습니다RentalService → NotificationService도 이미 이벤트 발행으로 바뀐 상태라 함께 수정)🔀 Edge Case & 실패 시나리오
Permanent로 기록, 알림은 저장 유지UNREGISTERED)FAILEDEXPIRED로 포기 (ERROR 로그). 인앱 알림은 남음📋 검토한 대안과 선택 이유
@Retryable) 인메모리 재시도 — 30분이면 끝나지만 프로세스가 죽으면 유실되고 실패 이력이 남지 않습니다. 재시도 동안 async 스레드도 계속 잡습니다. 알림 유실 방지가 목적이라 영속 대기열을 택했습니다Notification과 중복이고, enum 문구를 고치면 두 곳이 어긋납니다. status + formatValues로 재구성하는 쪽을 택했습니다spring.task.execution.*프로퍼티로만 실행기 설정 —RejectedExecutionHandler를 지정할 수 없어 큐 포화 시 태스크가 버려집니다DELETE로 일괄 삭제 — 코드는 짧지만 락 구간이 길어지고, 같은 스레드를 쓰는 재시도 폴러가 그만큼 밀립니다rentAt) 기준으로 유효 시간 계산 — 승인 푸시의 실제 가치는 "대여 시각까지 남은 시간"에 달려 있어 가장 정확합니다. 다만 아웃박스가 대여 도메인을 알아야 해서, 종류별 고정 TTL로 충분하다고 판단했습니다sendEachForMulticast로 관리자 일괄 발송 — 호출 횟수는 줄지만 수신자별 재시도 상태를 따로 관리해야 해서, 아웃박스가 수신자 단위인 현재 구조와 맞지 않습니다💬 리뷰 포인트
[r]TTL 10분이 적절한지 봐주세요. 이 안에 30초·2분·5분 세 번의 재시도가 들어갑니다. 더 줄이면 재시도 횟수도 함께 줄여야 합니다[r]Member가 detached 상태로Notification·NotificationPushOutbox에 연결됩니다. 핸들러에 트랜잭션이 없어져memberService.findById()가 준영속 엔티티를 반환하고, 그 상태로 저장 메서드에 넘어갑니다.@ManyToOne에 cascade가 없어 FK만 기록되므로 동작에는 문제가 없다고 판단했는데, 이 방식이 괜찮은지 봐주시면 좋겠습니다[c]INVALID_ARGUMENT를Permanent로 두고 토큰을 지우지 않았습니다. 이 코드는 토큰 문제일 수도, 페이로드 문제일 수도 있어 멀쩡한 토큰을 지우는 위험을 피했습니다. 실제 로그를 보고 조정하는 게 나을 것 같습니다[c]보존 기간(SENT7일 /FAILED·EXPIRED30일) 이 적절한지 봐주세요. 실패 원인을 들여다볼 기간이 30일이면 충분한지가 관건입니다[a]풀 크기(4/8/500), 폴링 주기 30초, 배치 100건 수치에 의견 있으시면 알려주세요🔍 검증
./gradlew compileKotlin통과./gradlew test통과 —contextLoads1건 + 아웃박스 상태 전이 단위 테스트 8건 (백오프 간격, 마지막 재시도가 유효 시간 안에 드는지, 최대 재시도 횟수 소진, TTL 초과, 만료될 재시도 예약 차단, 성공 시 오류 기록 초기화)contextLoads는application-local.yml의 H2 TCP 설정이 로컬 환경에 의존해서, 인메모리 H2로 datasource를 덮어 실행했습니다 (SPRING_DATASOURCE_URL='jdbc:h2:mem:billilge;MODE=MySQL')notification_push_outbox테이블과idx_push_outbox_delivery (delivery_status, next_retry_at)인덱스가 만들어지는 것을 확인했습니다SENT/FAILED/EXPIRED만 삭제되고, 기간 내의 건과PENDING은 100일이 지나도 남습니다. (기대값을 뒤집어 실패하는 것까지 확인한 뒤 임시 테스트는 제거했습니다. CI가-x test로 테스트를 건너뛰고 로컬 DB 설정에 의존하는 테스트라 커밋하지 않았습니다)