diff --git a/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt b/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt index e1459c0..2457d77 100644 --- a/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt +++ b/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt @@ -4,6 +4,7 @@ import com.moa.entity.notification.NotificationLog import com.moa.entity.notification.NotificationStatus import com.moa.entity.notification.NotificationType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import java.time.LocalDate import java.time.LocalTime @@ -53,4 +54,18 @@ interface NotificationLogRepository : JpaRepository { statuses: Collection, memberIds: Collection, ): List + + /** + * Cleanup — [threshold] 이전(미만)에 예약됐는데 여전히 PENDING 인 알림을 일괄 EXPIRED 로 마킹한다. + * + * @return 마킹된 행 수 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query( + "update NotificationLog n " + + "set n.status = com.moa.entity.notification.NotificationStatus.EXPIRED " + + "where n.status = com.moa.entity.notification.NotificationStatus.PENDING " + + "and n.scheduledDate < :threshold" + ) + fun markExpiredBefore(threshold: LocalDate): Int } diff --git a/src/main/kotlin/com/moa/service/FcmService.kt b/src/main/kotlin/com/moa/service/FcmService.kt index 8085065..8aa7e1c 100644 --- a/src/main/kotlin/com/moa/service/FcmService.kt +++ b/src/main/kotlin/com/moa/service/FcmService.kt @@ -1,6 +1,11 @@ package com.moa.service -import com.google.firebase.messaging.* +import com.google.firebase.messaging.AndroidConfig +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.FirebaseMessagingException +import com.google.firebase.messaging.Message +import com.google.firebase.messaging.MessagingErrorCode +import com.google.firebase.messaging.Notification import com.moa.repository.FcmTokenRepository import com.moa.service.dto.FcmRequest import io.micrometer.core.instrument.Counter @@ -73,21 +78,23 @@ class FcmService( private fun handleFcmException(ex: Exception?, token: String) { val fcmEx = ex as? FirebaseMessagingException ?: run { - log.error("FCM 전송 중 예상치 못한 예외: {}", token, ex) + log.error("FCM 전송 중 예상치 못한 예외: tokenPrefix={}", maskToken(token), ex) return } when (fcmEx.messagingErrorCode) { MessagingErrorCode.UNREGISTERED -> { - log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) + log.warn("유효하지 않은 FCM 토큰 삭제: tokenPrefix={}", maskToken(token)) fcmTokenRepository.deleteByToken(token) tokenInvalidated("unregistered").increment() } + MessagingErrorCode.INVALID_ARGUMENT -> { - log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) + log.warn("유효하지 않은 FCM 토큰 삭제: tokenPrefix={}", maskToken(token)) fcmTokenRepository.deleteByToken(token) tokenInvalidated("invalid_argument").increment() } - else -> log.error("FCM 전송 실패: {}", token, fcmEx) + + else -> log.error("FCM 전송 실패: tokenPrefix={}", maskToken(token), fcmEx) } } @@ -95,5 +102,14 @@ class FcmService( private const val MAX_BATCH_SIZE = 500 private const val METRIC_SEND = "moa.fcm.send" private const val METRIC_TOKEN_INVALIDATED = "moa.fcm.token_invalidated" + private const val TOKEN_PREFIX_LENGTH = 8 + + /** + * FCM 토큰을 로그에 남길 때 식별용 prefix 만 노출한다. + */ + fun maskToken(token: String): String { + if (token.length <= TOKEN_PREFIX_LENGTH) return "***" + return token.take(TOKEN_PREFIX_LENGTH) + "***" + } } } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationCleanupScheduler.kt b/src/main/kotlin/com/moa/service/notification/NotificationCleanupScheduler.kt new file mode 100644 index 0000000..9b89c54 --- /dev/null +++ b/src/main/kotlin/com/moa/service/notification/NotificationCleanupScheduler.kt @@ -0,0 +1,46 @@ +package com.moa.service.notification + +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +/** + * Cleanup 스케줄러. + * + * 매일 새벽 1시에 [CLEANUP_THRESHOLD_DAYS]일 이상 지난 PENDING 을 + * EXPIRED 로 만료시킨다. Dispatch 인라인 catch-up 이 정상이면 처리 건수는 0 이어야 정상이며, + * 0 이 아니면 catch-up 미동작 가능성을 알리는 운영 신호이므로 WARN 으로 남긴다. + */ +@Component +class NotificationCleanupScheduler( + private val notificationCleanupService: NotificationCleanupService, + private val clock: NotificationScheduleClock, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Scheduled(cron = "0 0 1 * * *", zone = "Asia/Seoul") + @SchedulerLock( + name = "cleanupExpiredNotifications", + lockAtMostFor = "2m", + lockAtLeastFor = "1m", + ) + fun cleanupExpiredNotifications() { + val threshold = clock.now().toLocalDate().minusDays(CLEANUP_THRESHOLD_DAYS) + val expired = notificationCleanupService.expirePendingBefore(threshold) + if (expired > 0) { + // 정상 동작이면 0 이어야 한다. + // dispatch/catch-up 이 왜 이들을 발송·만료하지 못했는지 디버깅 필요 + log.warn( + "Cleanup expired {} stale PENDING notifications older than {} — investigate why dispatch left them PENDING", + expired, threshold, + ) + } else { + log.info("Cleanup found no stale PENDING (threshold={})", threshold) + } + } + + companion object { + private const val CLEANUP_THRESHOLD_DAYS = 7L + } +} diff --git a/src/main/kotlin/com/moa/service/notification/NotificationCleanupService.kt b/src/main/kotlin/com/moa/service/notification/NotificationCleanupService.kt new file mode 100644 index 0000000..3c38278 --- /dev/null +++ b/src/main/kotlin/com/moa/service/notification/NotificationCleanupService.kt @@ -0,0 +1,37 @@ +package com.moa.service.notification + +import com.moa.repository.NotificationLogRepository +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +/** + * Cleanup 안전망 서비스. + * + * Dispatch 인라인 catch-up 이 어떤 이유로 누락했을 때를 대비해, [threshold] 이전에 예약됐는데 + * 여전히 PENDING 인 알림을 일괄 EXPIRED 로 만료시킨다. 정상 동작에서는 처리 건수가 0 이어야 정상. + */ +@Service +class NotificationCleanupService( + private val notificationLogRepository: NotificationLogRepository, + private val meterRegistry: MeterRegistry, +) { + @Transactional + fun expirePendingBefore(threshold: LocalDate): Int { + val expired = notificationLogRepository.markExpiredBefore(threshold) + if (expired > 0) { + cleanupExpiredCounter().increment(expired.toDouble()) + } + return expired + } + + private fun cleanupExpiredCounter(): Counter = Counter.builder(METRIC_CLEANUP_EXPIRED) + .description("Cleanup 안전망이 만료시킨 잔류 PENDING 알림 수 (정상 동작에서는 0)") + .register(meterRegistry) + + companion object { + const val METRIC_CLEANUP_EXPIRED = "moa.notification.cleanup.expired" + } +} diff --git a/src/test/kotlin/com/moa/repository/NotificationLogRepositoryCleanupTest.kt b/src/test/kotlin/com/moa/repository/NotificationLogRepositoryCleanupTest.kt new file mode 100644 index 0000000..fe5632f --- /dev/null +++ b/src/test/kotlin/com/moa/repository/NotificationLogRepositoryCleanupTest.kt @@ -0,0 +1,113 @@ +package com.moa.repository + +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import jakarta.persistence.EntityManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest +import java.time.LocalDate +import java.time.LocalTime + +/** + * Cleanup 배치 안전망의 벌크 쿼리(`markExpiredBefore`)를 실제 H2(MySQL 모드)에서 검증한다. + * + * mockk fake 대신 실 DB 로 검증하는 이유: JPQL 의 status/scheduledDate 조건이나 + * 벌크 UPDATE 의 영향 범위가 틀려도 fake 는 통과시켜버린다. SQL 동작 자체가 검증 대상. + */ +@DataJpaTest +class NotificationLogRepositoryCleanupTest @Autowired constructor( + private val notificationLogRepository: NotificationLogRepository, + private val entityManager: EntityManager, +) { + + @Test + fun `임계일 이전의 PENDING 은 EXPIRED 로 마킹된다`() { + val log = save(date = 기준일.minusDays(8), type = NotificationType.CLOCK_IN, status = NotificationStatus.PENDING) + + val updated = notificationLogRepository.markExpiredBefore(기준일) + flushAndClear() + + assertThat(updated).isEqualTo(1) + assertThat(reload(log.id).status).isEqualTo(NotificationStatus.EXPIRED) + } + + @Test + fun `임계일 당일 이후의 PENDING 은 영향받지 않는다`() { + // 경계 의미: markExpiredBefore 는 scheduledDate < threshold(strict) 만 만료시킨다. + // 따라서 임계일 당일(==)과 그 이후(>)는 모두 영향받지 않아야 한다. + val onThreshold = save(date = 기준일, type = NotificationType.CLOCK_IN, status = NotificationStatus.PENDING) + val afterThreshold = save(date = 기준일.plusDays(1), type = NotificationType.CLOCK_OUT, status = NotificationStatus.PENDING) + + val updated = notificationLogRepository.markExpiredBefore(기준일) + flushAndClear() + + assertThat(updated).isEqualTo(0) + assertThat(reload(onThreshold.id).status).isEqualTo(NotificationStatus.PENDING) + assertThat(reload(afterThreshold.id).status).isEqualTo(NotificationStatus.PENDING) + } + + @Test + fun `PENDING 이 아닌 행은 임계일 이전이라도 영향받지 않는다`() { + val sent = save(date = 기준일.minusDays(10), type = NotificationType.PAYDAY, status = NotificationStatus.SENT) + val failed = save(date = 기준일.minusDays(10), type = NotificationType.CLOCK_IN, status = NotificationStatus.FAILED) + val cancelled = save(date = 기준일.minusDays(10), type = NotificationType.CLOCK_OUT, status = NotificationStatus.CANCELLED) + + val updated = notificationLogRepository.markExpiredBefore(기준일) + flushAndClear() + + assertThat(updated).isEqualTo(0) + assertThat(reload(sent.id).status).isEqualTo(NotificationStatus.SENT) + assertThat(reload(failed.id).status).isEqualTo(NotificationStatus.FAILED) + assertThat(reload(cancelled.id).status).isEqualTo(NotificationStatus.CANCELLED) + } + + @Test + fun `대상이 없으면 0 을 반환하고 예외 없이 종료된다`() { + val updated = notificationLogRepository.markExpiredBefore(기준일) + + assertThat(updated).isEqualTo(0) + } + + @Test + fun `여러 type 이 섞여 있어도 임계일 이전 PENDING 만 마킹하고 실제 건수를 반환한다`() { + save(date = 기준일.minusDays(8), type = NotificationType.CLOCK_IN, status = NotificationStatus.PENDING) + save(date = 기준일.minusDays(8), type = NotificationType.CLOCK_IN, status = NotificationStatus.PENDING) + save(date = 기준일.minusDays(9), type = NotificationType.PAYDAY, status = NotificationStatus.PENDING) + // 제외 대상: 임계일 당일 / PENDING 아님 + save(date = 기준일, type = NotificationType.CLOCK_IN, status = NotificationStatus.PENDING) + save(date = 기준일.minusDays(8), type = NotificationType.CLOCK_OUT, status = NotificationStatus.SENT) + + val updated = notificationLogRepository.markExpiredBefore(기준일) + + assertThat(updated).isEqualTo(3) + } + + private fun save( + date: LocalDate, + type: NotificationType, + status: NotificationStatus, + ): NotificationLog = notificationLogRepository.save( + NotificationLog( + memberId = 1L, + notificationType = type, + scheduledDate = date, + scheduledTime = LocalTime.of(9, 0), + status = status, + ), + ) + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } + + private fun reload(id: Long): NotificationLog = + notificationLogRepository.findById(id).orElseThrow() + + companion object { + private val 기준일: LocalDate = LocalDate.of(2026, 5, 31) + } +} diff --git a/src/test/kotlin/com/moa/service/FcmServiceTokenMaskTest.kt b/src/test/kotlin/com/moa/service/FcmServiceTokenMaskTest.kt new file mode 100644 index 0000000..5a772c9 --- /dev/null +++ b/src/test/kotlin/com/moa/service/FcmServiceTokenMaskTest.kt @@ -0,0 +1,37 @@ +package com.moa.service + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +/** + * FCM 토큰 마스킹 단위 테스트. + */ +class FcmServiceTokenMaskTest { + + @Test + fun `긴 토큰은 앞 8자 prefix 만 남기고 나머지는 가린다`() { + val token = "abcdefgh_super_secret_remainder_1234567890" + + val masked = FcmService.maskToken(token) + + assertThat(masked).startsWith("abcdefgh") + assertThat(masked).doesNotContain("super_secret_remainder") + assertThat(masked).doesNotContain(token) + } + + @Test + fun `8자 이하의 짧은 토큰은 전체를 가린다`() { + val token = "short" + + val masked = FcmService.maskToken(token) + + assertThat(masked).doesNotContain("short") + } + + @Test + fun `빈 토큰도 예외 없이 가려진 표현을 반환한다`() { + val masked = FcmService.maskToken("") + + assertThat(masked).isNotEmpty() + } +} diff --git a/src/test/kotlin/com/moa/service/notification/NotificationCleanupSchedulerTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationCleanupSchedulerTest.kt new file mode 100644 index 0000000..7efd1c9 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationCleanupSchedulerTest.kt @@ -0,0 +1,37 @@ +package com.moa.service.notification + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime + +class NotificationCleanupSchedulerTest { + + private val cleanupService = mockk() + private val clock = mockk() + private val sut = NotificationCleanupScheduler(cleanupService, clock) + + @Test + fun `오늘로부터 7일 이전을 threshold 로 cleanup 서비스에 위임한다`() { + every { clock.now() } returns LocalDateTime.of(2026, 5, 31, 1, 0) + every { cleanupService.expirePendingBefore(any()) } returns 0 + + sut.cleanupExpiredNotifications() + + verify(exactly = 1) { + cleanupService.expirePendingBefore(LocalDate.of(2026, 5, 24)) + } + } + + @Test + fun `처리 건수가 0 이 아니어도 예외 없이 정상 종료된다`() { + every { clock.now() } returns LocalDateTime.of(2026, 5, 31, 1, 0) + every { cleanupService.expirePendingBefore(any()) } returns 5 + + sut.cleanupExpiredNotifications() + + verify { cleanupService.expirePendingBefore(LocalDate.of(2026, 5, 24)) } + } +} diff --git a/src/test/kotlin/com/moa/service/notification/NotificationCleanupServiceTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationCleanupServiceTest.kt new file mode 100644 index 0000000..3a296b9 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationCleanupServiceTest.kt @@ -0,0 +1,65 @@ +package com.moa.service.notification + +import com.moa.repository.NotificationLogRepository +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.time.LocalDate + +/** + * `NotificationCleanupService` 단위 테스트. + * + * 책임: 벌크 EXPIRED 마킹을 수행하고, **실제 마킹된 건수**(쿼리 반환값)만큼 cleanup 전용 카운터를 올린 뒤 + * 그 건수를 반환한다. 메트릭이 "사전 집계값"이 아닌 "실제 UPDATE 건수"여야 dispatch catch-up 과의 경쟁에서 + * 0건 처리하고도 메트릭만 오르는 과계상을 막을 수 있다. + */ +class NotificationCleanupServiceTest { + + private val repository = mockk(relaxed = true) + private val meterRegistry = SimpleMeterRegistry() + private val sut = NotificationCleanupService(repository, meterRegistry) + + @Test + fun `실제 마킹된 건수만큼 cleanup 카운터를 올리고 그 건수를 반환한다`() { + every { repository.markExpiredBefore(threshold) } returns 3 + + val result = sut.expirePendingBefore(threshold) + + assertThat(result).isEqualTo(3) + assertThat(cleanupExpiredCount()).isEqualTo(3.0) + } + + @Test + fun `마킹된 건수가 0 이면 카운터를 올리지 않고 0 을 반환한다`() { + every { repository.markExpiredBefore(threshold) } returns 0 + + val result = sut.expirePendingBefore(threshold) + + assertThat(result).isEqualTo(0) + assertThat(meterRegistry.find(NotificationCleanupService.METRIC_CLEANUP_EXPIRED).counters()).isEmpty() + } + + @Test + fun `경쟁으로 실제 UPDATE 가 0건이면 메트릭도 증가하지 않는다`() { + // dispatch catch-up 이 같은 오래된 PENDING 을 먼저 EXPIRED 로 바꾼 경우: + // markExpiredBefore 는 0 을 돌려주고, 메트릭은 실제 건수 기반이라 과계상되지 않는다. + every { repository.markExpiredBefore(threshold) } returns 0 + + sut.expirePendingBefore(threshold) + + verify(exactly = 1) { repository.markExpiredBefore(threshold) } + assertThat(cleanupExpiredCount()).isEqualTo(0.0) + } + + private fun cleanupExpiredCount(): Double = + meterRegistry.find(NotificationCleanupService.METRIC_CLEANUP_EXPIRED) + .counter() + ?.count() ?: 0.0 + + companion object { + private val threshold: LocalDate = LocalDate.of(2026, 5, 24) + } +}