Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/main/kotlin/com/moa/repository/NotificationLogRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,4 +54,18 @@ interface NotificationLogRepository : JpaRepository<NotificationLog, Long> {
statuses: Collection<NotificationStatus>,
memberIds: Collection<Long>,
): List<Long>

/**
* 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
}
26 changes: 21 additions & 5 deletions src/main/kotlin/com/moa/service/FcmService.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -73,27 +78,38 @@ 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)
}
}

companion object {
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) + "***"
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
37 changes: 37 additions & 0 deletions src/test/kotlin/com/moa/service/FcmServiceTokenMaskTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationCleanupService>()
private val clock = mockk<NotificationScheduleClock>()
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)) }
}
}
Loading
Loading