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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ enum class NotificationType(
) {
CLOCK_IN("출근 했어요!", "지금부터 급여가 쌓일 예정이에요."),
CLOCK_OUT("퇴근 했어요!", "오늘 ₩%s 벌었어요."),
PAYDAY("오늘은 월급날이에요!", "한 달 동안 고생 많으셨어요."),
PUBLIC_HOLIDAY("오늘은 공휴일이에요!", "오늘 하루 푹 쉬세요.");
PAYDAY("오늘은 월급날이에요!", "한 달 동안 고생 많으셨어요.");

fun getBody(vararg args: Any): String {
return String.format(body, *args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class NotificationDispatchService(
.tag("reason", reason)
.register(meterRegistry)

private fun messageFallbackCounter(type: String, reason: String): Counter = Counter.builder(METRIC_MESSAGE_FALLBACK)
.description("메시지 빌드 시 fallback으로 대체된 알림 수 (FCM 발송 성공 여부와 별개)")
.tag("notification_type", type)
.tag("reason", reason)
.register(meterRegistry)

Comment on lines +41 to +46
@Timed(value = "moa.notification.dispatch", histogram = true)
fun processNotifications(date: LocalDate, currentTime: LocalTime) {
val pendingLogs = notificationLogRepository
Expand Down Expand Up @@ -73,7 +79,13 @@ class NotificationDispatchService(
}
try {
val publicHolidays = holidaysByMonth[YearMonth.from(notification.scheduledDate)] ?: emptySet()
val data = notificationMessageBuilder.buildMessage(notification, publicHolidays).toData()
val result = notificationMessageBuilder.buildMessage(notification, publicHolidays)
when (result) {
is NotificationMessageBuildResult.Fallback ->
messageFallbackCounter(typeName, result.reason).increment()
is NotificationMessageBuildResult.Normal -> Unit
}
val data = result.message.toData()
Comment on lines +82 to +88
tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) }
} catch (e: Exception) {
notification.status = NotificationStatus.FAILED
Expand Down Expand Up @@ -105,6 +117,7 @@ class NotificationDispatchService(
companion object {
private const val METRIC_ATTEMPTS = "moa.notification.dispatch.attempts"
private const val METRIC_FAILED = "moa.notification.dispatch.failed"
private const val METRIC_MESSAGE_FALLBACK = "moa.notification.message.fallback"
private const val REASON_NO_TOKEN = "no_token"
private const val REASON_BUILD = "build"
private const val REASON_FCM = "fcm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.moa.service.notification

import com.moa.entity.notification.NotificationLog
import com.moa.entity.notification.NotificationType
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
Expand All @@ -13,40 +14,80 @@ import java.util.*
class NotificationMessageBuilder(
private val notificationEarningsService: NotificationEarningsService,
) {
private val log = LoggerFactory.getLogger(javaClass)

fun buildMessage(
notification: NotificationLog,
publicHolidays: Set<LocalDate>,
): NotificationMessage {
): NotificationMessageBuildResult {
val title = notification.notificationType.title
val body = when (notification.notificationType) {
NotificationType.CLOCK_IN -> notification.notificationType.body
NotificationType.CLOCK_OUT -> buildClockOutBody(notification, publicHolidays)
NotificationType.PAYDAY -> notification.notificationType.body
NotificationType.PUBLIC_HOLIDAY -> notification.notificationType.body
return when (notification.notificationType) {
NotificationType.CLOCK_IN, NotificationType.PAYDAY ->
NotificationMessageBuildResult.Normal(
NotificationMessage(title, notification.notificationType.body, notification.notificationType),
)

NotificationType.CLOCK_OUT -> buildClockOutResult(notification, publicHolidays)
}
return NotificationMessage(title, body, notification.notificationType)
}

private fun buildClockOutBody(
private fun buildClockOutResult(
notification: NotificationLog,
publicHolidays: Set<LocalDate>,
): String {
val earnings = notificationEarningsService.calculateTodayEarnings(
notification.memberId,
notification.scheduledDate,
publicHolidays,
)
): NotificationMessageBuildResult {
val earnings = try {
notificationEarningsService.calculateTodayEarnings(
notification.memberId,
notification.scheduledDate,
publicHolidays,
)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw e
} catch (e: Exception) {
log.error(
"Fallback message used — earnings calculation failed for notification {}, member {}",
notification.id,
notification.memberId,
e,
)
return fallbackResult(notification, REASON_EARNINGS_ERROR)
}
Comment on lines +38 to +55

if (earnings == BigDecimal.ZERO) {
return CLOCK_OUT_FALLBACK_BODY
log.warn(
"Fallback message used — zero earnings for notification {}, member {}",
notification.id,
notification.memberId,
Comment on lines 57 to +61
)
return fallbackResult(notification, REASON_ZERO_EARNINGS)
}

val formatted = NumberFormat.getNumberInstance(Locale.KOREA)
.format(earnings.setScale(0, RoundingMode.HALF_UP))
return notification.notificationType.getBody(formatted)
return NotificationMessageBuildResult.Normal(
NotificationMessage(
notification.notificationType.title,
notification.notificationType.getBody(formatted),
notification.notificationType,
),
)
}

private fun fallbackResult(notification: NotificationLog, reason: String): NotificationMessageBuildResult.Fallback =
NotificationMessageBuildResult.Fallback(
message = NotificationMessage(
notification.notificationType.title,
CLOCK_OUT_FALLBACK_BODY,
notification.notificationType,
),
reason = reason,
)

companion object {
private const val CLOCK_OUT_FALLBACK_BODY = "오늘도 수고하셨어요!"
const val REASON_ZERO_EARNINGS = "zero_earnings"
const val REASON_EARNINGS_ERROR = "earnings_error"
}
}

Expand All @@ -57,3 +98,16 @@ data class NotificationMessage(val title: String, val body: String, val type: No
"type" to type.name,
)
}

sealed class NotificationMessageBuildResult {
abstract val message: NotificationMessage

data class Normal(
override val message: NotificationMessage,
) : NotificationMessageBuildResult()

data class Fallback(
override val message: NotificationMessage,
val reason: String,
) : NotificationMessageBuildResult()
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ class NotificationSyncService(
}

NotificationType.PAYDAY -> Unit
NotificationType.PUBLIC_HOLIDAY -> Unit
}
}
log.info("Synced pending notifications for member {} on {}", memberId, date)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@ import java.time.LocalDate
import java.time.LocalTime

/**
* Step 5 — 비즈니스 메트릭 단위 테스트.
*
* - Classicist 스타일: out-of-process 의존성(Repository, FcmService, MessageBuilder)만 mock,
* `SimpleMeterRegistry`는 실물 사용.
* - 단언은 메트릭 레지스트리의 최종 상태로만.
* 비즈니스 메트릭 단위 테스트.
*/
class NotificationDispatchServiceMetricsTest {

Expand Down Expand Up @@ -63,7 +59,9 @@ class NotificationDispatchServiceMetricsTest {
private val messageBuilder = mockk<NotificationMessageBuilder>().apply {
every { buildMessage(any(), any()) } answers {
val n = firstArg<NotificationLog>()
NotificationMessage("title", "body", n.notificationType)
NotificationMessageBuildResult.Normal(
NotificationMessage("title", "body", n.notificationType),
)
}
}
private val registry = SimpleMeterRegistry()
Expand Down Expand Up @@ -111,6 +109,29 @@ class NotificationDispatchServiceMetricsTest {
assertThat(failed?.count()).isEqualTo(1.0)
}

@Test
fun `fallback 메시지로 빌드된 알림은 message_fallback 카운터를 증가시키고 정상 발송된다`() {
notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_OUT)
fcmTokens += FcmToken(memberId = 1L, token = "tok-1")
every { messageBuilder.buildMessage(any(), any()) } answers {
val n = firstArg<NotificationLog>()
NotificationMessageBuildResult.Fallback(
NotificationMessage("title", "오늘도 수고하셨어요!", n.notificationType),
reason = NotificationMessageBuilder.REASON_EARNINGS_ERROR,
)
}

sut.processNotifications(TODAY, NOON)

val fallback = registry.find("moa.notification.message.fallback")
.tag("notification_type", "CLOCK_OUT").tag("reason", "earnings_error").counter()
val failed = registry.find("moa.notification.dispatch.failed")
.tag("notification_type", "CLOCK_OUT").counter()
assertThat(fallback?.count()).isEqualTo(1.0)
assertThat(failed).isNull()
assertThat(notificationLogs[0].status).isEqualTo(NotificationStatus.SENT)
}

@Test
fun `FCM 결과가 false인 알림은 failed 카운터를 reason=fcm 태그로 증가시킨다`() {
notificationLogs += notificationLog(memberId = 1L, type = NotificationType.PAYDAY)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.moa.service.notification

import com.moa.common.exception.NotFoundException
import com.moa.entity.notification.NotificationLog
import com.moa.entity.notification.NotificationType
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalTime

class NotificationMessageBuilderTest {

private val earningsService = mockk<NotificationEarningsService>()
private val sut = NotificationMessageBuilder(earningsService)

@Test
fun `CLOCK_IN 알림은 정적 본문을 반환하고 Normal 결과를 만든다`() {
val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_IN), emptySet())

assertThat(result).isInstanceOf(NotificationMessageBuildResult.Normal::class.java)
assertThat(result.message.body).isEqualTo(NotificationType.CLOCK_IN.body)
}

@Test
fun `PAYDAY 알림은 정적 본문을 반환하고 Normal 결과를 만든다`() {
val result = sut.buildMessage(notificationLog(NotificationType.PAYDAY), emptySet())

assertThat(result).isInstanceOf(NotificationMessageBuildResult.Normal::class.java)
assertThat(result.message.body).isEqualTo(NotificationType.PAYDAY.body)
}

@Test
fun `CLOCK_OUT earnings 계산 성공 시 포맷된 금액 본문의 Normal 결과를 만든다`() {
every { earningsService.calculateTodayEarnings(any(), any(), any()) } returns BigDecimal("123456")

val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet())

assertThat(result).isInstanceOf(NotificationMessageBuildResult.Normal::class.java)
assertThat(result.message.body).contains("123,456")
}

@Test
fun `CLOCK_OUT earnings 가 0원이면 zero_earnings reason의 Fallback 결과를 만든다`() {
every { earningsService.calculateTodayEarnings(any(), any(), any()) } returns BigDecimal.ZERO

val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet())

assertThat(result).isInstanceOf(NotificationMessageBuildResult.Fallback::class.java)
val fallback = result as NotificationMessageBuildResult.Fallback
assertThat(fallback.reason).isEqualTo(NotificationMessageBuilder.REASON_ZERO_EARNINGS)
assertThat(fallback.message.body).isEqualTo("오늘도 수고하셨어요!")
}

@Test
fun `CLOCK_OUT earnings 계산이 예외를 던지면 earnings_error reason의 Fallback 결과를 만든다`() {
every { earningsService.calculateTodayEarnings(any(), any(), any()) } throws NotFoundException()

val result = sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet())

assertThat(result).isInstanceOf(NotificationMessageBuildResult.Fallback::class.java)
val fallback = result as NotificationMessageBuildResult.Fallback
assertThat(fallback.reason).isEqualTo(NotificationMessageBuilder.REASON_EARNINGS_ERROR)
assertThat(fallback.message.body).isEqualTo("오늘도 수고하셨어요!")
}

@Test
fun `CLOCK_OUT earnings 계산이 InterruptedException을 던지면 그대로 전파하고 인터럽트 플래그를 복원한다`() {
every { earningsService.calculateTodayEarnings(any(), any(), any()) } throws InterruptedException("cancel")

try {
assertThat(Thread.interrupted()).isFalse() // 시작 시 클린한 상태 보장
org.junit.jupiter.api.assertThrows<InterruptedException> {
sut.buildMessage(notificationLog(NotificationType.CLOCK_OUT), emptySet())
}
assertThat(Thread.currentThread().isInterrupted).isTrue()
} finally {
Thread.interrupted() // 다른 테스트에 영향 안 가도록 클리어
}
}

private fun notificationLog(type: NotificationType) = NotificationLog(
memberId = 1L,
notificationType = type,
scheduledDate = LocalDate.of(2026, 5, 26),
scheduledTime = LocalTime.NOON,
)
}
Loading