From 543a368f820d487b10ce4d3f061a8ce851dd0136 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 15:53:13 +0900 Subject: [PATCH 01/13] feat: Add metrics for core module --- core/build.gradle | 3 ++ .../core/aop/DiscordNotifierAspect.java | 35 +++++++++++++-- .../core/infrastructure/RedisDataStorage.java | 43 +++++++++++-------- .../discord/DiscordWebhookService.java | 25 ++++++++--- .../core/aop/DiscordNotifierAspectTest.java | 31 +++++++++---- 5 files changed, 99 insertions(+), 38 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index 5682716..363ce8d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -14,6 +14,9 @@ dependencies { // Utils api 'org.apache.commons:commons-lang3:3.18.0' + // Metrics + implementation 'io.micrometer:micrometer-core' + // Jackson for JSON serialization api 'com.fasterxml.jackson.core:jackson-databind' } diff --git a/core/src/main/java/com/services/core/aop/DiscordNotifierAspect.java b/core/src/main/java/com/services/core/aop/DiscordNotifierAspect.java index a00b6cb..01f02af 100644 --- a/core/src/main/java/com/services/core/aop/DiscordNotifierAspect.java +++ b/core/src/main/java/com/services/core/aop/DiscordNotifierAspect.java @@ -6,6 +6,8 @@ import com.services.core.notification.discord.DiscordWebhookPayload; import com.services.core.notification.discord.DiscordWebhookService; import com.services.core.notification.discord.Embed; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -31,28 +33,53 @@ public class DiscordNotifierAspect { private static final String BOT_USERNAME = "Application Event Bot"; private final DiscordWebhookService discordWebhookService; - private final Optional messageSource; // Optional dependency + private final Optional messageSource; + private final MeterRegistry registry; @Around("@annotation(notifyDiscord)") public Object notifyEvent(ProceedingJoinPoint joinPoint, NotifyDiscord notifyDiscord) throws Throwable { String serviceName = joinPoint.getSignature().getDeclaringType().getSimpleName(); String taskName = notifyDiscord.taskName(); - Instant startTime = Instant.now(); log.info("Starting task: '{}' in {}", taskName, serviceName); + Timer.Sample sample = Timer.start(registry); try { Object result = joinPoint.proceed(); - Duration duration = Duration.between(startTime, Instant.now()); - log.info("Task '{}' completed successfully in {} seconds.", taskName, duration.toSeconds()); + long durationNanos = + sample.stop( + Timer.builder("task.execution.duration") + .tag("service", serviceName) + .tag("task", taskName) + .publishPercentileHistogram() + .register(registry)); + + Duration duration = Duration.ofNanos(durationNanos); + log.info( + "Task '{}' completed successfully in {} seconds.", taskName, duration.toSeconds()); + + registry + .counter("task.execution.total", "task", taskName, "status", "success") + .increment(); sendSuccessWebhook(serviceName, taskName, duration, result); return result; } catch (Throwable e) { + sample.stop( + Timer.builder("task.execution.duration") + .tag("service", serviceName) + .tag("task", taskName) + .publishPercentileHistogram() + .register(registry)); + log.error("Task '{}' in {} failed.", taskName, serviceName, e); + registry + .counter("task.execution.total", "task", taskName, "status", "failure") + .increment(); + sendErrorWebhook(serviceName, taskName, e); throw e; } diff --git a/core/src/main/java/com/services/core/infrastructure/RedisDataStorage.java b/core/src/main/java/com/services/core/infrastructure/RedisDataStorage.java index 9fffa09..22ced06 100644 --- a/core/src/main/java/com/services/core/infrastructure/RedisDataStorage.java +++ b/core/src/main/java/com/services/core/infrastructure/RedisDataStorage.java @@ -2,8 +2,9 @@ import com.services.core.exception.ErrorCode; import com.services.core.exception.NotFoundException; -import com.services.core.util.RandomUtils; -import java.util.List; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.util.Collection; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,24 +19,31 @@ public class RedisDataStorage { private final RedisTemplate redisTemplate; + private final MeterRegistry registry; - public void setListData(String key, List data) { + public void setData(String key, Collection data) { if (data == null || data.isEmpty()) { log.warn("No data to store for key: {}", key); return; } - try { - redisTemplate.executePipelined(createPipelineCallback(key, data)); - log.info("Stored {} items to Redis key: {} (pipeline)", data.size(), key); - } catch (Exception e) { - log.error("Failed to store data to Redis key: {}", key, e); - throw e; - } + Timer.builder("redis.pipeline.duration") + .tag("key", key) + .register(registry) + .record( + () -> { + try { + redisTemplate.executePipelined(createPipelineCallback(key, data)); + log.info("Stored {} items to Redis key: {} (pipeline set)", data.size(), key); + } catch (Exception e) { + log.error("Failed to store data to Redis key: {}", key, e); + throw e; + } + }); } @SuppressWarnings({"unchecked", "rawtypes"}) - private RedisCallback createPipelineCallback(String key, List data) { + private RedisCallback createPipelineCallback(String key, Collection data) { return connection -> { RedisSerializer keySerializer = redisTemplate.getStringSerializer(); RedisSerializer valueSerializer = redisTemplate.getValueSerializer(); @@ -46,7 +54,7 @@ private RedisCallback createPipelineCallback(String key, List dat for (T item : data) { byte[] valueBytes = valueSerializer.serialize(item); - connection.rPush(keyBytes, valueBytes); + connection.sAdd(keyBytes, valueBytes); } return null; @@ -55,12 +63,11 @@ private RedisCallback createPipelineCallback(String key, List dat @SuppressWarnings("unchecked") public Optional getRandomElement(String key, Class elementType) { - Long size = redisTemplate.opsForList().size(key); - if (size == null || size == 0) { - return Optional.empty(); - } - int randomIndex = RandomUtils.generateRandomInt(size.intValue()); - Object element = redisTemplate.opsForList().index(key, randomIndex); + Object element = redisTemplate.opsForSet().randomMember(key); + + String status = (element == null) ? "miss" : "hit"; + registry.counter("redis.random.access", "key", key, "status", status).increment(); + return element == null ? Optional.empty() : Optional.of((T) element); } diff --git a/core/src/main/java/com/services/core/notification/discord/DiscordWebhookService.java b/core/src/main/java/com/services/core/notification/discord/DiscordWebhookService.java index 946d1c7..05eb878 100644 --- a/core/src/main/java/com/services/core/notification/discord/DiscordWebhookService.java +++ b/core/src/main/java/com/services/core/notification/discord/DiscordWebhookService.java @@ -2,6 +2,7 @@ import com.services.core.exception.BadGatewayException; import com.services.core.exception.ErrorCode; +import io.micrometer.core.instrument.MeterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; @@ -14,9 +15,13 @@ public class DiscordWebhookService { private final RestClient restClient; + private final MeterRegistry registry; public DiscordWebhookService( - RestClient.Builder restClientBuilder, @Value("${discord.webhook.url}") String webhookUrl) { + RestClient.Builder restClientBuilder, + @Value("${discord.webhook.url}") String webhookUrl, + MeterRegistry registry) { + this.registry = registry; this.restClient = restClientBuilder .baseUrl(webhookUrl) @@ -30,12 +35,18 @@ public DiscordWebhookService( } public void sendMessage(DiscordWebhookPayload payload) { - restClient - .post() - .contentType(MediaType.APPLICATION_JSON) - .body(payload) - .retrieve() - .toBodilessEntity(); + try { + restClient + .post() + .contentType(MediaType.APPLICATION_JSON) + .body(payload) + .retrieve() + .toBodilessEntity(); + registry.counter("discord.webhook.sent", "status", "success").increment(); + } catch (Exception e) { + registry.counter("discord.webhook.sent", "status", "failure").increment(); + throw e; + } } public void sendMessageAsync(DiscordWebhookPayload payload) { diff --git a/core/src/test/java/com/services/core/aop/DiscordNotifierAspectTest.java b/core/src/test/java/com/services/core/aop/DiscordNotifierAspectTest.java index 6406eae..680b369 100644 --- a/core/src/test/java/com/services/core/aop/DiscordNotifierAspectTest.java +++ b/core/src/test/java/com/services/core/aop/DiscordNotifierAspectTest.java @@ -1,26 +1,38 @@ package com.services.core.aop; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.services.core.notification.discord.DiscordWebhookService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.EnableAspectJAutoProxy; import org.springframework.stereotype.Component; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @SpringBootTest( - classes = {DiscordNotifierAspectTest.TestComponent.class, DiscordNotifierAspect.class}) + classes = { + DiscordNotifierAspectTest.TestComponent.class, + DiscordNotifierAspect.class, + DiscordNotifierAspectTest.TestConfig.class + }) @EnableAspectJAutoProxy -@ExtendWith(MockitoExtension.class) class DiscordNotifierAspectTest { + @Configuration + static class TestConfig { + @Bean + public MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + } + @Component static class TestComponent { @NotifyDiscord(taskName = "테스트 작업") @@ -33,14 +45,15 @@ public void annotatedMethod() { @MockitoBean private DiscordWebhookService discordWebhookService; + @Autowired private MeterRegistry meterRegistry; + @Test - @DisplayName("@NotifyDiscord 어노테이션이 붙은 메서드가 호출되면 Aspect가 동작하여 Discord 알림을 보낸다") - void aspectShouldTriggerForAnnotatedMethod() { + @DisplayName("어노테이션 메서드 호출 시 Discord 알림 전송 - 성공") + void annotatedMethod_shouldTriggerDiscordNotification() { // Given & When testComponent.annotatedMethod(); // Then - // Verify that the async message sending method was called exactly once. - verify(discordWebhookService, times(1)).sendMessageAsync(any()); + verify(discordWebhookService).sendMessageAsync(any()); } } From 1908d111b63f95095b08049ad8599c4922746134 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 15:54:30 +0900 Subject: [PATCH 02/13] chore: Replace random from timestamp to ThreadLocalRandom --- .../src/main/java/com/services/core/util/RandomUtils.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/com/services/core/util/RandomUtils.java b/core/src/main/java/com/services/core/util/RandomUtils.java index 2722f82..5f222fe 100644 --- a/core/src/main/java/com/services/core/util/RandomUtils.java +++ b/core/src/main/java/com/services/core/util/RandomUtils.java @@ -1,9 +1,13 @@ package com.services.core.util; +import java.util.concurrent.ThreadLocalRandom; + public class RandomUtils { public static int generateRandomInt(int max) { - long timestamp = System.currentTimeMillis(); - return (int) (timestamp % max); + if (max <= 0) { + return 0; + } + return ThreadLocalRandom.current().nextInt(max); } } From 9a7ef5a15294236f62997d87810267093edff941 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 15:55:09 +0900 Subject: [PATCH 03/13] test: Add test for core module --- .../infrastructure/RedisDataStorageTest.java | 86 +++++++++++++++++++ .../core/message/MessageValidatorTest.java | 58 +++++++++++++ .../services/core/util/RandomUtilsTest.java | 23 +++++ 3 files changed, 167 insertions(+) create mode 100644 core/src/test/java/com/services/core/infrastructure/RedisDataStorageTest.java create mode 100644 core/src/test/java/com/services/core/message/MessageValidatorTest.java create mode 100644 core/src/test/java/com/services/core/util/RandomUtilsTest.java diff --git a/core/src/test/java/com/services/core/infrastructure/RedisDataStorageTest.java b/core/src/test/java/com/services/core/infrastructure/RedisDataStorageTest.java new file mode 100644 index 0000000..bd18e0c --- /dev/null +++ b/core/src/test/java/com/services/core/infrastructure/RedisDataStorageTest.java @@ -0,0 +1,86 @@ +package com.services.core.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.SetOperations; + +@ExtendWith(MockitoExtension.class) +class RedisDataStorageTest { + + @Mock private RedisTemplate redisTemplate; + @Mock private SetOperations setOperations; + private MeterRegistry registry; + + private RedisDataStorage storage; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + storage = new RedisDataStorage(redisTemplate, registry); + } + + @Test + @DisplayName("setData - 데이터가 있을 때 저장 로직 실행") + void setData_whenDataIsPresent_shouldStoreData() { + // Given + String key = "test-key"; + List data = List.of("item1", "item2"); + + // When + storage.setData(key, data); + + // Then + verify(redisTemplate).executePipelined(any(RedisCallback.class)); + assertThat(registry.find("redis.pipeline.duration").timer()).isNotNull(); + } + + @Test + @DisplayName("getRandomElement - 데이터가 없을 때 Optional.empty() 반환") + void getRandomElement_whenNoData_shouldReturnEmpty() { + // Given + String key = "test-key"; + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.randomMember(key)).thenReturn(null); + + // When + Optional result = storage.getRandomElement(key, String.class); + + // Then + assertThat(result).isEmpty(); + assertThat(registry.find("redis.random.access").counter().count()).isEqualTo(1.0); + assertThat(registry.find("redis.random.access").tags("status", "miss").counter()).isNotNull(); + } + + @Test + @DisplayName("getRandomElement - 데이터가 있을 때 랜덤 요소 반환") + void getRandomElement_whenDataExists_shouldReturnElement() { + // Given + String key = "test-key"; + when(redisTemplate.opsForSet()).thenReturn(setOperations); + when(setOperations.randomMember(key)).thenReturn("item"); + + // When + Optional result = storage.getRandomElement(key, String.class); + + // Then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo("item"); + assertThat(registry.find("redis.random.access").counter().count()).isEqualTo(1.0); + assertThat(registry.find("redis.random.access").tags("status", "hit").counter()).isNotNull(); + } +} diff --git a/core/src/test/java/com/services/core/message/MessageValidatorTest.java b/core/src/test/java/com/services/core/message/MessageValidatorTest.java new file mode 100644 index 0000000..cf926f0 --- /dev/null +++ b/core/src/test/java/com/services/core/message/MessageValidatorTest.java @@ -0,0 +1,58 @@ +package com.services.core.message; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MessageValidatorTest { + + @ParameterizedTest + @CsvSource(value = { + "안녕하세요, true", + "Hello, true", + "안녕하세요 Hello 123, true", + "Special!@#, false", + " , false", + "null, false" + }, nullValues = {"null"}) + @DisplayName("isValid - 유효한 문자 및 패턴 검증") + void isValid_shouldValidateContent(String content, boolean expected) { + // When + boolean result = MessageValidator.isValid(content); + + // Then + assertThat(result).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(strings = { + "이것은 서른 자가 넘는 아주 긴 메시지입니다. 한글은 두 자로 계산되기 때문에 금방 초과하게 됩니다.", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" // 31 chars + }) + @DisplayName("isValid - 최대 글자 수 초과 시 false 반환") + void isValid_whenExceedsMaxCharCount_shouldReturnFalse(String content) { + // When + boolean result = MessageValidator.isValid(content); + + // Then + assertThat(result).isFalse(); + } + + @ParameterizedTest + @CsvSource({ + "한글, 4", + "abc, 3", + "한글abc, 7" + }) + @DisplayName("calculateCharCount - 한글은 2자, 나머지는 1자로 계산") + void calculateCharCount_shouldCalculateCorrectly(String text, int expected) { + // When + int result = MessageValidator.calculateCharCount(text); + + // Then + assertThat(result).isEqualTo(expected); + } +} diff --git a/core/src/test/java/com/services/core/util/RandomUtilsTest.java b/core/src/test/java/com/services/core/util/RandomUtilsTest.java new file mode 100644 index 0000000..0efe707 --- /dev/null +++ b/core/src/test/java/com/services/core/util/RandomUtilsTest.java @@ -0,0 +1,23 @@ +package com.services.core.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RandomUtilsTest { + + @Test + @DisplayName("generateRandomInt - 0보다 크거나 같고 max보다 작은 값 반환") + void generateRandomInt_shouldReturnInRange() { + // Given + int max = 10; + + // When + int result = RandomUtils.generateRandomInt(max); + + // Then + assertThat(result).isGreaterThanOrEqualTo(0); + assertThat(result).isLessThan(max); + } +} From 14990455cfd9bc68af64f0e8b1ef6d5b2b59087d Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 15:56:33 +0900 Subject: [PATCH 04/13] feat: Add metrics for data module --- .../data/pixabay/PixabayDataCollector.java | 15 +++++++++++++-- .../data/pixabay/PixabayMusicCollector.java | 8 ++++++-- .../data/pixabay/PixabayVideoCollector.java | 8 ++++++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/data/src/main/java/com/services/data/pixabay/PixabayDataCollector.java b/data/src/main/java/com/services/data/pixabay/PixabayDataCollector.java index 16659f8..a8b8f82 100644 --- a/data/src/main/java/com/services/data/pixabay/PixabayDataCollector.java +++ b/data/src/main/java/com/services/data/pixabay/PixabayDataCollector.java @@ -3,6 +3,7 @@ import com.services.core.dto.ApiResponse; import com.services.core.infrastructure.RedisDataStorage; import com.services.core.notification.DataCollectionResult; +import io.micrometer.core.instrument.MeterRegistry; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -22,6 +23,7 @@ public abstract class PixabayDataCollector> { protected final RestClient restClient; protected final Environment environment; protected final RedisDataStorage redisDataStorage; + protected final MeterRegistry registry; // Rate limiting configuration private static final long STAGGER_DELAY_MS = 1000; // 1 second between submissions @@ -29,10 +31,14 @@ public abstract class PixabayDataCollector> { private record FetchStatistics(List results, long successCount, long failureCount) {} public PixabayDataCollector( - RestClient restClient, Environment environment, RedisDataStorage redisDataStorage) { + RestClient restClient, + Environment environment, + RedisDataStorage redisDataStorage, + MeterRegistry registry) { this.restClient = restClient; this.environment = environment; this.redisDataStorage = redisDataStorage; + this.registry = registry; } public DataCollectionResult collectAndStore() { @@ -40,9 +46,14 @@ public DataCollectionResult collectAndStore() { log.info("Starting data collection for: {}", getStorageKey()); FetchStatistics stats = fetchAllData(); - redisDataStorage.setListData(getStorageKey(), stats.results()); + redisDataStorage.setData(getStorageKey(), stats.results()); double durationSeconds = (System.currentTimeMillis() - startTime) / 1000.0; + + registry.counter("pixabay.collection.items", "type", getDataType()).increment(stats.results().size()); + registry.counter("pixabay.collection.filters", "type", getDataType(), "status", "success").increment(stats.successCount()); + registry.counter("pixabay.collection.filters", "type", getDataType(), "status", "failure").increment(stats.failureCount()); + log.info( "Completed data collection for: {} - {} items stored", getStorageKey(), diff --git a/data/src/main/java/com/services/data/pixabay/PixabayMusicCollector.java b/data/src/main/java/com/services/data/pixabay/PixabayMusicCollector.java index c1d7470..e790f70 100644 --- a/data/src/main/java/com/services/data/pixabay/PixabayMusicCollector.java +++ b/data/src/main/java/com/services/data/pixabay/PixabayMusicCollector.java @@ -6,6 +6,7 @@ import com.services.core.notification.DataCollectionResult; import com.services.core.pixabay.dto.CustomPixabayMusicResponse; import com.services.core.pixabay.dto.PixabayMusicResult; +import io.micrometer.core.instrument.MeterRegistry; import java.util.List; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.env.Environment; @@ -54,8 +55,11 @@ public class PixabayMusicCollector "solo%20classical%20instruments"); public PixabayMusicCollector( - RestClient restClient, Environment environment, RedisDataStorage redisDataStorage) { - super(restClient, environment, redisDataStorage); + RestClient restClient, + Environment environment, + RedisDataStorage redisDataStorage, + MeterRegistry registry) { + super(restClient, environment, redisDataStorage, registry); } @Override diff --git a/data/src/main/java/com/services/data/pixabay/PixabayVideoCollector.java b/data/src/main/java/com/services/data/pixabay/PixabayVideoCollector.java index a0c3d41..410d402 100644 --- a/data/src/main/java/com/services/data/pixabay/PixabayVideoCollector.java +++ b/data/src/main/java/com/services/data/pixabay/PixabayVideoCollector.java @@ -6,6 +6,7 @@ import com.services.core.notification.DataCollectionResult; import com.services.core.pixabay.dto.PixabayResponse; import com.services.core.pixabay.dto.PixabayVideoResult; +import io.micrometer.core.instrument.MeterRegistry; import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; @@ -45,8 +46,11 @@ public class PixabayVideoCollector private String apiKey; public PixabayVideoCollector( - RestClient restClient, Environment environment, RedisDataStorage redisDataStorage) { - super(restClient, environment, redisDataStorage); + RestClient restClient, + Environment environment, + RedisDataStorage redisDataStorage, + MeterRegistry registry) { + super(restClient, environment, redisDataStorage, registry); } @Override From 213acb2feefa093607a81c0c1895a1b4a8ba6741 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 15:58:10 +0900 Subject: [PATCH 05/13] Refactor: Initialize data asynchronously during server startup --- .../data/scheduler/PixabayDataScheduler.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/data/src/main/java/com/services/data/scheduler/PixabayDataScheduler.java b/data/src/main/java/com/services/data/scheduler/PixabayDataScheduler.java index 369650d..f094b71 100644 --- a/data/src/main/java/com/services/data/scheduler/PixabayDataScheduler.java +++ b/data/src/main/java/com/services/data/scheduler/PixabayDataScheduler.java @@ -2,9 +2,10 @@ import com.services.data.pixabay.PixabayMusicCollector; import com.services.data.pixabay.PixabayVideoCollector; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -16,11 +17,14 @@ public class PixabayDataScheduler { private final PixabayVideoCollector videoCollector; private final PixabayMusicCollector musicCollector; - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void initializeData() { - log.info("=== Starting initial data collection ==="); - collectAllData(); - log.info("=== Initial data collection completed ==="); + Thread.startVirtualThread( + () -> { + log.info("=== Starting initial data collection in virtual thread ==="); + collectAllData(); + log.info("=== Initial data collection completed ==="); + }); } @Scheduled(cron = "0 0 3 * * *") From 200642e3eb7315c7d0d935e67bd276de6ae6b9b2 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 15:58:27 +0900 Subject: [PATCH 06/13] test: Add test for data module --- .../pixabay/PixabayVideoCollectorTest.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 data/src/test/java/com/services/data/pixabay/PixabayVideoCollectorTest.java diff --git a/data/src/test/java/com/services/data/pixabay/PixabayVideoCollectorTest.java b/data/src/test/java/com/services/data/pixabay/PixabayVideoCollectorTest.java new file mode 100644 index 0000000..bdf8f06 --- /dev/null +++ b/data/src/test/java/com/services/data/pixabay/PixabayVideoCollectorTest.java @@ -0,0 +1,66 @@ +package com.services.data.pixabay; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.services.core.infrastructure.RedisDataStorage; +import com.services.core.notification.DataCollectionResult; +import com.services.core.pixabay.dto.PixabayResponse; +import com.services.core.pixabay.dto.PixabayVideoResult; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.env.Environment; +import org.springframework.web.client.RestClient; + +@ExtendWith(MockitoExtension.class) +class PixabayVideoCollectorTest { + + @Mock private RestClient restClient; + @Mock private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + @Mock private RestClient.RequestHeadersSpec requestHeadersSpec; + @Mock private RestClient.ResponseSpec responseSpec; + @Mock private Environment environment; + @Mock private RedisDataStorage redisDataStorage; + @Mock private MeterRegistry registry; + @Mock private Counter counter; + + @InjectMocks private PixabayVideoCollector collector; + + @Test + @DisplayName("collectAndStore - 성공적으로 데이터를 수집하고 저장") + void collectAndStore_shouldFetchAndStoreData() { + // Given + when(environment.getProperty(anyString())).thenReturn("https://api.pixabay.com"); + when(restClient.get()).thenReturn(requestHeadersUriSpec); + when(requestHeadersUriSpec.uri(anyString())).thenReturn(requestHeadersSpec); + when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + + PixabayResponse pixabayResponse = + PixabayResponse.of("1", "1", List.of(PixabayVideoResult.builder().id(1).build())); + when(responseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(pixabayResponse); + + when(registry.counter(anyString(), any(String[].class))).thenReturn(counter); + + // When + DataCollectionResult result = collector.collectAndStore(); + + // Then + assertThat(result).isNotNull(); + assertThat(result.totalItems()).isEqualTo(20); + + verify(redisDataStorage).setData(eq("pixabayVideos"), anyList()); + } +} From 4a8552fff7eecc09783e4442f525aa4cecac7e0f Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:00:40 +0900 Subject: [PATCH 07/13] feat: Add metrics for api module --- .../api/presentation/GlobalExceptionHandler.java | 7 +++++++ .../services/api/pixabay/PixabayControllerTest.java | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/api/src/main/java/com/services/api/presentation/GlobalExceptionHandler.java b/api/src/main/java/com/services/api/presentation/GlobalExceptionHandler.java index 754a20c..15d32ee 100644 --- a/api/src/main/java/com/services/api/presentation/GlobalExceptionHandler.java +++ b/api/src/main/java/com/services/api/presentation/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import com.services.core.exception.CustomException; import com.services.core.exception.InternalServerException; import com.services.core.exception.NotFoundException; +import io.micrometer.core.instrument.MeterRegistry; import java.util.Locale; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,9 +22,15 @@ public class GlobalExceptionHandler { private final MessageSource messageSource; + private final MeterRegistry registry; private ResponseEntity> createErrorResponse( CustomException e, HttpStatus status) { + + registry + .counter("api.errors.total", "code", e.getErrorCode().getCode(), "status", status.name()) + .increment(); + String message = messageSource.getMessage(e.getErrorCode().getMessageKey(), null, Locale.getDefault()); BaseResponse response = BaseResponse.of(status, e.getErrorCode().getCode(), message); diff --git a/api/src/test/java/com/services/api/pixabay/PixabayControllerTest.java b/api/src/test/java/com/services/api/pixabay/PixabayControllerTest.java index 3985127..2d64768 100644 --- a/api/src/test/java/com/services/api/pixabay/PixabayControllerTest.java +++ b/api/src/test/java/com/services/api/pixabay/PixabayControllerTest.java @@ -1,5 +1,8 @@ package com.services.api.pixabay; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -11,7 +14,10 @@ import com.services.core.exception.NotFoundException; import com.services.core.pixabay.dto.PixabayMusicResult; import com.services.core.pixabay.dto.PixabayVideoResult; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import java.util.Locale; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,8 +37,15 @@ class PixabayControllerTest { @Autowired private MessageSource messageSource; + @MockitoBean private MeterRegistry meterRegistry; + @MockitoBean private PixabayService pixabayService; + @BeforeEach + void setUp() { + when(meterRegistry.counter(anyString(), any(String[].class))).thenReturn(mock(Counter.class)); + } + private String getErrorMessage(ErrorCode errorCode) { return messageSource.getMessage(errorCode.getMessageKey(), null, Locale.getDefault()); } From dceee1bcd4d03e40abf1909daf6ea620477aa107 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:01:32 +0900 Subject: [PATCH 08/13] chore: Remove redundant CORS configurations --- .../com/services/api/config/CorsConfig.java | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/api/src/main/java/com/services/api/config/CorsConfig.java b/api/src/main/java/com/services/api/config/CorsConfig.java index e1bf7a9..f130145 100644 --- a/api/src/main/java/com/services/api/config/CorsConfig.java +++ b/api/src/main/java/com/services/api/config/CorsConfig.java @@ -3,11 +3,7 @@ import java.util.Arrays; import java.util.List; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -29,20 +25,4 @@ public void addCorsMappings(CorsRegistry registry) { .allowCredentials(true) .maxAge(3600); } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - List origins = Arrays.asList(allowedOrigins.split(",")); - - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(origins); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); - configuration.setMaxAge(3600L); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } } From 3ea1b7f7166347fb4d5563f4a0e88d8c9445cfdd Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:02:05 +0900 Subject: [PATCH 09/13] chore: Add messages for test configuration --- api/src/test/resources/application-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/src/test/resources/application-test.yml b/api/src/test/resources/application-test.yml index 194e782..6840c26 100644 --- a/api/src/test/resources/application-test.yml +++ b/api/src/test/resources/application-test.yml @@ -1,3 +1,7 @@ +spring: + messages: + basename: messages + logging: level: com.services: DEBUG From a5ebfafc987b7512dcc034002f8847a91ac4f5af Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:02:18 +0900 Subject: [PATCH 10/13] test: Add test for api module --- .../api/pixabay/PixabayServiceTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 api/src/test/java/com/services/api/pixabay/PixabayServiceTest.java diff --git a/api/src/test/java/com/services/api/pixabay/PixabayServiceTest.java b/api/src/test/java/com/services/api/pixabay/PixabayServiceTest.java new file mode 100644 index 0000000..bc46905 --- /dev/null +++ b/api/src/test/java/com/services/api/pixabay/PixabayServiceTest.java @@ -0,0 +1,54 @@ +package com.services.api.pixabay; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.services.core.infrastructure.RedisDataStorage; +import com.services.core.pixabay.dto.PixabayMusicResult; +import com.services.core.pixabay.dto.PixabayVideoResult; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PixabayServiceTest { + + @Mock private RedisDataStorage redisDataStorage; + + @InjectMocks private PixabayService pixabayService; + + @Test + @DisplayName("getRandomVideo - 성공 시 비디오 결과 반환") + void getRandomVideo_shouldReturnVideo() { + // Given + PixabayVideoResult videoResult = PixabayVideoResult.builder().id(1).build(); + when(redisDataStorage.getRandomElement(any(), eq(PixabayVideoResult.class), any())) + .thenReturn(videoResult); + + // When + PixabayVideoResult result = pixabayService.getRandomVideo(); + + // Then + assertThat(result).isEqualTo(videoResult); + } + + @Test + @DisplayName("getRandomMusic - 성공 시 음악 결과 반환") + void getRandomMusic_shouldReturnMusic() { + // Given + PixabayMusicResult musicResult = PixabayMusicResult.builder().id(2).build(); + when(redisDataStorage.getRandomElement(any(), eq(PixabayMusicResult.class), any())) + .thenReturn(musicResult); + + // When + PixabayMusicResult result = pixabayService.getRandomMusic(); + + // Then + assertThat(result).isEqualTo(musicResult); + } +} From 543a5d6e63393753fd97c0f44edb9296fd30cbd8 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:02:41 +0900 Subject: [PATCH 11/13] test: Add test for monitoring module --- .../monitoring/MonitoringApplicationTest.java | 54 +++++++++++++++++++ .../src/test/resources/application-test.yml | 3 ++ 2 files changed, 57 insertions(+) create mode 100644 monitoring/src/test/java/com/services/monitoring/MonitoringApplicationTest.java create mode 100644 monitoring/src/test/resources/application-test.yml diff --git a/monitoring/src/test/java/com/services/monitoring/MonitoringApplicationTest.java b/monitoring/src/test/java/com/services/monitoring/MonitoringApplicationTest.java new file mode 100644 index 0000000..e26d74c --- /dev/null +++ b/monitoring/src/test/java/com/services/monitoring/MonitoringApplicationTest.java @@ -0,0 +1,54 @@ +package com.services.monitoring; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +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.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MonitoringApplicationTest { + + @Autowired private TestRestTemplate restTemplate; + + @Test + @DisplayName("스프링 컨텍스트 로드 성공") + void contextLoads_shouldSucceed() { + // When - Checking if the context loads successfully + + // Then - Succeeds if it reaches here without exception + } + + @Test + @DisplayName("Prometheus 메트릭 엔드포인트 조회 - 성공") + void getPrometheusMetrics_shouldReturnMetrics() { + // When + var actuatorResponse = restTemplate.getForEntity("/actuator", String.class); + System.out.println("Actuator Links: " + actuatorResponse.getBody()); + + var response = restTemplate.getForEntity("/actuator/prometheus", String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).contains("jvm_memory_used_bytes"); + assertThat(response.getBody()).contains("http_server_requests_seconds_count"); + } + + @Test + @DisplayName("Health 체크 엔드포인트 조회 - 성공 및 UP 상태 확인") + void getHealth_shouldReturnUpStatus() { + // When + var response = restTemplate.getForEntity("/actuator/health", String.class); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).contains("UP"); + } +} diff --git a/monitoring/src/test/resources/application-test.yml b/monitoring/src/test/resources/application-test.yml new file mode 100644 index 0000000..f3bb49a --- /dev/null +++ b/monitoring/src/test/resources/application-test.yml @@ -0,0 +1,3 @@ +spring: + application: + name: monitoring-test From daafc9d434ade3f3c76df7ef68b551a9f97657ef Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:03:52 +0900 Subject: [PATCH 12/13] docs: Update Context --- .gemini/architecture.md | 6 +++--- .gemini/deployment.md | 2 +- .gemini/project.md | 5 +++-- .gemini/workflows.md | 8 ++++---- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.gemini/architecture.md b/.gemini/architecture.md index fc3a400..08fb307 100644 --- a/.gemini/architecture.md +++ b/.gemini/architecture.md @@ -101,7 +101,7 @@ public class PixabayController { public abstract class PixabayDataCollector { public void collectAndStore() { List dataList = fetchAllData(); - redisDataStorage.setListData(getStorageKey(), dataList); + redisDataStorage.setData(getStorageKey(), dataList); } protected abstract String getStorageKey(); @@ -135,10 +135,10 @@ public class PixabayVideoCollector extends PixabayDataCollector<...> { // Redis 저장소 추상화 @Component public class RedisDataStorage { - public void setListData(String key, List data) { ... } + public void setData(String key, Collection data) { ... } // Set 자료구조 사용 (O(1)) public T getRandomElement(String key, Class type, ErrorCode errorCode) { ... } } - +``` @Component public class RedisMessageStorage { public void saveMessage(String content) { ... } diff --git a/.gemini/deployment.md b/.gemini/deployment.md index 87fd8bd..b0ee1ff 100644 --- a/.gemini/deployment.md +++ b/.gemini/deployment.md @@ -81,7 +81,7 @@ docker exec -it 4d4cat-redis redis-cli KEYS * # 데이터 확인 -LRANGE pixabayVideos 0 10 +SMEMBERS pixabayVideos GET message:last ``` diff --git a/.gemini/project.md b/.gemini/project.md index b1ae338..1c367bd 100644 --- a/.gemini/project.md +++ b/.gemini/project.md @@ -27,14 +27,15 @@ - 공통 예외 클래스 (`ErrorCode`, `CustomException` 등) - 공통 DTO (`BaseResponse`, `ApiResponse`, `DataCollectionResult`) - Redis 설정 및 저장소 (`RedisConfig`, `RedisDataStorage`, `RedisMessageStorage`) + - `RedisDataStorage`: Redis Set 자료구조를 활용한 랜덤 요소 조회 최적화 (O(1)) - Pixabay DTO (`PixabayVideoResult`, `PixabayMusicResult`) - AOP 및 알림 (`@NotifyDiscord`, `DiscordNotifierAspect`, `DiscordWebhookService`) -- 유틸리티 (`RandomUtils`) +- 유틸리티 (`RandomUtils`: `ThreadLocalRandom` 기반 고품질 난수 생성) ### data 모듈 - Pixabay API 데이터 수집기 (`PixabayDataCollector`, `PixabayVideoCollector`, `PixabayMusicCollector`) - 스케줄러 (`PixabayDataScheduler`) -- 서버 시작 시 데이터 초기화, 주기적 갱신 +- 서버 시작 완료 시(`ApplicationReadyEvent`) 비동기적으로 데이터 초기화, 주기적 갱신 - `@NotifyDiscord` 어노테이션을 통한 데이터 수집 알림 ### api 모듈 diff --git a/.gemini/workflows.md b/.gemini/workflows.md index 0ebcc53..07d2b82 100644 --- a/.gemini/workflows.md +++ b/.gemini/workflows.md @@ -4,7 +4,7 @@ ### Pixabay 데이터 수집 (PixabayDataScheduler) ``` -[Server Startup] → @PostConstruct +[Server Startup] → ApplicationReadyEvent (Async) ↓ PixabayDataScheduler.initializeData() ↓ @@ -24,7 +24,7 @@ CompletableFuture 병렬 처리 CompletableFuture 병렬 처리 Pixabay API Pixabay API │ │ ▼ ▼ -RedisDataStorage.setListData() RedisDataStorage.setListData() + RedisDataStorage.setData() RedisDataStorage.setData() │ │ ▼ ▼ DataCollectionResult 반환 DataCollectionResult 반환 @@ -39,7 +39,7 @@ Discord 성공 알림 전송 Discord 성공 알림 전송 ``` ### 스케줄링 전략 -- `@PostConstruct`: 서버 시작 시 즉시 수집 +- `ApplicationReadyEvent`: 서버 구동 완료 시 비동기(Virtual Thread)로 즉시 수집 시작 (부팅 지연 방지) - `@Scheduled(cron = "0 0 3 * * *")`: 매일 새벽 3시 - `@Scheduled(fixedRate = 21600000)`: 6시간마다 갱신 @@ -51,7 +51,7 @@ Discord 성공 알림 전송 Discord 성공 알림 전송 - 실패 시 retry 없이 즉시 Optional.empty() 반환 - 개별 실패가 전체 수집에 영향 주지 않음 4. 응답 데이터를 DTO로 변환 -5. `RedisDataStorage`를 통해 Redis에 저장 +5. `RedisDataStorage`를 통해 Redis Set 자료구조에 저장 (O(1) 랜덤 액세스 보장) 6. **AOP After**: `DataCollectionResult` 반환값 기반으로 Discord 알림 전송 - 총 아이템 수, 성공/실패 필터 수, 소요 시간 포함 - Virtual Thread로 비동기 전송 (메인 워크플로우 지연 없음) From 6f320dd7171ba603bfb6691c2016c3a578fa6715 Mon Sep 17 00:00:00 2001 From: yuuuuuuyu Date: Fri, 6 Mar 2026 16:05:21 +0900 Subject: [PATCH 13/13] docs: Add metrics guide --- docs/monitoring/METRICS_GUIDE.md | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/monitoring/METRICS_GUIDE.md diff --git a/docs/monitoring/METRICS_GUIDE.md b/docs/monitoring/METRICS_GUIDE.md new file mode 100644 index 0000000..e748038 --- /dev/null +++ b/docs/monitoring/METRICS_GUIDE.md @@ -0,0 +1,60 @@ +# Metrics Monitoring Guide + +Micrometer 표준 API 및 `Timer.Sample`을 활용한 정밀한 측정이 적용되었습니다. + +## 1. 공통 인프라 메트릭 (Core Module) + +`core` 모듈에 설정된 메트릭은 이를 사용하는 모든 서비스(`api`, `data`)에서 공통으로 수집됩니다. + +### Redis 메트릭 +| 메트릭 이름 | 타입 | 태그 | 설명 | +| :--- | :--- | :--- | :--- | +| `redis.pipeline.duration` | Timer | `key` | Redis 파이프라인(벌크 저장) 소요 시간 | +| `redis.random.access` | Counter | `key`, `status` (hit/miss) | 랜덤 데이터 조회 성공/실패 횟수 | + +### Discord 알림 메트릭 +| 메트릭 이름 | 타입 | 태그 | 설명 | +| :--- | :--- | :--- | :--- | +| `discord.webhook.sent` | Counter | `status` (success/failure) | Discord 웹훅 전송 성공/실패 횟수 | + +### AOP 작업 메트릭 +| 메트릭 이름 | 타입 | 태그 | 설명 | +| :--- | :--- | :--- | :--- | +| `task.execution.duration` | Timer | `service`, `task` | `@NotifyDiscord` 작업의 소요 시간 (Timer.Sample 적용) | +| `task.execution.total` | Counter | `task`, `status` (success/failure) | 작업 실행 횟수 및 성공/실패 여부 | + +--- + +## 2. 데이터 수집 메트릭 (Data Module) + +`data` 서비스의 수집 프로세스 상태를 모니터링합니다. + +| 메트릭 이름 | 타입 | 태그 | 설명 | +| :--- | :--- | :--- | :--- | +| `pixabay.collection.items` | Counter | `type` (Video/Music) | 수집 및 저장된 총 아이템 수 | +| `pixabay.collection.filters` | Counter | `type`, `status` (success/failure) | 카테고리/장르별 수집 성공 및 실패 횟수 | + +--- + +## 3. API 서비스 메트릭 (Api Module) + +`api` 서비스의 요청 처리 및 예외 상황을 모니터링합니다. + +| 메트릭 이름 | 타입 | 태그 | 설명 | +| :--- | :--- | :--- | :--- | +| `api.errors.total` | Counter | `code`, `status` | `GlobalExceptionHandler`에서 처리된 에러 코드별 횟수 | + +--- + +## 4. Grafana 활용 팁 + +### Prometheus Query 예시 +- **Redis 조회 실패율:** `sum(rate(redis_random_access_total{status="miss"}[5m])) / sum(rate(redis_random_access_total[5m]))` +- **전체 에러 발생 추이:** `sum by (code) (rate(api_errors_total[1m]))` +- **수집 데이터 처리량:** `sum by (type) (increase(pixabay_collection_items_total[24h]))` +- **작업 평균 소요 시간:** `rate(task_execution_duration_seconds_sum[5m]) / rate(task_execution_duration_seconds_count[5m])` + +### 대시보드 구성 권장 +1. **[Core] Infrastructure Dashboard:** Redis Connection Pool, Redis Latency, Discord Webhook Status +2. **[Data] Collection Dashboard:** Collection Success Rate, Items Count, Scheduled Task Duration +3. **[Api] Service Dashboard:** Request Latency (Actuator 기본), Error Code Count (api.errors.total)