From 9cbeb5872211f3e5f61fb2e60bbb1e90b4ca0895 Mon Sep 17 00:00:00 2001 From: hod Date: Sun, 3 May 2026 20:00:13 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20TimedAspect=20bean=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/moa/common/config/MetricsConfig.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/kotlin/com/moa/common/config/MetricsConfig.kt b/src/main/kotlin/com/moa/common/config/MetricsConfig.kt index 20ed987..caf2fb5 100644 --- a/src/main/kotlin/com/moa/common/config/MetricsConfig.kt +++ b/src/main/kotlin/com/moa/common/config/MetricsConfig.kt @@ -1,12 +1,15 @@ package com.moa.common.config +import io.micrometer.core.aop.TimedAspect import io.micrometer.core.instrument.MeterRegistry import org.springframework.beans.factory.annotation.Value import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy @Configuration +@EnableAspectJAutoProxy(proxyTargetClass = true) class MetricsConfig( @Value("\${spring.application.name}") private val appName: String, @Value("\${app.deploy-color}") private val deployColor: String, @@ -20,4 +23,7 @@ class MetricsConfig( "app_version", appVersion, ) } + + @Bean + fun timedAspect(registry: MeterRegistry) = TimedAspect(registry) } From eeef80b9e1d3df1d18aaaaf0abcc195e4e78ba79 Mon Sep 17 00:00:00 2001 From: hod Date: Mon, 4 May 2026 11:44:03 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20@Timed=20=EB=B0=8F=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B9=B4=EC=9A=B4=ED=84=B0=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationDispatchService.kt | 41 ++++- .../NotificationDispatchServiceMetricsTest.kt | 142 ++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index fe0f6ac..c821224 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -7,6 +7,9 @@ import com.moa.repository.NotificationLogRepository import com.moa.service.FcmService import com.moa.service.PublicHolidayService import com.moa.service.dto.FcmRequest +import io.micrometer.core.annotation.Timed +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.LocalDate @@ -20,9 +23,22 @@ class NotificationDispatchService( private val fcmService: FcmService, private val notificationMessageBuilder: NotificationMessageBuilder, private val publicHolidayService: PublicHolidayService, + private val meterRegistry: MeterRegistry, ) { private val log = LoggerFactory.getLogger(javaClass) + private fun pendingCounter(type: String): Counter = Counter.builder(METRIC_PENDING) + .description("발송 시도된 알림 수") + .tag("notification_type", type) + .register(meterRegistry) + + private fun failedCounter(type: String, reason: String): Counter = Counter.builder(METRIC_FAILED) + .description("발송 실패한 알림 수") + .tag("notification_type", type) + .tag("reason", reason) + .register(meterRegistry) + + @Timed(value = "moa.notification.dispatch", percentiles = [0.5, 0.95, 0.99]) fun processNotifications(date: LocalDate, currentTime: LocalTime) { val pendingLogs = notificationLogRepository .findAllByScheduledDateAndScheduledTimeLessThanEqualAndStatus( @@ -33,6 +49,8 @@ class NotificationDispatchService( if (pendingLogs.isEmpty()) return log.info("Dispatching {} pending notifications", pendingLogs.size) + pendingLogs.groupingBy { it.notificationType.name }.eachCount() + .forEach { (type, n) -> pendingCounter(type).increment(n.toDouble()) } val memberIds = pendingLogs.map { it.memberId }.distinct() val tokensByMemberId = fcmTokenRepository.findAllByMemberIdIn(memberIds) @@ -45,9 +63,11 @@ class NotificationDispatchService( val dispatchItems = mutableListOf() for (notification in pendingLogs) { + val typeName = notification.notificationType.name val tokens = tokensByMemberId[notification.memberId].orEmpty() if (tokens.isEmpty()) { notification.status = NotificationStatus.FAILED + failedCounter(typeName, REASON_NO_TOKEN).increment() log.warn("No FCM tokens for member {}, marking as FAILED", notification.memberId) continue } @@ -57,7 +77,13 @@ class NotificationDispatchService( tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) } } catch (e: Exception) { notification.status = NotificationStatus.FAILED - log.error("Failed to build message for notification {}, member {}", notification.id, notification.memberId, e) + failedCounter(typeName, REASON_BUILD).increment() + log.error( + "Failed to build message for notification {}, member {}", + notification.id, + notification.memberId, + e + ) } } @@ -67,11 +93,22 @@ class NotificationDispatchService( if (success) dispatchItems[i].notification.status = NotificationStatus.SENT } pendingLogs.filter { it.status == NotificationStatus.PENDING } - .forEach { it.status = NotificationStatus.FAILED } + .forEach { + it.status = NotificationStatus.FAILED + failedCounter(it.notificationType.name, REASON_FCM).increment() + } } notificationLogRepository.saveAll(pendingLogs) } + + companion object { + private const val METRIC_PENDING = "moa.notification.dispatch.pending" + private const val METRIC_FAILED = "moa.notification.dispatch.failed" + private const val REASON_NO_TOKEN = "no_token" + private const val REASON_BUILD = "build" + private const val REASON_FCM = "fcm" + } } private data class DispatchItem( diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt new file mode 100644 index 0000000..6711d4a --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt @@ -0,0 +1,142 @@ +package com.moa.service.notification + +import com.moa.entity.FcmToken +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.FcmTokenRepository +import com.moa.repository.NotificationLogRepository +import com.moa.service.FcmService +import com.moa.service.PublicHolidayService +import com.moa.service.dto.FcmRequest +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime + +/** + * Step 5 — 비즈니스 메트릭 단위 테스트. + * + * - Classicist 스타일: out-of-process 의존성(Repository, FcmService, MessageBuilder)만 mock, + * `SimpleMeterRegistry`는 실물 사용. + * - 단언은 메트릭 레지스트리의 최종 상태로만. + */ +class NotificationDispatchServiceMetricsTest { + + private val notificationLogs = mutableListOf() + private val fcmTokens = mutableListOf() + + private val notificationLogRepo = mockk().apply { + every { + findAllByScheduledDateAndScheduledTimeLessThanEqualAndStatus(any(), any(), any()) + } answers { + val date = firstArg() + val time = secondArg() + val status = thirdArg() + notificationLogs.filter { + it.scheduledDate == date && !it.scheduledTime.isAfter(time) && it.status == status + } + } + every { saveAll(any>()) } answers { + firstArg>().toList() + } + } + private val fcmTokenRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + fcmTokens.filter { it.memberId in ids } + } + } + private val fcmService = mockk().apply { + every { sendEach(any()) } answers { + val reqs = firstArg>() + List(reqs.size) { true } + } + } + private val publicHolidayService = mockk().apply { + every { getHolidayDatesForMonth(any(), any()) } returns emptySet() + } + private val messageBuilder = mockk().apply { + every { buildMessage(any(), any()) } answers { + val n = firstArg() + NotificationMessage("title", "body", n.notificationType) + } + } + private val registry = SimpleMeterRegistry() + + private val sut = NotificationDispatchService( + notificationLogRepo, + fcmTokenRepo, + fcmService, + messageBuilder, + publicHolidayService, + registry, + ) + + @BeforeEach + fun reset() { + notificationLogs.clear() + fcmTokens.clear() + } + + @Test + fun `토큰 없는 회원의 알림은 failed_no_token 카운터를 증가시킨다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_IN) + + sut.processNotifications(TODAY, NOON) + + val pending = registry.find("moa.notification.dispatch.pending") + .tag("notification_type", "CLOCK_IN").counter() + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_IN").tag("reason", "no_token").counter() + + assertThat(pending?.count()).isEqualTo(1.0) + assertThat(failed?.count()).isEqualTo(1.0) + } + + @Test + fun `메시지 빌드 실패는 failed 카운터를 reason=build 태그로 증가시킨다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.CLOCK_OUT) + fcmTokens += FcmToken(memberId = 1L, token = "tok-1") + every { messageBuilder.buildMessage(any(), any()) } throws IllegalStateException("boom") + + sut.processNotifications(TODAY, NOON) + + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_OUT").tag("reason", "build").counter() + assertThat(failed?.count()).isEqualTo(1.0) + } + + @Test + fun `FCM 결과가 false인 알림은 failed 카운터를 reason=fcm 태그로 증가시킨다`() { + notificationLogs += notificationLog(memberId = 1L, type = NotificationType.PAYDAY) + fcmTokens += FcmToken(memberId = 1L, token = "tok-1") + every { fcmService.sendEach(any()) } answers { + val reqs = firstArg>() + List(reqs.size) { false } + } + + sut.processNotifications(TODAY, NOON) + + val failed = registry.find("moa.notification.dispatch.failed") + .tag("notification_type", "PAYDAY").tag("reason", "fcm").counter() + assertThat(failed?.count()).isEqualTo(1.0) + } + + private fun notificationLog(memberId: Long, type: NotificationType) = NotificationLog( + memberId = memberId, + notificationType = type, + scheduledDate = TODAY, + scheduledTime = NOON.minusHours(1), + status = NotificationStatus.PENDING, + ) + + companion object { + private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) + private val NOON: LocalTime = LocalTime.NOON + } +} From 706833e6aeed85e3aea7caab1e255f449f1ec542 Mon Sep 17 00:00:00 2001 From: hod Date: Mon, 4 May 2026 12:07:06 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20FCM=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/moa/service/FcmService.kt | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/moa/service/FcmService.kt b/src/main/kotlin/com/moa/service/FcmService.kt index 379824a..8085065 100644 --- a/src/main/kotlin/com/moa/service/FcmService.kt +++ b/src/main/kotlin/com/moa/service/FcmService.kt @@ -3,15 +3,32 @@ package com.moa.service import com.google.firebase.messaging.* import com.moa.repository.FcmTokenRepository import com.moa.service.dto.FcmRequest +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @Service class FcmService( private val fcmTokenRepository: FcmTokenRepository, + private val meterRegistry: MeterRegistry, ) { private val log = LoggerFactory.getLogger(javaClass) + private val sendSuccess: Counter = Counter.builder(METRIC_SEND) + .description("FCM 메시지 단위 결과") + .tag("result", "success") + .register(meterRegistry) + private val sendFailure: Counter = Counter.builder(METRIC_SEND) + .description("FCM 메시지 단위 결과") + .tag("result", "failure") + .register(meterRegistry) + + private fun tokenInvalidated(errorCode: String): Counter = Counter.builder(METRIC_TOKEN_INVALIDATED) + .description("FCM 에러로 자동 삭제된 토큰 수") + .tag("error_code", errorCode) + .register(meterRegistry) + fun sendEach(requests: List): List { if (requests.isEmpty()) return emptyList() val results = ArrayList(requests.size) @@ -20,13 +37,17 @@ class FcmService( val messages = batch.map { buildMessage(it.token, it.data) } val response = FirebaseMessaging.getInstance().sendEach(messages) response.responses.forEachIndexed { i, sendResponse -> - if (!sendResponse.isSuccessful) { + if (sendResponse.isSuccessful) { + sendSuccess.increment() + } else { + sendFailure.increment() handleFcmException(sendResponse.exception, batch[i].token) } results.add(sendResponse.isSuccessful) } } catch (ex: Exception) { log.error("FCM batch send failed for {} messages", batch.size, ex) + sendFailure.increment(batch.size.toDouble()) repeat(batch.size) { results.add(false) } } } @@ -56,17 +77,23 @@ class FcmService( return } when (fcmEx.messagingErrorCode) { - MessagingErrorCode.UNREGISTERED, + MessagingErrorCode.UNREGISTERED -> { + log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) + fcmTokenRepository.deleteByToken(token) + tokenInvalidated("unregistered").increment() + } MessagingErrorCode.INVALID_ARGUMENT -> { log.warn("유효하지 않은 FCM 토큰 삭제: {}", token) fcmTokenRepository.deleteByToken(token) + tokenInvalidated("invalid_argument").increment() } - else -> log.error("FCM 전송 실패: {}", 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" } } From ed668cca241add1ad5bfa98e0e46922561302ce6 Mon Sep 17 00:00:00 2001 From: hod Date: Mon, 4 May 2026 12:25:27 +0900 Subject: [PATCH 4/6] =?UTF-8?q?test:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20e2e=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20+=20=EA=B3=B5=ED=9C=B4?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=A8=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationBatchServiceHolidayTest.kt | 350 ++++++++++++++++++ .../NotificationDispatchIntegrationTest.kt | 70 ++++ .../notification/PrometheusEndpointE2eTest.kt | 93 +++++ 3 files changed, 513 insertions(+) create mode 100644 src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt create mode 100644 src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt create mode 100644 src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt diff --git a/src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt new file mode 100644 index 0000000..5c258f2 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationBatchServiceHolidayTest.kt @@ -0,0 +1,350 @@ +package com.moa.service.notification + +import com.moa.entity.DailyWorkSchedule +import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.FcmToken +import com.moa.entity.Term +import com.moa.entity.TermAgreement +import com.moa.entity.WorkPolicyVersion +import com.moa.entity.Workday +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.DailyWorkScheduleRepository +import com.moa.repository.FcmTokenRepository +import com.moa.repository.NotificationLogRepository +import com.moa.repository.NotificationSettingRepository +import com.moa.repository.PublicHolidayRepository +import com.moa.repository.TermAgreementRepository +import com.moa.repository.TermRepository +import com.moa.repository.WorkPolicyVersionRepository +import com.moa.service.PublicHolidayService +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalTime + +/** + * 고전파(Classicist) 스타일 단위 테스트. + * + * - 모든 **out-of-process** 의존성(JPA Repository)만 Fake로 대체한다. + * - `PublicHolidayService`, `NotificationEligibilityService` 는 **실물**을 사용한다. + * - 검증은 오직 최종 상태(`notificationLogs` 리스트의 내용)로만 한다 — `verify` 금지. + * + * Fake 저장소(`holidayCalendar`, `workPolicies`, …)가 프로덕션의 MySQL 테이블 역할을 한다. + */ +class NotificationBatchServiceHolidayTest { + + private val holidayCalendar = mutableSetOf() + private val workPolicies = mutableListOf() + private val notificationLogs = mutableListOf() + private val terms = mutableListOf() + private val agreements = mutableListOf() + private val settings = mutableListOf() + private val fcmTokens = mutableListOf() + private val dailySchedules = mutableListOf() + + // ─── Repository = 자료구조의 얇은 어댑터 (Fake 역할) ────────────────────── + private val publicHolidayRepo = mockk().apply { + every { existsByDate(any()) } answers { firstArg() in holidayCalendar } + } + private val workPolicyRepo = mockk().apply { + every { findLatestEffectivePoliciesPerMember(any()) } answers { + val date = firstArg() + workPolicies + .filter { !it.effectiveFrom.isAfter(date) } + .groupBy { it.memberId } + .map { (_, versions) -> versions.maxBy { v -> v.effectiveFrom } } + } + } + private val notificationLogRepo = mockk().apply { + every { saveAll(any>()) } answers { + val added = firstArg>().toList() + notificationLogs += added + added + } + every { + findMemberIdsByScheduledDateAndNotificationTypeAndMemberIdIn(any(), any(), any()) + } answers { + val date = firstArg() + val type = secondArg() + val ids = thirdArg>() + notificationLogs + .filter { it.scheduledDate == date && it.notificationType == type && it.memberId in ids } + .map { it.memberId } + .distinct() + } + } + private val termRepo = mockk().apply { + every { findAll() } answers { terms.toList() } + } + private val agreementRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + agreements.filter { it.memberId in ids } + } + } + private val settingRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + settings.filter { it.memberId in ids } + } + } + private val fcmTokenRepo = mockk().apply { + every { findAllByMemberIdIn(any()) } answers { + val ids = firstArg>() + fcmTokens.filter { it.memberId in ids } + } + } + private val dailyScheduleRepo = mockk().apply { + every { findAllByMemberIdInAndDate(any(), any()) } answers { + val ids = firstArg>() + val date = secondArg() + dailySchedules.filter { it.memberId in ids && it.date == date } + } + } + + // ─── 실물 도메인 서비스 ─────────────────────────────────────────────────── + private val publicHolidayService = PublicHolidayService(publicHolidayRepo) + private val eligibilityService = NotificationEligibilityService( + termRepo, agreementRepo, settingRepo, fcmTokenRepo, dailyScheduleRepo, + ) + private val sut = NotificationBatchService( + workPolicyRepo, notificationLogRepo, eligibilityService, publicHolidayService, + ) + + @BeforeEach + fun `필수 약관 한 건을 공리로 등록해둔다`() { + terms += Term( + code = TOS_CODE, + title = "서비스 이용약관", + required = true, + contentUrl = "https://moa.example/tos", + sortOrder = 1, + ) + } + + // ═════════════════════════════════════════════════════════════════════════ + // 1. 행복 경로 + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `공휴일에 세팅·약관·토큰을 모두 갖춘 회원은 09시 PUBLIC_HOLIDAY 로그를 PENDING 상태로 받는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + val log = notificationLogs.first() + assertThat(log.memberId).isEqualTo(1L) + assertThat(log.notificationType).isEqualTo(NotificationType.PUBLIC_HOLIDAY) + assertThat(log.scheduledDate).isEqualTo(신정) + assertThat(log.scheduledTime).isEqualTo(LocalTime.of(9, 0)) + assertThat(log.status).isEqualTo(NotificationStatus.PENDING) + } + + @Test + fun `적격 회원이 여러 명이면 각자 한 건씩 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + 회원_등록(id = 2L) + 회원_등록(id = 3L) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs.map { it.memberId }).containsExactlyInAnyOrder(1L, 2L, 3L) + assertThat(notificationLogs).allMatch { it.notificationType == NotificationType.PUBLIC_HOLIDAY } + } + + @Test + fun `비공휴일에는 PUBLIC_HOLIDAY 로그가 생성되지 않는다`() { + val 평일 = LocalDate.of(2026, 3, 10) + 회원_등록(id = 1L, 정책기준일 = 평일) + + sut.generateNotificationsForDate(평일) + + assertThat(notificationLogs.none { it.notificationType == NotificationType.PUBLIC_HOLIDAY }).isTrue() + } + + // ═════════════════════════════════════════════════════════════════════════ + // 2. 제외 조건 (알림을 받지 말아야 하는 회원) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `공휴일이어도 WORK 알림을 꺼둔 회원에게는 로그가 생성되지 않는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L, 알림_켜짐 = false) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + @Test + fun `공휴일이어도 필수 약관에 동의하지 않은 회원에게는 로그가 생성되지 않는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + agreements.clear() // 약관 동의 철회 + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + @Test + fun `공휴일이어도 FCM 토큰이 없는 회원에게는 로그가 생성되지 않는다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + fcmTokens.clear() + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + @Test + fun `공휴일이어도 정책의 effectiveFrom이 미래인 회원은 알림을 받지 않는다`() { + 공휴일로_지정(신정) + workPolicies += WorkPolicyVersion( + memberId = 1L, + effectiveFrom = 신정.plusDays(1), // 정책이 내일부터 유효 + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(18, 0), + workdays = Workday.WEEKDAYS.toMutableSet(), + ) + agreements += TermAgreement(memberId = 1L, termCode = TOS_CODE, agreed = true) + settings += NotificationSetting(memberId = 1L) + fcmTokens += FcmToken(memberId = 1L, token = "t-1") + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).isEmpty() + } + + // ═════════════════════════════════════════════════════════════════════════ + // 3. 멱등성 (배치가 두 번 돌아도 중복 생성되지 않는다) + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `같은 공휴일에 두 번 실행해도 회원당 PUBLIC_HOLIDAY 로그는 1건만 존재한다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + + sut.generateNotificationsForDate(신정) + sut.generateNotificationsForDate(신정) + + assertThat( + notificationLogs.count { it.memberId == 1L && it.notificationType == NotificationType.PUBLIC_HOLIDAY }, + ).isEqualTo(1) + } + + @Test + fun `공휴일에 일부 회원만 이미 로그가 있으면 나머지 회원에게만 신규 로그가 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + 회원_등록(id = 2L) + notificationLogs += NotificationLog( + memberId = 1L, + notificationType = NotificationType.PUBLIC_HOLIDAY, + scheduledDate = 신정, + scheduledTime = LocalTime.of(9, 0), + ) + + sut.generateNotificationsForDate(신정) + + val holidayLogs = notificationLogs.filter { it.notificationType == NotificationType.PUBLIC_HOLIDAY } + assertThat(holidayLogs.map { it.memberId }).containsExactlyInAnyOrder(1L, 2L) + } + + // ═════════════════════════════════════════════════════════════════════════ + // 4. 현재 구현의 의도를 박제하는 "문서화 테스트" + // ═════════════════════════════════════════════════════════════════════════ + + @Test + fun `NotificationSetting row가 아예 없으면 기본 활성화로 간주되어 공휴일 알림이 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + settings.clear() // isSettingEnabled 의 "null != false → true" 분기 검증 + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + } + + @Test + fun `공휴일이 정책상 비근무 요일이어도 PUBLIC_HOLIDAY 로그가 생성된다`() { + // PUBLIC_HOLIDAY 경로는 workdays 를 보지 않는다 (CLOCK_IN 경로와의 의도된 차이) + 공휴일로_지정(신정) + 회원_등록(id = 1L, 근무요일 = setOf(Workday.MON)) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + } + + @Test + fun `공휴일에 휴가가 등록되어 있어도 PUBLIC_HOLIDAY 로그가 생성된다`() { + // PUBLIC_HOLIDAY 경로는 shouldSkipNotification 을 호출하지 않는다 (CLOCK_IN 경로와의 의도된 차이) + 공휴일로_지정(신정) + 회원_등록(id = 1L) + dailySchedules += DailyWorkSchedule( + memberId = 1L, + date = 신정, + type = DailyWorkScheduleType.VACATION, + clockInTime = LocalTime.of(10, 0), + clockOutTime = LocalTime.of(16, 0), + ) + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + assertThat(notificationLogs.first().notificationType).isEqualTo(NotificationType.PUBLIC_HOLIDAY) + } + + @Test + fun `FCM 토큰이 여러 개여도 회원당 로그는 1건만 생성된다`() { + 공휴일로_지정(신정) + 회원_등록(id = 1L) + fcmTokens += FcmToken(memberId = 1L, token = "token-1-second-device") + + sut.generateNotificationsForDate(신정) + + assertThat(notificationLogs).hasSize(1) + } + + // ═════════════════════════════════════════════════════════════════════════ + // 도메인 언어 DSL + // ═════════════════════════════════════════════════════════════════════════ + + private fun 공휴일로_지정(date: LocalDate) { + holidayCalendar += date + } + + private fun 회원_등록( + id: Long, + 알림_켜짐: Boolean = true, + 정책기준일: LocalDate = 신정, + 근무요일: Set = Workday.WEEKDAYS, + ) { + workPolicies += WorkPolicyVersion( + memberId = id, + effectiveFrom = 정책기준일.minusDays(30), + clockInTime = LocalTime.of(9, 0), + clockOutTime = LocalTime.of(18, 0), + workdays = 근무요일.toMutableSet(), + ) + agreements += TermAgreement(memberId = id, termCode = TOS_CODE, agreed = true) + settings += NotificationSetting(memberId = id, workNotificationEnabled = 알림_켜짐) + fcmTokens += FcmToken(memberId = id, token = "token-$id") + } + + companion object { + private const val TOS_CODE = "TERMS_OF_SERVICE" + private val 신정: LocalDate = LocalDate.of(2026, 1, 1) + } +} diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt new file mode 100644 index 0000000..e4ceb66 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt @@ -0,0 +1,70 @@ +package com.moa.service.notification + +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.NotificationLogRepository +import io.micrometer.core.instrument.MeterRegistry +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import java.time.LocalDate +import java.time.LocalTime + +/** + * 통합 테스트 + * + * 단위 테스트(`NotificationDispatchServiceMetricsTest`)와 비교: + * - 단위: `SimpleMeterRegistry` + MockK Fake → 빠르고 격리됨, 그러나 Spring 자동 구성을 + * 거치지 않아 common tag(`application/deploy_color/app_version`)가 부착되지 않음. + * - 통합: 실제 Spring 컨텍스트 + 실제 `PrometheusMeterRegistry` + 실제 H2 → 자동 구성된 + * `MeterRegistryCustomizer`까지 포함해 메트릭이 운영에서 보일 모습 그대로 검증. + * + */ +@SpringBootTest +class NotificationDispatchIntegrationTest @Autowired constructor( + private val notificationDispatchService: NotificationDispatchService, + private val notificationLogRepository: NotificationLogRepository, + private val meterRegistry: MeterRegistry, +) { + + @AfterEach + fun cleanup() { + notificationLogRepository.deleteAll() + } + + @Test + fun `통합 - dispatch 호출 후 PrometheusMeterRegistry에 비즈니스 메트릭과 common tag가 함께 기록된다`() { + notificationLogRepository.save( + NotificationLog( + memberId = 1L, + notificationType = NotificationType.CLOCK_IN, + scheduledDate = TODAY, + scheduledTime = NOON.minusHours(1), + status = NotificationStatus.PENDING, + ) + ) + + notificationDispatchService.processNotifications(TODAY, NOON) + + val pending = meterRegistry.find("moa.notification.dispatch.pending") + .tag("notification_type", "CLOCK_IN").counter() + val failed = meterRegistry.find("moa.notification.dispatch.failed") + .tag("notification_type", "CLOCK_IN").tag("reason", "no_token").counter() + + assertThat(pending?.count()).isEqualTo(1.0) + assertThat(failed?.count()).isEqualTo(1.0) + + // common tag 부착 검증 — `MetricsConfig`의 `MeterRegistryCustomizer`가 + // 모든 신규 메트릭에 application/deploy_color/app_version 을 자동으로 붙이는지 확인. + val tagKeys = pending?.id?.tags?.map { it.key }.orEmpty() + assertThat(tagKeys).contains("application", "deploy_color", "app_version", "notification_type") + } + + companion object { + private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) + private val NOON: LocalTime = LocalTime.NOON + } +} diff --git a/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt new file mode 100644 index 0000000..af2fff7 --- /dev/null +++ b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt @@ -0,0 +1,93 @@ +package com.moa.service.notification + +import com.moa.entity.notification.NotificationLog +import com.moa.entity.notification.NotificationStatus +import com.moa.entity.notification.NotificationType +import com.moa.repository.NotificationLogRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.web.client.RestClient +import java.time.LocalDate +import java.time.LocalTime + +/** + * End-to-End 테스트. + * + * 통합 테스트(`NotificationDispatchIntegrationTest`)와의 차이: + * - 통합: `MeterRegistry` 객체를 직접 주입받아 메트릭 값을 in-process로 단언. + * - E2E: 실제 HTTP 서버(Random Port)를 띄우고 `TestRestTemplate`으로 `/actuator/prometheus` + * 를 호출해 **응답 본문 텍스트**를 검증. 운영에서 Grafana Alloy가 scrape하는 그대로의 + * 계약(노출 형식 + Prometheus exposition format + 라벨 직렬화)을 회귀로 잠근다. + * + * 즉, 단위→통합→E2E 로 갈수록: + * - 격리도는 낮아지고 + * - 시스템 경계까지 검증 범위가 늘어나고 + * - 실행 비용은 커진다. + * 그래서 **단위는 분기마다, 통합은 핵심 흐름당 1개, E2E는 외부 계약당 1개**가 일반적인 비율. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "management.endpoints.web.exposure.include=health,info,metrics,prometheus", + "management.endpoints.access.default=read_only", + "management.prometheus.metrics.export.enabled=true", + ], +) +class PrometheusEndpointE2eTest @Autowired constructor( + @LocalServerPort private val port: Int, + private val notificationDispatchService: NotificationDispatchService, + private val notificationLogRepository: NotificationLogRepository, +) { + + private val httpClient = RestClient.create() + + @AfterEach + fun cleanup() { + notificationLogRepository.deleteAll() + } + + @Test + fun `E2E - dispatch 후 actuator prometheus 엔드포인트가 신규 메트릭을 텍스트로 노출한다`() { + notificationLogRepository.save( + NotificationLog( + memberId = 1L, + notificationType = NotificationType.CLOCK_IN, + scheduledDate = TODAY, + scheduledTime = NOON.minusHours(1), + status = NotificationStatus.PENDING, + ) + ) + notificationDispatchService.processNotifications(TODAY, NOON) + + val body = httpClient.get() + .uri("http://localhost:$port/actuator/prometheus") + .retrieve() + .body(String::class.java) ?: "" + + // 1) Counter 라인 노출 (Prometheus는 점을 underscore로 변환하고 카운터에 _total 접미사 부착) + assertThat(body).contains("moa_notification_dispatch_pending_total") + assertThat(body).contains("moa_notification_dispatch_failed_total") + + // 2) Timer (Spring Boot는 summary 형태 + p50/p95/p99 quantile) + assertThat(body).contains("moa_notification_dispatch_seconds") + assertThat(body).contains("quantile=\"0.95\"") + + // 3) 분기별 태그가 그대로 직렬화되어 보이는지 (notification_type, reason) + assertThat(body).contains("notification_type=\"CLOCK_IN\"") + assertThat(body).contains("reason=\"no_token\"") + + // 4) common tag — 운영 PromQL의 `deploy_color`, `app_version` 라벨 분리에 필수 + assertThat(body).contains("application=\"moa\"") + assertThat(body).contains("deploy_color=") + assertThat(body).contains("app_version=") + } + + companion object { + private val TODAY: LocalDate = LocalDate.of(2026, 5, 1) + private val NOON: LocalTime = LocalTime.NOON + } +} From 2afaf871d104e48f144b01c53fafac52a4a73165 Mon Sep 17 00:00:00 2001 From: hod Date: Mon, 4 May 2026 17:03:10 +0900 Subject: [PATCH 5/6] Refactor notification dispatch metrics for attempts tracking --- .../NotificationDispatchService.kt | 10 +-- .../NotificationDispatchIntegrationTest.kt | 27 ++++++-- .../NotificationDispatchServiceMetricsTest.kt | 4 +- .../notification/PrometheusEndpointE2eTest.kt | 62 ++++++++++++++++--- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index c821224..4e66d25 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -27,8 +27,8 @@ class NotificationDispatchService( ) { private val log = LoggerFactory.getLogger(javaClass) - private fun pendingCounter(type: String): Counter = Counter.builder(METRIC_PENDING) - .description("발송 시도된 알림 수") + private fun attemptsCounter(type: String): Counter = Counter.builder(METRIC_ATTEMPTS) + .description("디스패치 시도된 알림의 누적 수 (이전에 PENDING 상태였던 행을 처리할 때마다 증가)") .tag("notification_type", type) .register(meterRegistry) @@ -38,7 +38,7 @@ class NotificationDispatchService( .tag("reason", reason) .register(meterRegistry) - @Timed(value = "moa.notification.dispatch", percentiles = [0.5, 0.95, 0.99]) + @Timed(value = "moa.notification.dispatch", histogram = true) fun processNotifications(date: LocalDate, currentTime: LocalTime) { val pendingLogs = notificationLogRepository .findAllByScheduledDateAndScheduledTimeLessThanEqualAndStatus( @@ -50,7 +50,7 @@ class NotificationDispatchService( log.info("Dispatching {} pending notifications", pendingLogs.size) pendingLogs.groupingBy { it.notificationType.name }.eachCount() - .forEach { (type, n) -> pendingCounter(type).increment(n.toDouble()) } + .forEach { (type, n) -> attemptsCounter(type).increment(n.toDouble()) } val memberIds = pendingLogs.map { it.memberId }.distinct() val tokensByMemberId = fcmTokenRepository.findAllByMemberIdIn(memberIds) @@ -103,7 +103,7 @@ class NotificationDispatchService( } companion object { - private const val METRIC_PENDING = "moa.notification.dispatch.pending" + private const val METRIC_ATTEMPTS = "moa.notification.dispatch.attempts" private const val METRIC_FAILED = "moa.notification.dispatch.failed" private const val REASON_NO_TOKEN = "no_token" private const val REASON_BUILD = "build" diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt index e4ceb66..c53c980 100644 --- a/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchIntegrationTest.kt @@ -49,18 +49,37 @@ class NotificationDispatchIntegrationTest @Autowired constructor( notificationDispatchService.processNotifications(TODAY, NOON) - val pending = meterRegistry.find("moa.notification.dispatch.pending") + val attempts = meterRegistry.find("moa.notification.dispatch.attempts") .tag("notification_type", "CLOCK_IN").counter() val failed = meterRegistry.find("moa.notification.dispatch.failed") .tag("notification_type", "CLOCK_IN").tag("reason", "no_token").counter() - assertThat(pending?.count()).isEqualTo(1.0) + assertThat(attempts?.count()).isEqualTo(1.0) assertThat(failed?.count()).isEqualTo(1.0) // common tag 부착 검증 — `MetricsConfig`의 `MeterRegistryCustomizer`가 // 모든 신규 메트릭에 application/deploy_color/app_version 을 자동으로 붙이는지 확인. - val tagKeys = pending?.id?.tags?.map { it.key }.orEmpty() - assertThat(tagKeys).contains("application", "deploy_color", "app_version", "notification_type") + // + // 키 존재만으론 부족하다 — `app_version=""` 처럼 *값이 비어있는* 회귀를 못 잡는다. + // 값까지 검증 + 두 메트릭 간 공통 태그 일관성까지 비교해서 customizer가 *부분만* + // 적용되는 깨진 상태를 잡는다. + val attemptsTags = attempts?.id?.tags.orEmpty().associate { it.key to it.value } + val failedTags = failed?.id?.tags.orEmpty().associate { it.key to it.value } + + // 분기 태그 — 비즈니스 코드가 직접 붙인 태그가 정확히 흘러갔는지 + assertThat(attemptsTags["notification_type"]).isEqualTo("CLOCK_IN") + assertThat(failedTags["notification_type"]).isEqualTo("CLOCK_IN") + assertThat(failedTags["reason"]).isEqualTo("no_token") + + // common tag — 값이 비어있지 않은지 + assertThat(attemptsTags["application"]).isNotNull().isNotBlank() + assertThat(attemptsTags["deploy_color"]).isNotNull().isNotBlank() + assertThat(attemptsTags["app_version"]).isNotNull().isNotBlank() + + // common tag 일관성 — customizer가 두 메트릭에 *같은 값*을 붙였는지 + assertThat(failedTags["application"]).isEqualTo(attemptsTags["application"]) + assertThat(failedTags["deploy_color"]).isEqualTo(attemptsTags["deploy_color"]) + assertThat(failedTags["app_version"]).isEqualTo(attemptsTags["app_version"]) } companion object { diff --git a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt index 6711d4a..3a8e618 100644 --- a/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt +++ b/src/test/kotlin/com/moa/service/notification/NotificationDispatchServiceMetricsTest.kt @@ -89,12 +89,12 @@ class NotificationDispatchServiceMetricsTest { sut.processNotifications(TODAY, NOON) - val pending = registry.find("moa.notification.dispatch.pending") + val attempts = registry.find("moa.notification.dispatch.attempts") .tag("notification_type", "CLOCK_IN").counter() val failed = registry.find("moa.notification.dispatch.failed") .tag("notification_type", "CLOCK_IN").tag("reason", "no_token").counter() - assertThat(pending?.count()).isEqualTo(1.0) + assertThat(attempts?.count()).isEqualTo(1.0) assertThat(failed?.count()).isEqualTo(1.0) } diff --git a/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt index af2fff7..c1a3773 100644 --- a/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt +++ b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt @@ -4,12 +4,18 @@ import com.moa.entity.notification.NotificationLog import com.moa.entity.notification.NotificationStatus import com.moa.entity.notification.NotificationType import com.moa.repository.NotificationLogRepository +import io.mockk.mockk +import io.micrometer.core.instrument.MeterRegistry import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary import org.springframework.web.client.RestClient import java.time.LocalDate import java.time.LocalTime @@ -19,7 +25,7 @@ import java.time.LocalTime * * 통합 테스트(`NotificationDispatchIntegrationTest`)와의 차이: * - 통합: `MeterRegistry` 객체를 직접 주입받아 메트릭 값을 in-process로 단언. - * - E2E: 실제 HTTP 서버(Random Port)를 띄우고 `TestRestTemplate`으로 `/actuator/prometheus` + * - E2E: 실제 HTTP 서버(Random Port)를 띄우고 `RestClient`로 `/actuator/prometheus` * 를 호출해 **응답 본문 텍스트**를 검증. 운영에서 Grafana Alloy가 scrape하는 그대로의 * 계약(노출 형식 + Prometheus exposition format + 라벨 직렬화)을 회귀로 잠근다. * @@ -28,6 +34,12 @@ import java.time.LocalTime * - 시스템 경계까지 검증 범위가 늘어나고 * - 실행 비용은 커진다. * 그래서 **단위는 분기마다, 통합은 핵심 흐름당 1개, E2E는 외부 계약당 1개**가 일반적인 비율. + * + * NotificationDispatchScheduler 빈을 mock으로 교체한 이유: + * `@SpringBootTest`는 컴포넌트 스캔을 모두 수행해서 Scheduler가 cron으로 살아있다. + * 그 cron이 분 경계에서 실제 `processNotifications`를 호출하면, 본 테스트가 + * 명시 호출을 안 하고도 Timer가 노출되어 false positive로 통과할 수 있다. + * noop mock으로 교체해 *명시 호출만 측정*되도록 격리. */ @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -37,10 +49,12 @@ import java.time.LocalTime "management.prometheus.metrics.export.enabled=true", ], ) +@Import(PrometheusEndpointE2eTest.NoopSchedulerConfig::class) class PrometheusEndpointE2eTest @Autowired constructor( @LocalServerPort private val port: Int, private val notificationDispatchService: NotificationDispatchService, private val notificationLogRepository: NotificationLogRepository, + private val meterRegistry: MeterRegistry, ) { private val httpClient = RestClient.create() @@ -48,6 +62,7 @@ class PrometheusEndpointE2eTest @Autowired constructor( @AfterEach fun cleanup() { notificationLogRepository.deleteAll() + meterRegistry.clear() } @Test @@ -69,21 +84,50 @@ class PrometheusEndpointE2eTest @Autowired constructor( .body(String::class.java) ?: "" // 1) Counter 라인 노출 (Prometheus는 점을 underscore로 변환하고 카운터에 _total 접미사 부착) - assertThat(body).contains("moa_notification_dispatch_pending_total") + assertThat(body).contains("moa_notification_dispatch_attempts_total") assertThat(body).contains("moa_notification_dispatch_failed_total") - // 2) Timer (Spring Boot는 summary 형태 + p50/p95/p99 quantile) - assertThat(body).contains("moa_notification_dispatch_seconds") - assertThat(body).contains("quantile=\"0.95\"") + // 2) Timer — histogram 모드라 bucket 시리즈가 노출되어야 한다. + // server-side `histogram_quantile()`로 정확한 multi-instance/multi-color 집계가 가능. + // summary(percentiles)와 달리 deploy_color 합산 시 수학적으로 valid. + assertThat(body).contains("moa_notification_dispatch_seconds_bucket") // 3) 분기별 태그가 그대로 직렬화되어 보이는지 (notification_type, reason) assertThat(body).contains("notification_type=\"CLOCK_IN\"") assertThat(body).contains("reason=\"no_token\"") - // 4) common tag — 운영 PromQL의 `deploy_color`, `app_version` 라벨 분리에 필수 - assertThat(body).contains("application=\"moa\"") - assertThat(body).contains("deploy_color=") - assertThat(body).contains("app_version=") + // 4) common tag — 비즈니스 메트릭 라인에 *실제로* 부착되었는지 확인. + // `body.contains("application=\"moa\"")`만 단언하면 JVM/HTTP 등 무관한 메트릭에도 + // common tag가 붙어있어 가짜 통과 가능. 비즈니스 라인만 추출해 그 라인이 모두 + // common tag를 가지는지(`allSatisfy`) 확인해야 customizer 회귀를 잡을 수 있다. + val notificationMetricLines = metricLines(body, "moa_notification_dispatch_attempts_total") + + metricLines(body, "moa_notification_dispatch_failed_total") + + metricLines(body, "moa_notification_dispatch_seconds") + assertThat(notificationMetricLines).isNotEmpty + assertThat(notificationMetricLines).allSatisfy { line -> + assertThat(line).contains("application=\"moa\"") + assertThat(line).contains("deploy_color=") + assertThat(line).contains("app_version=") + } + } + + private fun metricLines(body: String, metricPrefix: String): List = + body.lineSequence() + .filter { line -> line.isNotBlank() && !line.startsWith("#") } + .filter { line -> line.startsWith(metricPrefix) } + .toList() + + /** + * NotificationDispatchScheduler를 noop mock으로 교체하는 테스트 컨피그. + * Scheduler 빈은 살아있되 cron이 호출하는 메서드가 무력화되어, 본 테스트의 + * 명시 호출만 Timer/Counter에 반영된다. + */ + @TestConfiguration + class NoopSchedulerConfig { + @Bean + @Primary + fun noopDispatchScheduler(): NotificationDispatchScheduler = + mockk(relaxed = true) } companion object { From 5bd5d6a0b1437901fd0bf84fb515617b3b06bb85 Mon Sep 17 00:00:00 2001 From: hod Date: Mon, 4 May 2026 22:24:23 +0900 Subject: [PATCH 6/6] Fix test for overriding NotificationDispatchScheduler bean --- .../notification/PrometheusEndpointE2eTest.kt | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt index c1a3773..3ab2893 100644 --- a/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt +++ b/src/test/kotlin/com/moa/service/notification/PrometheusEndpointE2eTest.kt @@ -4,8 +4,8 @@ import com.moa.entity.notification.NotificationLog import com.moa.entity.notification.NotificationStatus import com.moa.entity.notification.NotificationType import com.moa.repository.NotificationLogRepository -import io.mockk.mockk import io.micrometer.core.instrument.MeterRegistry +import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -13,9 +13,9 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.context.annotation.Primary import org.springframework.web.client.RestClient import java.time.LocalDate import java.time.LocalTime @@ -23,18 +23,6 @@ import java.time.LocalTime /** * End-to-End 테스트. * - * 통합 테스트(`NotificationDispatchIntegrationTest`)와의 차이: - * - 통합: `MeterRegistry` 객체를 직접 주입받아 메트릭 값을 in-process로 단언. - * - E2E: 실제 HTTP 서버(Random Port)를 띄우고 `RestClient`로 `/actuator/prometheus` - * 를 호출해 **응답 본문 텍스트**를 검증. 운영에서 Grafana Alloy가 scrape하는 그대로의 - * 계약(노출 형식 + Prometheus exposition format + 라벨 직렬화)을 회귀로 잠근다. - * - * 즉, 단위→통합→E2E 로 갈수록: - * - 격리도는 낮아지고 - * - 시스템 경계까지 검증 범위가 늘어나고 - * - 실행 비용은 커진다. - * 그래서 **단위는 분기마다, 통합은 핵심 흐름당 1개, E2E는 외부 계약당 1개**가 일반적인 비율. - * * NotificationDispatchScheduler 빈을 mock으로 교체한 이유: * `@SpringBootTest`는 컴포넌트 스캔을 모두 수행해서 Scheduler가 cron으로 살아있다. * 그 cron이 분 경계에서 실제 `processNotifications`를 호출하면, 본 테스트가 @@ -47,6 +35,7 @@ import java.time.LocalTime "management.endpoints.web.exposure.include=health,info,metrics,prometheus", "management.endpoints.access.default=read_only", "management.prometheus.metrics.export.enabled=true", + "spring.main.allow-bean-definition-overriding=true", ], ) @Import(PrometheusEndpointE2eTest.NoopSchedulerConfig::class) @@ -55,6 +44,8 @@ class PrometheusEndpointE2eTest @Autowired constructor( private val notificationDispatchService: NotificationDispatchService, private val notificationLogRepository: NotificationLogRepository, private val meterRegistry: MeterRegistry, + private val applicationContext: ApplicationContext, + private val dispatchScheduler: NotificationDispatchScheduler, ) { private val httpClient = RestClient.create() @@ -65,6 +56,18 @@ class PrometheusEndpointE2eTest @Autowired constructor( meterRegistry.clear() } + @Test + fun `Scheduler 빈이 진짜로 mock으로 교체됐는지 — bean override 검증`() { + val schedulerBeans = applicationContext.getBeansOfType(NotificationDispatchScheduler::class.java) + assertThat(schedulerBeans).hasSize(1) + assertThat(schedulerBeans.keys).containsExactly("notificationDispatchScheduler") + + dispatchScheduler.dispatchPendingNotifications() + assertThat(meterRegistry.find("moa.notification.dispatch").timer()) + .`as`("mock scheduler 호출은 production processNotifications 를 안 부르므로 Timer 등록도 안 됨") + .isNull() + } + @Test fun `E2E - dispatch 후 actuator prometheus 엔드포인트가 신규 메트릭을 텍스트로 노출한다`() { notificationLogRepository.save( @@ -101,8 +104,8 @@ class PrometheusEndpointE2eTest @Autowired constructor( // common tag가 붙어있어 가짜 통과 가능. 비즈니스 라인만 추출해 그 라인이 모두 // common tag를 가지는지(`allSatisfy`) 확인해야 customizer 회귀를 잡을 수 있다. val notificationMetricLines = metricLines(body, "moa_notification_dispatch_attempts_total") + - metricLines(body, "moa_notification_dispatch_failed_total") + - metricLines(body, "moa_notification_dispatch_seconds") + metricLines(body, "moa_notification_dispatch_failed_total") + + metricLines(body, "moa_notification_dispatch_seconds") assertThat(notificationMetricLines).isNotEmpty assertThat(notificationMetricLines).allSatisfy { line -> assertThat(line).contains("application=\"moa\"") @@ -118,14 +121,23 @@ class PrometheusEndpointE2eTest @Autowired constructor( .toList() /** - * NotificationDispatchScheduler를 noop mock으로 교체하는 테스트 컨피그. - * Scheduler 빈은 살아있되 cron이 호출하는 메서드가 무력화되어, 본 테스트의 - * 명시 호출만 Timer/Counter에 반영된다. + * NotificationDispatchScheduler 빈을 noop mock으로 *교체*(override) 한다. + * + * 중요: `@Bean` 의 *name* 을 component-scanned 빈과 *동일하게* + * (`notificationDispatchScheduler`) 지정해야 진짜 교체가 일어난다. + * + * 다른 이름으로 정의하고 `@Primary` 만 붙이면 두 빈이 *공존* 하고, + * component-scanned 빈이 그대로 살아있어 `@Scheduled` cron이 여전히 + * 등록된다 — Spring 의 `ScheduledAnnotationBeanPostProcessor` 는 + * 컨텍스트 안의 *모든* 빈을 스캔하기 때문이고, `@Primary` 는 + * *주입 우선순위* 만 결정할 뿐 빈 등록 자체에 개입하지 않는다. + * + * `spring.main.allow-bean-definition-overriding=true` 가 properties 에 + * 활성화돼야 같은 이름의 빈 재정의가 허용된다. */ @TestConfiguration class NoopSchedulerConfig { - @Bean - @Primary + @Bean("notificationDispatchScheduler") fun noopDispatchScheduler(): NotificationDispatchScheduler = mockk(relaxed = true) }