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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gemini/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public class PixabayController {
public abstract class PixabayDataCollector<T, R> {
public void collectAndStore() {
List<T> dataList = fetchAllData();
redisDataStorage.setListData(getStorageKey(), dataList);
redisDataStorage.setData(getStorageKey(), dataList);
}

protected abstract String getStorageKey();
Expand Down Expand Up @@ -135,10 +135,10 @@ public class PixabayVideoCollector extends PixabayDataCollector<...> {
// Redis 저장소 추상화
@Component
public class RedisDataStorage {
public <T> void setListData(String key, List<T> data) { ... }
public <T> void setData(String key, Collection<T> data) { ... } // Set 자료구조 사용 (O(1))
public <T> T getRandomElement(String key, Class<T> type, ErrorCode errorCode) { ... }
}

```
@Component
public class RedisMessageStorage {
public void saveMessage(String content) { ... }
Expand Down
2 changes: 1 addition & 1 deletion .gemini/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ docker exec -it 4d4cat-redis redis-cli
KEYS *

# 데이터 확인
LRANGE pixabayVideos 0 10
SMEMBERS pixabayVideos
GET message:last
```

Expand Down
5 changes: 3 additions & 2 deletions .gemini/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 모듈
Expand Down
8 changes: 4 additions & 4 deletions .gemini/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

### Pixabay 데이터 수집 (PixabayDataScheduler)
```
[Server Startup] → @PostConstruct
[Server Startup] → ApplicationReadyEvent (Async)
PixabayDataScheduler.initializeData()
Expand All @@ -24,7 +24,7 @@ CompletableFuture 병렬 처리 CompletableFuture 병렬 처리
Pixabay API Pixabay API
│ │
▼ ▼
RedisDataStorage.setListData() RedisDataStorage.setListData()
RedisDataStorage.setData() RedisDataStorage.setData()
│ │
▼ ▼
DataCollectionResult 반환 DataCollectionResult 반환
Expand All @@ -39,7 +39,7 @@ Discord 성공 알림 전송 Discord 성공 알림 전송
```

### 스케줄링 전략
- `@PostConstruct`: 서버 시작 시 즉시 수집
- `ApplicationReadyEvent`: 서버 구동 완료 시 비동기(Virtual Thread)로 즉시 수집 시작 (부팅 지연 방지)
- `@Scheduled(cron = "0 0 3 * * *")`: 매일 새벽 3시
- `@Scheduled(fixedRate = 21600000)`: 6시간마다 갱신

Expand All @@ -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로 비동기 전송 (메인 워크플로우 지연 없음)
Expand Down
20 changes: 0 additions & 20 deletions api/src/main/java/com/services/api/config/CorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -29,20 +25,4 @@ public void addCorsMappings(CorsRegistry registry) {
.allowCredentials(true)
.maxAge(3600);
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,9 +22,15 @@
public class GlobalExceptionHandler {

private final MessageSource messageSource;
private final MeterRegistry registry;

private ResponseEntity<BaseResponse<Void>> 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<Void> response = BaseResponse.of(status, e.getErrorCode().getCode(), message);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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());
}
Expand Down
54 changes: 54 additions & 0 deletions api/src/test/java/com/services/api/pixabay/PixabayServiceTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 4 additions & 0 deletions api/src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
spring:
messages:
basename: messages

logging:
level:
com.services: DEBUG
Expand Down
3 changes: 3 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,28 +33,53 @@ public class DiscordNotifierAspect {
private static final String BOT_USERNAME = "Application Event Bot";

private final DiscordWebhookService discordWebhookService;
private final Optional<MessageSource> messageSource; // Optional dependency
private final Optional<MessageSource> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,24 +19,31 @@
public class RedisDataStorage {

private final RedisTemplate<String, Object> redisTemplate;
private final MeterRegistry registry;

public <T> void setListData(String key, List<T> data) {
public <T> void setData(String key, Collection<T> 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 <T> RedisCallback<Object> createPipelineCallback(String key, List<T> data) {
private <T> RedisCallback<Object> createPipelineCallback(String key, Collection<T> data) {
return connection -> {
RedisSerializer<String> keySerializer = redisTemplate.getStringSerializer();
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
Expand All @@ -46,7 +54,7 @@ private <T> RedisCallback<Object> createPipelineCallback(String key, List<T> dat

for (T item : data) {
byte[] valueBytes = valueSerializer.serialize(item);
connection.rPush(keyBytes, valueBytes);
connection.sAdd(keyBytes, valueBytes);
}

return null;
Expand All @@ -55,12 +63,11 @@ private <T> RedisCallback<Object> createPipelineCallback(String key, List<T> dat

@SuppressWarnings("unchecked")
public <T> Optional<T> getRandomElement(String key, Class<T> 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);
}

Expand Down
Loading