From cab1b5c2ddc9d28046442869690fe85cdef8c30c Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:41:47 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=91=B8=EC=8B=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=EC=9D=84=20=EC=95=8C=EB=A6=BC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B0=96=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FCM 호출이 알림 저장 트랜잭션 안에 있어, 푸시가 실패하면 방금 저장한 Notification 레코드까지 롤백되고 관리자 알림은 루프 중간에서 끊겼다. 또 HTTP 호출이 끝날 때까지 HikariCP 커넥션을 점유했다. - NotificationService는 알림 저장만 담당하도록 축소 (FCMService, MemberService 의존 제거) - PushNotificationSender를 추가해 트랜잭션 밖에서 푸시 발송, 실패를 예외로 전파하지 않음 - NotificationEventHandler의 REQUIRES_NEW 제거 — 저장은 짧은 트랜잭션에서 커밋 후 푸시 발송 - FCMService 반환 타입을 Boolean에서 PushResult로 교체해 재시도 가능 여부를 구분 (기존에는 네트워크 오류·FCM 5xx도 true로 반환되어 실패가 호출부에 전달되지 않았음) Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 27 ++++++-- .../handler/NotificationEventHandler.kt | 52 +++++++++++---- .../service/NotificationService.kt | 63 +++---------------- .../service/PushNotificationSender.kt | 61 ++++++++++++++++++ .../backend/global/external/fcm/FCMService.kt | 53 +++++++++++++--- .../backend/global/external/fcm/PushResult.kt | 20 ++++++ 6 files changed, 195 insertions(+), 81 deletions(-) create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/global/external/fcm/PushResult.kt diff --git a/CLAUDE.md b/CLAUDE.md index e93eadf..bbb5bab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,13 +72,30 @@ domain/{name}/ ## 서비스 의존성 ``` -ItemService → ItemRepository, S3Service -MemberService → MemberRepository, TokenProvider, PayerService -NotificationService → NotificationRepository, FCMService, MemberService -PayerService → PayerRepository, MemberRepository, ExcelGenerator -RentalService → RentalRepository, NotificationService +ItemService → ItemRepository, S3Service +MemberService → MemberRepository, TokenProvider, PayerService +NotificationService → NotificationRepository +PushNotificationSender → FCMService, MemberService +PayerService → PayerRepository, MemberRepository, ExcelGenerator +RentalService → RentalRepository, ApplicationEventPublisher ``` +### 알림 발송 구조 + +`RentalService`가 이벤트를 발행하면 `NotificationEventHandler`가 커밋 이후 비동기로 받아 처리한다. + +``` +RentalService → 이벤트 발행 + └ NotificationEventHandler (@Async + AFTER_COMMIT, 트랜잭션 없음) + ├ NotificationService.createNotification() # 짧은 트랜잭션에서 저장 후 즉시 커밋 + └ PushNotificationSender.send() # 트랜잭션 밖에서 FCM 호출 +``` + +- **푸시 발송은 트랜잭션 밖에서 수행한다** — 네트워크 I/O가 DB 커넥션을 점유하지 않도록, 그리고 푸시 실패가 저장된 알림을 롤백시키지 않도록 분리 +- **`PushNotificationSender`는 예외를 전파하지 않는다** — 한 수신자의 실패가 다른 수신자에게 영향을 주면 안 됨 +- **FCM 실패는 `PushResult`로 구분한다** — `InvalidToken`(토큰 제거) / `Retryable`(재시도 대상) / `Permanent`(재시도 무의미) +- **`@Async`는 알림 전용 실행기(`notificationTaskExecutor`)를 사용한다** — `AsyncConfig`에 정의 + ## 대여 상태 머신 ``` diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt index 21fdd1c..499b32b 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt @@ -2,28 +2,34 @@ package site.billilge.api.backend.domain.notification.handler import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Component -import org.springframework.transaction.annotation.Propagation -import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener +import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.service.MemberService import site.billilge.api.backend.domain.notification.enums.NotificationStatus import site.billilge.api.backend.domain.notification.service.NotificationService +import site.billilge.api.backend.domain.notification.service.PushNotificationSender import site.billilge.api.backend.domain.rental.enums.RentalStatus import site.billilge.api.backend.domain.rental.event.* +/** + * 알림 저장(DB)과 푸시 발송(FCM)을 분리해 호출한다. + * + * 핸들러 자체에는 트랜잭션을 걸지 않는다. 알림 저장은 NotificationService의 짧은 트랜잭션에서 + * 즉시 커밋되고, 그 뒤 트랜잭션 밖에서 푸시를 보낸다. 푸시가 실패해도 저장된 알림은 남는다. + */ @Component class NotificationEventHandler( private val notificationService: NotificationService, + private val pushNotificationSender: PushNotificationSender, private val memberService: MemberService, ) { @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun handleRentalApplied(event: RentalAppliedEvent) { val member = memberService.findById(event.memberId) - notificationService.sendNotification( + notifyUser( member, NotificationStatus.USER_RENTAL_APPLY, listOf(event.itemName), @@ -31,7 +37,7 @@ class NotificationEventHandler( ) if (!event.isDevMode) { - notificationService.sendNotificationToAdmin( + notifyAdmins( NotificationStatus.ADMIN_RENTAL_APPLY, listOf( member.name, @@ -45,12 +51,11 @@ class NotificationEventHandler( } @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun handleRentalCancelled(event: RentalCancelledEvent) { val member = memberService.findById(event.memberId) - notificationService.sendNotificationToAdmin( + notifyAdmins( NotificationStatus.ADMIN_RENTAL_CANCEL, listOf(member.name, member.studentId, event.itemName), needPush = true, @@ -58,19 +63,18 @@ class NotificationEventHandler( } @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun handleReturnApplied(event: ReturnAppliedEvent) { val member = memberService.findById(event.memberId) - notificationService.sendNotification( + notifyUser( member, NotificationStatus.USER_RETURN_APPLY, listOf(event.itemName), needPush = true, ) - notificationService.sendNotificationToAdmin( + notifyAdmins( NotificationStatus.ADMIN_RETURN_APPLY, listOf(member.name, member.studentId, event.itemName), needPush = true, @@ -78,7 +82,6 @@ class NotificationEventHandler( } @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun handleRentalStatusChanged(event: RentalStatusChangedEvent) { val member = memberService.findById(event.memberId) @@ -91,6 +94,31 @@ class NotificationEventHandler( else -> return } - notificationService.sendNotification(member, notificationStatus, listOf(event.itemName), needPush) + notifyUser(member, notificationStatus, listOf(event.itemName), needPush) + } + + private fun notifyUser( + member: Member, + status: NotificationStatus, + formatValues: List, + needPush: Boolean, + ) { + notificationService.createNotification(member, status, formatValues) + + if (needPush) { + pushNotificationSender.send(member, status, formatValues) + } + } + + private fun notifyAdmins( + status: NotificationStatus, + formatValues: List, + needPush: Boolean, + ) { + notificationService.createAdminNotification(status, formatValues) + + if (needPush) { + pushNotificationSender.sendAll(memberService.findAllWorkers(), status, formatValues) + } } } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt index 74896cf..f3cc69c 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt @@ -1,26 +1,18 @@ package site.billilge.api.backend.domain.notification.service -import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import site.billilge.api.backend.domain.member.entity.Member -import site.billilge.api.backend.domain.member.enums.Role -import site.billilge.api.backend.domain.member.service.MemberService import site.billilge.api.backend.domain.notification.entity.Notification import site.billilge.api.backend.domain.notification.enums.NotificationStatus import site.billilge.api.backend.domain.notification.exception.NotificationErrorCode import site.billilge.api.backend.domain.notification.repository.NotificationRepository import site.billilge.api.backend.global.exception.ApiException -import site.billilge.api.backend.global.external.fcm.FCMService - -private val log = KotlinLogging.logger {} @Service @Transactional(readOnly = true) class NotificationService( private val notificationRepository: NotificationRepository, - private val fcmService: FCMService, - private val memberService: MemberService, ) { fun getNotifications(memberId: Long?): List { return notificationRepository.findAllUserNotificationsByMemberId(memberId!!) @@ -45,70 +37,31 @@ class NotificationService( } @Transactional - fun sendNotification( + fun createNotification( member: Member, status: NotificationStatus, formatValues: List, - needPush: Boolean = false, - ) { + ): Notification { val notification = Notification( member = member, status = status, formatValues = formatValues.joinToString(",") ) - notificationRepository.save(notification) - - if (needPush) { - sendPushNotification(member, status, formatValues) - } - } - - private fun sendPushNotification( - member: Member, - status: NotificationStatus, - formatValues: List, - ) { - val studentId = member.studentId - - if (member.fcmToken == null) { - log.warn { "(studentId=${studentId}) FCM Token is null" } - return - } - - val isTokenValid = fcmService.sendPushNotification( - member.fcmToken!!, - status.title, - status.formattedMessage(*formatValues.toTypedArray()), - status.link, - studentId - ) - - if (!isTokenValid) { - memberService.clearFcmToken(member.id!!) - } + return notificationRepository.save(notification) } @Transactional - fun sendNotificationToAdmin( - type: NotificationStatus, + fun createAdminNotification( + status: NotificationStatus, formatValues: List, - needPush: Boolean = false - ) { - val admins = memberService.findAllWorkers() - + ): Notification { val notification = Notification( - status = type, + status = status, formatValues = formatValues.joinToString(",") ) - notificationRepository.save(notification) - - if (needPush) { - admins.forEach { admin -> - sendPushNotification(admin, type, formatValues) - } - } + return notificationRepository.save(notification) } fun getNotificationCount(memberId: Long?): Int { diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt new file mode 100644 index 0000000..af92c53 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt @@ -0,0 +1,61 @@ +package site.billilge.api.backend.domain.notification.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.member.service.MemberService +import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import site.billilge.api.backend.global.external.fcm.FCMService +import site.billilge.api.backend.global.external.fcm.PushResult + +private val log = KotlinLogging.logger {} + +/** + * FCM 푸시 발송 담당. + * + * 알림 저장 트랜잭션 바깥에서 호출된다. 네트워크 I/O가 DB 커넥션을 붙잡지 않도록 + * @Transactional을 걸지 않으며, 발송 실패를 예외로 전파하지 않는다. + * 한 수신자의 실패가 다른 수신자나 이미 저장된 알림에 영향을 주면 안 되기 때문이다. + */ +@Component +class PushNotificationSender( + private val fcmService: FCMService, + private val memberService: MemberService, +) { + fun send(member: Member, status: NotificationStatus, formatValues: List) { + val studentId = member.studentId + val fcmToken = member.fcmToken + + if (fcmToken == null) { + log.warn { "(studentId=$studentId) FCM Token is null" } + return + } + + val result = try { + fcmService.sendPushNotification( + fcmToken, + status.title, + status.formattedMessage(*formatValues.toTypedArray()), + status.link, + studentId + ) + } catch (e: Exception) { + // 메시지 포맷 인자 개수 불일치 등 발송 이전 단계에서 발생하는 오류 + log.error(e) { "(studentId=$studentId) 푸시 메시지 생성 실패: ${e.message}" } + return + } + + when (result) { + PushResult.Success -> Unit + PushResult.InvalidToken -> memberService.clearFcmToken(member.id!!) + is PushResult.Retryable -> log.warn { "(studentId=$studentId) 재시도 가능한 푸시 실패: ${result.reason}" } + is PushResult.Permanent -> log.error { "(studentId=$studentId) 재시도 불가능한 푸시 실패: ${result.reason}" } + } + } + + fun sendAll(members: List, status: NotificationStatus, formatValues: List) { + members.forEach { member -> + send(member, status, formatValues) + } + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt index 8e70895..1539c10 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/FCMService.kt @@ -8,7 +8,13 @@ import site.billilge.api.backend.global.logging.log class FCMService( private val firebaseMessaging: FirebaseMessaging, ) { - fun sendPushNotification(fcmToken: String, title: String, body: String, link: String, studentId: String = "20000000"): Boolean { + fun sendPushNotification( + fcmToken: String, + title: String, + body: String, + link: String, + studentId: String = "20000000" + ): PushResult { val fcmMessage = Message.builder() .putData("title", title) .putData("body", body.replace("\n", " ")) @@ -24,15 +30,44 @@ class FCMService( return try { firebaseMessaging.send(fcmMessage) log.info { "(studentId=$studentId) FCM Message sent." } - true + PushResult.Success } catch (e: FirebaseMessagingException) { - if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { - log.warn { "(studentId=$studentId) FCM token is unregistered. Clearing token." } - false - } else { - log.error { "(studentId=$studentId) FCM send failed: ${e.message}" } - true + resolveFailure(e, studentId) + } catch (e: Exception) { + // 자격 증명 갱신 실패 등 SDK가 FirebaseMessagingException으로 감싸지 않는 오류 + log.error(e) { "(studentId=$studentId) FCM send failed: ${e.message}" } + PushResult.Retryable(e.javaClass.simpleName) + } + } + + private fun resolveFailure(e: FirebaseMessagingException, studentId: String): PushResult { + val errorCode = e.messagingErrorCode + + return when (errorCode) { + MessagingErrorCode.UNREGISTERED, MessagingErrorCode.SENDER_ID_MISMATCH -> { + log.warn { "(studentId=$studentId) FCM token is no longer valid($errorCode). Clearing token." } + PushResult.InvalidToken + } + + MessagingErrorCode.UNAVAILABLE, + MessagingErrorCode.INTERNAL, + MessagingErrorCode.QUOTA_EXCEEDED, + MessagingErrorCode.THIRD_PARTY_AUTH_ERROR -> { + log.error { "(studentId=$studentId) FCM send failed temporarily($errorCode): ${e.message}" } + PushResult.Retryable(errorCode.name) + } + + // 에러 코드가 없는 경우는 전송 계층 오류에 가까우므로 재시도 대상으로 둔다 + null -> { + log.error { "(studentId=$studentId) FCM send failed without error code: ${e.message}" } + PushResult.Retryable(e.javaClass.simpleName) + } + + // INVALID_ARGUMENT는 토큰 문제일 수도, 페이로드 문제일 수도 있어 토큰을 지우지 않는다 + else -> { + log.error { "(studentId=$studentId) FCM send failed permanently($errorCode): ${e.message}" } + PushResult.Permanent(errorCode.name) } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/site/billilge/api/backend/global/external/fcm/PushResult.kt b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/PushResult.kt new file mode 100644 index 0000000..da425a0 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/external/fcm/PushResult.kt @@ -0,0 +1,20 @@ +package site.billilge.api.backend.global.external.fcm + +/** + * FCM 푸시 발송 결과. + * + * 호출부가 "재시도해야 하는 실패"와 "재시도해도 소용없는 실패"를 구분할 수 있도록 + * 실패를 세 종류로 나눈다. + */ +sealed interface PushResult { + data object Success : PushResult + + /** 토큰이 더 이상 유효하지 않음 — 재시도 대신 토큰을 제거해야 한다 */ + data object InvalidToken : PushResult + + /** FCM 일시 장애·네트워크 오류 — 동일한 요청으로 재시도할 수 있다 */ + data class Retryable(val reason: String) : PushResult + + /** 페이로드 오류 등 재시도해도 결과가 같은 실패 */ + data class Permanent(val reason: String) : PushResult +} From ce1a92eef67bf55d96c49ec1d929d20af082cf38 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:41:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EA=B8=B0=EC=99=80=20=EC=98=88=EC=99=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=84=A4=EC=A0=95=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 설정 실행기(applicationTaskExecutor)는 큐가 무제한이라 FCM 지연 시 태스크가 계속 쌓이고, 종료 대기 설정이 없어 배포 시 큐에 남은 알림이 유실됐다. - notificationTaskExecutor 정의 (코어 4 / 최대 8 / 큐 500) - 큐 포화 시 CallerRunsPolicy로 유실 대신 지연 선택 - 종료 시 최대 20초간 잔여 작업 처리 대기 - AsyncUncaughtExceptionHandler 등록해 실패한 비동기 작업을 식별 가능하도록 기록 Co-Authored-By: Claude Opus 5 --- .../api/backend/global/config/AsyncConfig.kt | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt b/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt index 71b0bd9..96887a5 100644 --- a/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt +++ b/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt @@ -1,9 +1,54 @@ package site.billilge.api.backend.global.config +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.AsyncConfigurer import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor +import java.util.concurrent.Executor +import java.util.concurrent.ThreadPoolExecutor + +private val log = KotlinLogging.logger {} @EnableAsync @Configuration -class AsyncConfig { -} \ No newline at end of file +class AsyncConfig : AsyncConfigurer { + /** + * 알림 발송 전용 실행기. + * + * 자동 설정 실행기(applicationTaskExecutor)는 큐가 무제한이라 FCM이 지연되면 태스크가 + * 계속 쌓이고, 종료 대기 설정이 없어 배포 시 큐에 남은 알림이 유실된다. + */ + @Bean + fun notificationTaskExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply { + corePoolSize = CORE_POOL_SIZE + maxPoolSize = MAX_POOL_SIZE + setQueueCapacity(QUEUE_CAPACITY) + setThreadNamePrefix("notification-") + + // 큐가 가득 차면 버리는 대신 호출 스레드에서 실행한다 — 유실보다 지연을 택한다 + setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy()) + + // 종료 시 큐에 남은 알림을 처리할 시간을 준다 + setWaitForTasksToCompleteOnShutdown(true) + setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS) + } + + override fun getAsyncExecutor(): Executor = notificationTaskExecutor() + + override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler = + AsyncUncaughtExceptionHandler { ex, method, params -> + log.error(ex) { + "비동기 작업 실패: ${method.declaringClass.simpleName}.${method.name}(${params.joinToString()})" + } + } + + companion object { + private const val CORE_POOL_SIZE = 4 + private const val MAX_POOL_SIZE = 8 + private const val QUEUE_CAPACITY = 500 + private const val AWAIT_TERMINATION_SECONDS = 20 + } +} From 2dd3d4f97503902d19d6cf6f252e98bd57d19b87 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:13:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=95=84=EC=9B=83=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=ED=91=B8=EC=8B=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 33 ++++- .../notification/dto/PushDispatchTarget.kt | 17 +++ .../entity/NotificationPushOutbox.kt | 130 ++++++++++++++++++ .../notification/enums/PushDeliveryStatus.kt | 15 ++ .../handler/NotificationEventHandler.kt | 21 +-- .../NotificationPushOutboxRepository.kt | 23 ++++ .../scheduler/PushRetryScheduler.kt | 44 ++++++ .../service/NotificationPushOutboxService.kt | 103 ++++++++++++++ .../service/NotificationService.kt | 35 +++-- .../service/PushNotificationSender.kt | 63 +++++---- .../backend/global/config/SchedulingConfig.kt | 14 ++ .../entity/NotificationPushOutboxTest.kt | 98 +++++++++++++ 12 files changed, 543 insertions(+), 53 deletions(-) create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/dto/PushDispatchTarget.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/enums/PushDeliveryStatus.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushRetryScheduler.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/global/config/SchedulingConfig.kt create mode 100644 src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index bbb5bab..bcf62fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,10 +74,11 @@ domain/{name}/ ``` ItemService → ItemRepository, S3Service MemberService → MemberRepository, TokenProvider, PayerService -NotificationService → NotificationRepository -PushNotificationSender → FCMService, MemberService -PayerService → PayerRepository, MemberRepository, ExcelGenerator -RentalService → RentalRepository, ApplicationEventPublisher +NotificationService → NotificationRepository, NotificationPushOutboxService +NotificationPushOutboxService → NotificationPushOutboxRepository +PushNotificationSender → FCMService, MemberService, NotificationPushOutboxService +PayerService → PayerRepository, MemberRepository, ExcelGenerator +RentalService → RentalRepository, ApplicationEventPublisher ``` ### 알림 발송 구조 @@ -87,8 +88,11 @@ RentalService → RentalRepository, ApplicationEventPublisher ``` RentalService → 이벤트 발행 └ NotificationEventHandler (@Async + AFTER_COMMIT, 트랜잭션 없음) - ├ NotificationService.createNotification() # 짧은 트랜잭션에서 저장 후 즉시 커밋 - └ PushNotificationSender.send() # 트랜잭션 밖에서 FCM 호출 + ├ NotificationService.createNotification() # 알림 + 아웃박스 저장 (한 트랜잭션, 즉시 커밋) + └ PushNotificationSender.dispatch() # 트랜잭션 밖에서 FCM 호출 + +PushRetryScheduler (@Scheduled, 30초 간격) + └ PushNotificationSender.dispatch() # 미발송 건 재시도 (같은 경로) ``` - **푸시 발송은 트랜잭션 밖에서 수행한다** — 네트워크 I/O가 DB 커넥션을 점유하지 않도록, 그리고 푸시 실패가 저장된 알림을 롤백시키지 않도록 분리 @@ -96,6 +100,23 @@ RentalService → 이벤트 발행 - **FCM 실패는 `PushResult`로 구분한다** — `InvalidToken`(토큰 제거) / `Retryable`(재시도 대상) / `Permanent`(재시도 무의미) - **`@Async`는 알림 전용 실행기(`notificationTaskExecutor`)를 사용한다** — `AsyncConfig`에 정의 +### 푸시 재시도 (아웃박스) + +발송 대상을 `notification_push_outbox`에 **수신자 단위 row**로 남긴다. 알림과 같은 트랜잭션에서 저장되므로 프로세스가 재시작돼도 발송 대상이 남는다. + +``` +PENDING ─┬─ 발송 성공 ──────────────→ SENT + ├─ Retryable 실패 ─ 백오프 → PENDING (재시도 횟수 소진 시 FAILED) + ├─ InvalidToken/Permanent → FAILED + └─ 생성 후 1시간 경과 ─────→ EXPIRED +``` + +- **백오프는 `30초 → 2분 → 5분 → 15분`, 최대 4회** — `NotificationPushOutbox`의 상수로 정의 +- **생성 후 1시간이 지나면 포기한다(`EXPIRED`)** — 늦게 도착하는 푸시는 의미가 없다. 인앱 알림은 이미 저장돼 있음 +- **즉시 발송과 재시도가 같은 경로를 탄다** — 새 row의 `nextRetryAt`은 60초 뒤로 잡혀, 즉시 시도와 폴러가 겹치지 않는다 +- **메시지 본문은 저장하지 않는다** — 연결된 `Notification`의 status와 formatValues로 재구성 +- 인스턴스를 여러 대로 늘리면 조회에 잠금(`FOR UPDATE SKIP LOCKED`)이나 ShedLock이 필요하다 + ## 대여 상태 머신 ``` diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/PushDispatchTarget.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/PushDispatchTarget.kt new file mode 100644 index 0000000..95d0c3c --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/dto/PushDispatchTarget.kt @@ -0,0 +1,17 @@ +package site.billilge.api.backend.domain.notification.dto + +import site.billilge.api.backend.domain.notification.enums.NotificationStatus + +/** + * 아웃박스 한 건을 발송하는 데 필요한 값. + * + * FCM 호출은 트랜잭션 밖에서 이뤄지므로 엔티티 대신 이 값만 꺼내 넘긴다. + */ +data class PushDispatchTarget( + val outboxId: Long, + val receiverId: Long, + val studentId: String, + val fcmToken: String?, + val status: NotificationStatus, + val formatValues: List, +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt new file mode 100644 index 0000000..548fd58 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt @@ -0,0 +1,130 @@ +package site.billilge.api.backend.domain.notification.entity + +import jakarta.persistence.* +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.LastModifiedDate +import org.springframework.data.jpa.domain.support.AuditingEntityListener +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.notification.enums.PushDeliveryStatus +import java.time.Duration +import java.time.LocalDateTime + +/** + * 푸시 발송 대기열. + * + * 알림 저장과 같은 트랜잭션에서 수신자 수만큼 생성된다. 발송 실패 시 상태와 재시도 시각만 + * 갱신되므로 프로세스가 재시작돼도 발송 대상이 남는다. + * + * 메시지 본문은 저장하지 않는다 — 연결된 [Notification]의 status와 formatValues로 재구성한다. + */ +@Entity +@Table( + name = "notification_push_outbox", + indexes = [Index(name = "idx_push_outbox_delivery", columnList = "delivery_status, next_retry_at")] +) +@EntityListeners(AuditingEntityListener::class) +class NotificationPushOutbox( + @JoinColumn(name = "notification_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + val notification: Notification, + + @JoinColumn(name = "receiver_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @OnDelete(action = OnDeleteAction.CASCADE) + val receiver: Member, +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "notification_push_outbox_id", nullable = false) + val id: Long? = null + + @Enumerated(EnumType.STRING) + @Column(name = "delivery_status", nullable = false) + var deliveryStatus: PushDeliveryStatus = PushDeliveryStatus.PENDING + protected set + + @Column(name = "retry_count", nullable = false) + var retryCount: Int = 0 + protected set + + /** + * 폴러는 이 시각이 지난 건만 집어간다. + * + * 생성 직후에는 이벤트 핸들러가 즉시 1회 발송을 시도하므로, 그 시도와 폴러가 겹쳐 + * 중복 발송되지 않도록 첫 대상 시각을 뒤로 미뤄 둔다. + */ + @Column(name = "next_retry_at", nullable = false) + var nextRetryAt: LocalDateTime = LocalDateTime.now().plus(FIRST_POLL_DELAY) + protected set + + @Column(name = "last_error", length = MAX_ERROR_LENGTH) + var lastError: String? = null + protected set + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + protected set + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + protected set + + fun markSent() { + deliveryStatus = PushDeliveryStatus.SENT + lastError = null + } + + /** 재시도해도 결과가 같은 실패 — 더 시도하지 않는다 */ + fun markFailed(reason: String) { + deliveryStatus = PushDeliveryStatus.FAILED + lastError = reason.take(MAX_ERROR_LENGTH) + } + + fun markExpired() { + deliveryStatus = PushDeliveryStatus.EXPIRED + } + + /** + * 재시도 가능한 실패를 기록하고 다음 시도 시각을 뒤로 민다. + * 재시도 횟수를 모두 썼거나 유효 시간이 지났으면 발송을 포기한다. + */ + fun recordRetryableFailure(reason: String, now: LocalDateTime = LocalDateTime.now()) { + lastError = reason.take(MAX_ERROR_LENGTH) + + if (isExpired(now)) { + markExpired() + return + } + + if (retryCount >= BACKOFF_SECONDS.size) { + deliveryStatus = PushDeliveryStatus.FAILED + return + } + + nextRetryAt = now.plusSeconds(BACKOFF_SECONDS[retryCount]) + retryCount++ + } + + fun isPending(): Boolean = deliveryStatus == PushDeliveryStatus.PENDING + + fun isExpired(now: LocalDateTime = LocalDateTime.now()): Boolean = + now.isAfter(createdAt.plus(TIME_TO_LIVE)) + + companion object { + /** 즉시 발송 시도와 폴러가 겹치지 않도록 두는 간격 */ + private val FIRST_POLL_DELAY: Duration = Duration.ofSeconds(60) + + /** 늦게 도착하는 푸시는 의미가 없으므로 1시간까지만 재시도한다 */ + private val TIME_TO_LIVE: Duration = Duration.ofHours(1) + + /** 재시도 간격(초) — 배열 길이가 곧 최대 재시도 횟수 */ + private val BACKOFF_SECONDS = longArrayOf(30, 120, 300, 900) + + private const val MAX_ERROR_LENGTH = 500 + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/enums/PushDeliveryStatus.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/enums/PushDeliveryStatus.kt new file mode 100644 index 0000000..5d74ae6 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/enums/PushDeliveryStatus.kt @@ -0,0 +1,15 @@ +package site.billilge.api.backend.domain.notification.enums + +enum class PushDeliveryStatus { + /** 발송 대기 — 폴러가 재시도 대상으로 집어간다 */ + PENDING, + + /** 발송 완료 */ + SENT, + + /** 재시도 횟수를 모두 썼거나 재시도해도 소용없는 실패 */ + FAILED, + + /** 유효 시간이 지나 발송을 포기함 — 늦게 도착하는 푸시는 의미가 없다 */ + EXPIRED, +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt index 499b32b..8b6c62a 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/handler/NotificationEventHandler.kt @@ -103,11 +103,14 @@ class NotificationEventHandler( formatValues: List, needPush: Boolean, ) { - notificationService.createNotification(member, status, formatValues) + val outboxIds = notificationService.createNotification( + member, + status, + formatValues, + pushReceivers = if (needPush) listOf(member) else emptyList(), + ) - if (needPush) { - pushNotificationSender.send(member, status, formatValues) - } + pushNotificationSender.dispatch(outboxIds) } private fun notifyAdmins( @@ -115,10 +118,12 @@ class NotificationEventHandler( formatValues: List, needPush: Boolean, ) { - notificationService.createAdminNotification(status, formatValues) + val outboxIds = notificationService.createAdminNotification( + status, + formatValues, + pushReceivers = if (needPush) memberService.findAllWorkers() else emptyList(), + ) - if (needPush) { - pushNotificationSender.sendAll(memberService.findAllWorkers(), status, formatValues) - } + pushNotificationSender.dispatch(outboxIds) } } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt new file mode 100644 index 0000000..b7fd2ae --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt @@ -0,0 +1,23 @@ +package site.billilge.api.backend.domain.notification.repository + +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import site.billilge.api.backend.domain.notification.entity.NotificationPushOutbox +import site.billilge.api.backend.domain.notification.enums.PushDeliveryStatus +import java.time.LocalDateTime + +interface NotificationPushOutboxRepository : JpaRepository { + @Query( + """ + SELECT o.id FROM NotificationPushOutbox o + WHERE o.deliveryStatus = :deliveryStatus AND o.nextRetryAt <= :now + ORDER BY o.nextRetryAt ASC + """ + ) + fun findDispatchTargetIds( + deliveryStatus: PushDeliveryStatus, + now: LocalDateTime, + pageable: Pageable + ): List +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushRetryScheduler.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushRetryScheduler.kt new file mode 100644 index 0000000..0b500af --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushRetryScheduler.kt @@ -0,0 +1,44 @@ +package site.billilge.api.backend.domain.notification.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.notification.service.NotificationPushOutboxService +import site.billilge.api.backend.domain.notification.service.PushNotificationSender +import site.billilge.api.backend.global.external.fcm.PushResult + +private val log = KotlinLogging.logger {} + +/** + * 발송되지 못한 푸시를 주기적으로 재시도한다. + * + * fixedDelay라 이전 주기가 끝난 뒤에 다음 주기가 시작된다 — 발송이 밀려도 중복 실행되지 않는다. + */ +@Component +class PushRetryScheduler( + private val notificationPushOutboxService: NotificationPushOutboxService, + private val pushNotificationSender: PushNotificationSender, +) { + @Scheduled( + initialDelayString = "\${notification.push.retry.initial-delay-ms:60000}", + fixedDelayString = "\${notification.push.retry.poll-interval-ms:30000}", + ) + fun retryFailedPushes() { + val targetIds = notificationPushOutboxService.findRetryTargetIds(BATCH_SIZE) + + if (targetIds.isEmpty()) return + + val results = pushNotificationSender.dispatch(targetIds) + val sentCount = results.count { result -> result == PushResult.Success } + + log.info { "푸시 재시도 ${results.size}건 처리 (성공 $sentCount, 실패 ${results.size - sentCount})" } + + if (targetIds.size == BATCH_SIZE) { + log.warn { "재시도 대상이 한 주기 처리량(${BATCH_SIZE}건)을 채웠습니다. 남은 건은 다음 주기에 처리됩니다." } + } + } + + companion object { + private const val BATCH_SIZE = 100 + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt new file mode 100644 index 0000000..55ac573 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt @@ -0,0 +1,103 @@ +package site.billilge.api.backend.domain.notification.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.domain.PageRequest +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.notification.dto.PushDispatchTarget +import site.billilge.api.backend.domain.notification.entity.Notification +import site.billilge.api.backend.domain.notification.entity.NotificationPushOutbox +import site.billilge.api.backend.domain.notification.enums.PushDeliveryStatus +import site.billilge.api.backend.domain.notification.repository.NotificationPushOutboxRepository +import site.billilge.api.backend.global.external.fcm.PushResult +import java.time.LocalDateTime + +private val log = KotlinLogging.logger {} + +@Service +@Transactional(readOnly = true) +class NotificationPushOutboxService( + private val notificationPushOutboxRepository: NotificationPushOutboxRepository, +) { + /** + * 발송 대기열에 수신자 수만큼 등록한다. + * + * 알림 저장 트랜잭션에 참여하므로, 알림과 발송 대상은 함께 저장되거나 함께 롤백된다. + */ + @Transactional + fun register(notification: Notification, receivers: List): List { + return receivers + .filter { receiver -> receiver.hasFcmToken() } + .map { receiver -> + notificationPushOutboxRepository.save(NotificationPushOutbox(notification, receiver)).id!! + } + } + + fun findRetryTargetIds(limit: Int): List { + return notificationPushOutboxRepository.findDispatchTargetIds( + PushDeliveryStatus.PENDING, + LocalDateTime.now(), + PageRequest.of(0, limit) + ) + } + + /** + * 발송에 필요한 값을 꺼내 온다. 이미 처리됐거나 유효 시간이 지난 건은 null을 반환한다. + */ + @Transactional + fun findDispatchTarget(outboxId: Long): PushDispatchTarget? { + val outbox = notificationPushOutboxRepository.findByIdOrNull(outboxId) ?: return null + + if (!outbox.isPending()) return null + + if (outbox.isExpired()) { + outbox.markExpired() + log.warn { "(outboxId=$outboxId) 유효 시간이 지나 푸시 발송을 포기합니다." } + return null + } + + val receiver = outbox.receiver + val notification = outbox.notification + + return PushDispatchTarget( + outboxId = outboxId, + receiverId = receiver.id!!, + studentId = receiver.studentId, + fcmToken = receiver.fcmToken, + status = notification.status, + formatValues = notification.formatValueList, + ) + } + + @Transactional + fun applyResult(outboxId: Long, result: PushResult) { + val outbox = notificationPushOutboxRepository.findByIdOrNull(outboxId) ?: return + + when (result) { + PushResult.Success -> outbox.markSent() + PushResult.InvalidToken -> outbox.markFailed(INVALID_TOKEN_REASON) + is PushResult.Permanent -> outbox.markFailed(result.reason) + is PushResult.Retryable -> outbox.recordRetryableFailure(result.reason) + } + + if (outbox.deliveryStatus == PushDeliveryStatus.FAILED || outbox.deliveryStatus == PushDeliveryStatus.EXPIRED) { + log.error { + "(outboxId=$outboxId) 푸시 발송을 포기합니다. " + + "status=${outbox.deliveryStatus}, retryCount=${outbox.retryCount}, reason=${outbox.lastError}" + } + } + } + + private fun Member.hasFcmToken(): Boolean { + if (fcmToken != null) return true + + log.warn { "(studentId=$studentId) FCM Token is null" } + return false + } + + companion object { + private const val INVALID_TOKEN_REASON = "INVALID_TOKEN" + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt index f3cc69c..ab8683c 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationService.kt @@ -13,6 +13,7 @@ import site.billilge.api.backend.global.exception.ApiException @Transactional(readOnly = true) class NotificationService( private val notificationRepository: NotificationRepository, + private val notificationPushOutboxService: NotificationPushOutboxService, ) { fun getNotifications(memberId: Long?): List { return notificationRepository.findAllUserNotificationsByMemberId(memberId!!) @@ -36,32 +37,44 @@ class NotificationService( return notificationRepository.findAllAdminNotificationsByMemberId(memberId!!) } + /** + * 알림을 저장하고 푸시 발송 대상을 대기열에 등록한다. 등록된 아웃박스 ID를 반환한다. + * + * 알림과 발송 대상이 하나의 트랜잭션에서 저장되므로, 커밋된 이후에는 발송이 누락되더라도 + * 대기열에 남아 재시도된다. + */ @Transactional fun createNotification( member: Member, status: NotificationStatus, formatValues: List, - ): Notification { - val notification = Notification( - member = member, - status = status, - formatValues = formatValues.joinToString(",") + pushReceivers: List = emptyList(), + ): List { + val notification = notificationRepository.save( + Notification( + member = member, + status = status, + formatValues = formatValues.joinToString(",") + ) ) - return notificationRepository.save(notification) + return notificationPushOutboxService.register(notification, pushReceivers) } @Transactional fun createAdminNotification( status: NotificationStatus, formatValues: List, - ): Notification { - val notification = Notification( - status = status, - formatValues = formatValues.joinToString(",") + pushReceivers: List = emptyList(), + ): List { + val notification = notificationRepository.save( + Notification( + status = status, + formatValues = formatValues.joinToString(",") + ) ) - return notificationRepository.save(notification) + return notificationPushOutboxService.register(notification, pushReceivers) } fun getNotificationCount(memberId: Long?): Int { diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt index af92c53..1ed1223 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt @@ -2,60 +2,67 @@ package site.billilge.api.backend.domain.notification.service import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Component -import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.service.MemberService -import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import site.billilge.api.backend.domain.notification.dto.PushDispatchTarget import site.billilge.api.backend.global.external.fcm.FCMService import site.billilge.api.backend.global.external.fcm.PushResult private val log = KotlinLogging.logger {} /** - * FCM 푸시 발송 담당. + * 아웃박스에 등록된 푸시를 발송한다. * * 알림 저장 트랜잭션 바깥에서 호출된다. 네트워크 I/O가 DB 커넥션을 붙잡지 않도록 * @Transactional을 걸지 않으며, 발송 실패를 예외로 전파하지 않는다. - * 한 수신자의 실패가 다른 수신자나 이미 저장된 알림에 영향을 주면 안 되기 때문이다. + * 한 수신자의 실패가 다른 수신자에게 영향을 주면 안 되기 때문이다. + * + * 알림 발생 직후의 즉시 발송과 [PushRetryScheduler]의 재시도가 이 경로를 공유한다. */ @Component class PushNotificationSender( private val fcmService: FCMService, private val memberService: MemberService, + private val notificationPushOutboxService: NotificationPushOutboxService, ) { - fun send(member: Member, status: NotificationStatus, formatValues: List) { - val studentId = member.studentId - val fcmToken = member.fcmToken + fun dispatch(outboxIds: List): List { + return outboxIds.mapNotNull { outboxId -> dispatchOne(outboxId) } + } + + /** 이미 처리됐거나 유효 시간이 지난 건은 건너뛰고 null을 반환한다 */ + private fun dispatchOne(outboxId: Long): PushResult? { + val target = notificationPushOutboxService.findDispatchTarget(outboxId) ?: return null - if (fcmToken == null) { - log.warn { "(studentId=$studentId) FCM Token is null" } - return + val result = send(target) + + notificationPushOutboxService.applyResult(outboxId, result) + + if (result == PushResult.InvalidToken) { + memberService.clearFcmToken(target.receiverId) } - val result = try { + return result + } + + private fun send(target: PushDispatchTarget): PushResult { + val fcmToken = target.fcmToken + ?: return PushResult.Permanent(TOKEN_NOT_REGISTERED_REASON) + + return try { fcmService.sendPushNotification( fcmToken, - status.title, - status.formattedMessage(*formatValues.toTypedArray()), - status.link, - studentId + target.status.title, + target.status.formattedMessage(*target.formatValues.toTypedArray()), + target.status.link, + target.studentId ) } catch (e: Exception) { // 메시지 포맷 인자 개수 불일치 등 발송 이전 단계에서 발생하는 오류 - log.error(e) { "(studentId=$studentId) 푸시 메시지 생성 실패: ${e.message}" } - return - } - - when (result) { - PushResult.Success -> Unit - PushResult.InvalidToken -> memberService.clearFcmToken(member.id!!) - is PushResult.Retryable -> log.warn { "(studentId=$studentId) 재시도 가능한 푸시 실패: ${result.reason}" } - is PushResult.Permanent -> log.error { "(studentId=$studentId) 재시도 불가능한 푸시 실패: ${result.reason}" } + log.error(e) { "(studentId=${target.studentId}) 푸시 메시지 생성 실패: ${e.message}" } + PushResult.Permanent(e.javaClass.simpleName) } } - fun sendAll(members: List, status: NotificationStatus, formatValues: List) { - members.forEach { member -> - send(member, status, formatValues) - } + companion object { + private const val TOKEN_NOT_REGISTERED_REASON = "FCM_TOKEN_NOT_REGISTERED" } } diff --git a/src/main/kotlin/site/billilge/api/backend/global/config/SchedulingConfig.kt b/src/main/kotlin/site/billilge/api/backend/global/config/SchedulingConfig.kt new file mode 100644 index 0000000..52ccf38 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/config/SchedulingConfig.kt @@ -0,0 +1,14 @@ +package site.billilge.api.backend.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableScheduling + +/** + * 스케줄러는 단일 스레드에서 순차 실행된다. + * + * 인스턴스를 여러 대로 늘리면 같은 발송 건을 동시에 집어갈 수 있으므로, + * 그때는 조회에 잠금(FOR UPDATE SKIP LOCKED)이나 ShedLock이 필요하다. + */ +@EnableScheduling +@Configuration +class SchedulingConfig diff --git a/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt b/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt new file mode 100644 index 0000000..51fc4eb --- /dev/null +++ b/src/test/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutboxTest.kt @@ -0,0 +1,98 @@ +package site.billilge.api.backend.domain.notification.entity + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import site.billilge.api.backend.domain.member.entity.Member +import site.billilge.api.backend.domain.notification.enums.NotificationStatus +import site.billilge.api.backend.domain.notification.enums.PushDeliveryStatus +import java.time.LocalDateTime + +class NotificationPushOutboxTest { + @Test + @DisplayName("생성 직후에는 즉시 발송 시도와 겹치지 않도록 폴러 대상 시각이 뒤로 밀려 있다") + fun `첫 폴링 시각은 생성 시각보다 뒤에 있다`() { + val outbox = createOutbox() + + assertTrue(outbox.nextRetryAt.isAfter(outbox.createdAt)) + assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus) + assertEquals(0, outbox.retryCount) + } + + @Test + @DisplayName("재시도 가능한 실패는 정해진 간격만큼 다음 시도를 미룬다") + fun `백오프 간격이 점점 늘어난다`() { + val outbox = createOutbox() + val now = LocalDateTime.now() + + val expectedBackoffSeconds = listOf(30L, 120L, 300L, 900L) + + expectedBackoffSeconds.forEachIndexed { index, seconds -> + outbox.recordRetryableFailure("UNAVAILABLE", now) + + assertEquals(now.plusSeconds(seconds), outbox.nextRetryAt) + assertEquals(index + 1, outbox.retryCount) + assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus) + } + } + + @Test + @DisplayName("재시도 횟수를 모두 쓰면 발송을 포기한다") + fun `최대 재시도 횟수를 넘기면 FAILED가 된다`() { + val outbox = createOutbox() + val now = LocalDateTime.now() + + repeat(4) { outbox.recordRetryableFailure("UNAVAILABLE", now) } + assertEquals(PushDeliveryStatus.PENDING, outbox.deliveryStatus) + + outbox.recordRetryableFailure("UNAVAILABLE", now) + + assertEquals(PushDeliveryStatus.FAILED, outbox.deliveryStatus) + assertEquals("UNAVAILABLE", outbox.lastError) + } + + @Test + @DisplayName("유효 시간이 지나면 재시도 횟수가 남아 있어도 포기한다") + fun `TTL을 넘기면 EXPIRED가 된다`() { + val outbox = createOutbox() + + outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now().plusHours(2)) + + assertEquals(PushDeliveryStatus.EXPIRED, outbox.deliveryStatus) + assertEquals(0, outbox.retryCount) + } + + @Test + @DisplayName("발송에 성공하면 직전 실패 기록을 지운다") + fun `markSent는 lastError를 비운다`() { + val outbox = createOutbox() + outbox.recordRetryableFailure("UNAVAILABLE", LocalDateTime.now()) + + outbox.markSent() + + assertEquals(PushDeliveryStatus.SENT, outbox.deliveryStatus) + assertEquals(null, outbox.lastError) + } + + @Test + @DisplayName("처리가 끝난 건은 다시 발송 대상이 되지 않는다") + fun `SENT 상태는 isPending이 false다`() { + val outbox = createOutbox() + + outbox.markSent() + + assertTrue(!outbox.isPending()) + } + + private fun createOutbox(): NotificationPushOutbox { + val member = Member(name = "김국민", studentId = "20240001") + val notification = Notification( + member = member, + status = NotificationStatus.USER_RENTAL_APPROVED, + formatValues = "우산" + ) + + return NotificationPushOutbox(notification, member) + } +} From 1a600d2d0804311c5eb6ce8ca0ec6f2f01f8db34 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:52:33 +0900 Subject: [PATCH 4/4] =?UTF-8?q?chore:=20=ED=91=B8=EC=8B=9C=20=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=B0=95=EC=8A=A4=20=EB=B3=B4=EA=B4=80=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=B0=8F=20=EC=A0=95=EB=A6=AC=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송이 끝난 아웃박스 row가 계속 남았다. 알림 한 건마다 수신자 수만큼 쌓이므로 방치하면 재시도 대상 조회가 느려진다. - 매일 새벽 4시(KST) 보존 기간이 지난 건 삭제 - SENT 7일, FAILED/EXPIRED 30일(실패 원인 확인용), PENDING은 삭제하지 않음 - 배치(500건) 단위로 나눠 삭제하고 배치마다 트랜잭션을 끊어 락 구간을 짧게 유지 - 한 회 처리량 상한(20배치)에 도달하면 남은 건이 있다는 경고 로그 - 정리 조회용 인덱스 (delivery_status, created_at) 추가 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 11 +++ .../entity/NotificationPushOutbox.kt | 5 +- .../NotificationPushOutboxRepository.kt | 13 ++++ .../scheduler/PushOutboxPurgeScheduler.kt | 71 +++++++++++++++++++ .../service/NotificationPushOutboxService.kt | 25 +++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushOutboxPurgeScheduler.kt diff --git a/CLAUDE.md b/CLAUDE.md index bcf62fd..0532a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,17 @@ PENDING ─┬─ 발송 성공 ──────────────→ SE - **메시지 본문은 저장하지 않는다** — 연결된 `Notification`의 status와 formatValues로 재구성 - 인스턴스를 여러 대로 늘리면 조회에 잠금(`FOR UPDATE SKIP LOCKED`)이나 ShedLock이 필요하다 +**보관 정책** — `PushOutboxPurgeScheduler`가 매일 새벽 4시(KST)에 정리한다. + +| 상태 | 보존 기간 | +|---|---| +| `SENT` | 7일 | +| `FAILED`, `EXPIRED` | 30일 (실패 원인 확인용) | +| `PENDING` | 삭제하지 않음 | + +- **배치(500건)로 나눠 삭제하고 배치마다 트랜잭션을 끊는다** — 락 구간을 짧게 유지 +- **한 회 처리량 상한(20배치)에 도달하면 경고 로그를 남긴다** — 남은 건이 있다는 사실이 묻히지 않도록 + ## 대여 상태 머신 ``` diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt index 548fd58..257f4c2 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt @@ -22,7 +22,10 @@ import java.time.LocalDateTime @Entity @Table( name = "notification_push_outbox", - indexes = [Index(name = "idx_push_outbox_delivery", columnList = "delivery_status, next_retry_at")] + indexes = [ + Index(name = "idx_push_outbox_delivery", columnList = "delivery_status, next_retry_at"), + Index(name = "idx_push_outbox_purge", columnList = "delivery_status, created_at"), + ] ) @EntityListeners(AuditingEntityListener::class) class NotificationPushOutbox( diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt index b7fd2ae..c335bc8 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt @@ -20,4 +20,17 @@ interface NotificationPushOutboxRepository : JpaRepository + + /** 정렬하지 않는다 — 보존 기간이 지난 건을 모두 지우므로 삭제 순서는 중요하지 않다 */ + @Query( + """ + SELECT o.id FROM NotificationPushOutbox o + WHERE o.deliveryStatus IN :deliveryStatuses AND o.createdAt < :createdBefore + """ + ) + fun findPurgeTargetIds( + deliveryStatuses: Collection, + createdBefore: LocalDateTime, + pageable: Pageable + ): List } diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushOutboxPurgeScheduler.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushOutboxPurgeScheduler.kt new file mode 100644 index 0000000..5d86c7d --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/scheduler/PushOutboxPurgeScheduler.kt @@ -0,0 +1,71 @@ +package site.billilge.api.backend.domain.notification.scheduler + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import site.billilge.api.backend.domain.notification.enums.PushDeliveryStatus +import site.billilge.api.backend.domain.notification.service.NotificationPushOutboxService +import java.time.LocalDateTime + +private val log = KotlinLogging.logger {} + +/** + * 보존 기간이 지난 발송 대기열을 정리한다. + * + * 발송이 끝난 건은 폴러가 다시 조회하지 않지만, 알림 한 건마다 수신자 수만큼 쌓이므로 + * 방치하면 재시도 대상 조회가 느려진다. + * + * 재시도 폴러와 같은 스케줄러 스레드를 쓰므로, 한 회 처리량에 상한을 두어 폴러가 밀리지 않게 한다. + */ +@Component +class PushOutboxPurgeScheduler( + private val notificationPushOutboxService: NotificationPushOutboxService, +) { + @Scheduled(cron = "\${notification.push.outbox.purge-cron:0 0 4 * * *}", zone = "Asia/Seoul") + fun purgeExpiredOutbox() { + val now = LocalDateTime.now() + + val sentCount = purge(SENT_STATUSES, now.minusDays(SENT_RETENTION_DAYS)) + val givenUpCount = purge(GIVEN_UP_STATUSES, now.minusDays(GIVEN_UP_RETENTION_DAYS)) + + if (sentCount == 0 && givenUpCount == 0) return + + log.info { "푸시 아웃박스 정리 완료 (발송 완료 ${sentCount}건, 발송 포기 ${givenUpCount}건 삭제)" } + } + + private fun purge(deliveryStatuses: List, createdBefore: LocalDateTime): Int { + var deletedCount = 0 + + repeat(MAX_BATCHES_PER_RUN) { + val deleted = notificationPushOutboxService.deletePurgeTargetBatch( + deliveryStatuses, + createdBefore, + BATCH_SIZE + ) + + deletedCount += deleted + + if (deleted < BATCH_SIZE) return deletedCount + } + + log.warn { + "정리 대상이 한 회 처리량(${MAX_BATCHES_PER_RUN * BATCH_SIZE}건)을 채웠습니다. " + + "남은 건은 다음 주기에 정리됩니다. statuses=$deliveryStatuses" + } + + return deletedCount + } + + companion object { + private val SENT_STATUSES = listOf(PushDeliveryStatus.SENT) + + /** 실패 원인(last_error)을 들여다볼 여지를 남기기 위해 더 오래 보관한다 */ + private val GIVEN_UP_STATUSES = listOf(PushDeliveryStatus.FAILED, PushDeliveryStatus.EXPIRED) + + private const val SENT_RETENTION_DAYS = 7L + private const val GIVEN_UP_RETENTION_DAYS = 30L + + private const val BATCH_SIZE = 500 + private const val MAX_BATCHES_PER_RUN = 20 + } +} diff --git a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt index 55ac573..ffde208 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt @@ -90,6 +90,31 @@ class NotificationPushOutboxService( } } + /** + * 보존 기간이 지난 건을 배치 크기만큼 삭제하고 삭제한 수를 반환한다. + * + * 한 번에 다 지우면 락 구간이 길어지므로, 호출부가 반환값을 보고 반복 호출한다. + * 배치마다 트랜잭션이 끊기도록 이 메서드가 트랜잭션 경계를 잡는다. + */ + @Transactional + fun deletePurgeTargetBatch( + deliveryStatuses: Collection, + createdBefore: LocalDateTime, + batchSize: Int, + ): Int { + val targetIds = notificationPushOutboxRepository.findPurgeTargetIds( + deliveryStatuses, + createdBefore, + PageRequest.of(0, batchSize) + ) + + if (targetIds.isEmpty()) return 0 + + notificationPushOutboxRepository.deleteAllByIdInBatch(targetIds) + + return targetIds.size + } + private fun Member.hasFcmToken(): Boolean { if (fcmToken != null) return true