diff --git a/CLAUDE.md b/CLAUDE.md index e93eadf..0532a97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,13 +72,62 @@ 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, NotificationPushOutboxService +NotificationPushOutboxService → NotificationPushOutboxRepository +PushNotificationSender → FCMService, MemberService, NotificationPushOutboxService +PayerService → PayerRepository, MemberRepository, ExcelGenerator +RentalService → RentalRepository, ApplicationEventPublisher ``` +### 알림 발송 구조 + +`RentalService`가 이벤트를 발행하면 `NotificationEventHandler`가 커밋 이후 비동기로 받아 처리한다. + +``` +RentalService → 이벤트 발행 + └ NotificationEventHandler (@Async + AFTER_COMMIT, 트랜잭션 없음) + ├ NotificationService.createNotification() # 알림 + 아웃박스 저장 (한 트랜잭션, 즉시 커밋) + └ PushNotificationSender.dispatch() # 트랜잭션 밖에서 FCM 호출 + +PushRetryScheduler (@Scheduled, 30초 간격) + └ PushNotificationSender.dispatch() # 미발송 건 재시도 (같은 경로) +``` + +- **푸시 발송은 트랜잭션 밖에서 수행한다** — 네트워크 I/O가 DB 커넥션을 점유하지 않도록, 그리고 푸시 실패가 저장된 알림을 롤백시키지 않도록 분리 +- **`PushNotificationSender`는 예외를 전파하지 않는다** — 한 수신자의 실패가 다른 수신자에게 영향을 주면 안 됨 +- **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이 필요하다 + +**보관 정책** — `PushOutboxPurgeScheduler`가 매일 새벽 4시(KST)에 정리한다. + +| 상태 | 보존 기간 | +|---|---| +| `SENT` | 7일 | +| `FAILED`, `EXPIRED` | 30일 (실패 원인 확인용) | +| `PENDING` | 삭제하지 않음 | + +- **배치(500건)로 나눠 삭제하고 배치마다 트랜잭션을 끊는다** — 락 구간을 짧게 유지 +- **한 회 처리량 상한(20배치)에 도달하면 경고 로그를 남긴다** — 남은 건이 있다는 사실이 묻히지 않도록 + ## 대여 상태 머신 ``` 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..257f4c2 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/entity/NotificationPushOutbox.kt @@ -0,0 +1,133 @@ +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"), + Index(name = "idx_push_outbox_purge", columnList = "delivery_status, created_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 21fdd1c..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 @@ -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,36 @@ 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, + ) { + val outboxIds = notificationService.createNotification( + member, + status, + formatValues, + pushReceivers = if (needPush) listOf(member) else emptyList(), + ) + + pushNotificationSender.dispatch(outboxIds) + } + + private fun notifyAdmins( + status: NotificationStatus, + formatValues: List, + needPush: Boolean, + ) { + val outboxIds = notificationService.createAdminNotification( + status, + formatValues, + pushReceivers = if (needPush) memberService.findAllWorkers() else emptyList(), + ) + + 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..c335bc8 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/repository/NotificationPushOutboxRepository.kt @@ -0,0 +1,36 @@ +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 + + /** 정렬하지 않는다 — 보존 기간이 지난 건을 모두 지우므로 삭제 순서는 중요하지 않다 */ + @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/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..ffde208 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/NotificationPushOutboxService.kt @@ -0,0 +1,128 @@ +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}" + } + } + } + + /** + * 보존 기간이 지난 건을 배치 크기만큼 삭제하고 삭제한 수를 반환한다. + * + * 한 번에 다 지우면 락 구간이 길어지므로, 호출부가 반환값을 보고 반복 호출한다. + * 배치마다 트랜잭션이 끊기도록 이 메서드가 트랜잭션 경계를 잡는다. + */ + @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 + + 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 74896cf..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 @@ -1,26 +1,19 @@ 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, + private val notificationPushOutboxService: NotificationPushOutboxService, ) { fun getNotifications(memberId: Long?): List { return notificationRepository.findAllUserNotificationsByMemberId(memberId!!) @@ -44,71 +37,44 @@ class NotificationService( return notificationRepository.findAllAdminNotificationsByMemberId(memberId!!) } + /** + * 알림을 저장하고 푸시 발송 대상을 대기열에 등록한다. 등록된 아웃박스 ID를 반환한다. + * + * 알림과 발송 대상이 하나의 트랜잭션에서 저장되므로, 커밋된 이후에는 발송이 누락되더라도 + * 대기열에 남아 재시도된다. + */ @Transactional - fun sendNotification( - member: Member, - status: NotificationStatus, - formatValues: List, - needPush: Boolean = false, - ) { - val notification = Notification( - member = member, - status = status, - formatValues = formatValues.joinToString(",") - ) - - notificationRepository.save(notification) - - if (needPush) { - sendPushNotification(member, status, formatValues) - } - } - - private fun sendPushNotification( + fun createNotification( 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 + pushReceivers: List = emptyList(), + ): List { + val notification = notificationRepository.save( + Notification( + member = member, + status = status, + formatValues = formatValues.joinToString(",") + ) ) - if (!isTokenValid) { - memberService.clearFcmToken(member.id!!) - } + return notificationPushOutboxService.register(notification, pushReceivers) } @Transactional - fun sendNotificationToAdmin( - type: NotificationStatus, + fun createAdminNotification( + status: NotificationStatus, formatValues: List, - needPush: Boolean = false - ) { - val admins = memberService.findAllWorkers() - - val notification = Notification( - status = type, - formatValues = formatValues.joinToString(",") + pushReceivers: List = emptyList(), + ): List { + val notification = notificationRepository.save( + Notification( + status = status, + formatValues = formatValues.joinToString(",") + ) ) - notificationRepository.save(notification) - - if (needPush) { - admins.forEach { admin -> - sendPushNotification(admin, type, formatValues) - } - } + 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 new file mode 100644 index 0000000..1ed1223 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/notification/service/PushNotificationSender.kt @@ -0,0 +1,68 @@ +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.service.MemberService +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 {} + +/** + * 아웃박스에 등록된 푸시를 발송한다. + * + * 알림 저장 트랜잭션 바깥에서 호출된다. 네트워크 I/O가 DB 커넥션을 붙잡지 않도록 + * @Transactional을 걸지 않으며, 발송 실패를 예외로 전파하지 않는다. + * 한 수신자의 실패가 다른 수신자에게 영향을 주면 안 되기 때문이다. + * + * 알림 발생 직후의 즉시 발송과 [PushRetryScheduler]의 재시도가 이 경로를 공유한다. + */ +@Component +class PushNotificationSender( + private val fcmService: FCMService, + private val memberService: MemberService, + private val notificationPushOutboxService: NotificationPushOutboxService, +) { + 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 + + val result = send(target) + + notificationPushOutboxService.applyResult(outboxId, result) + + if (result == PushResult.InvalidToken) { + memberService.clearFcmToken(target.receiverId) + } + + return result + } + + private fun send(target: PushDispatchTarget): PushResult { + val fcmToken = target.fcmToken + ?: return PushResult.Permanent(TOKEN_NOT_REGISTERED_REASON) + + return try { + fcmService.sendPushNotification( + fcmToken, + target.status.title, + target.status.formattedMessage(*target.formatValues.toTypedArray()), + target.status.link, + target.studentId + ) + } catch (e: Exception) { + // 메시지 포맷 인자 개수 불일치 등 발송 이전 단계에서 발생하는 오류 + log.error(e) { "(studentId=${target.studentId}) 푸시 메시지 생성 실패: ${e.message}" } + PushResult.Permanent(e.javaClass.simpleName) + } + } + + 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/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 + } +} 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/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 +} 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) + } +}