diff --git a/.claude/architecture.md b/.claude/architecture.md index 58affea..36377ef 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -1,22 +1,71 @@ # Architecture & Design Patterns -## 계층형 아키텍처 +## 멀티모듈 아키텍처 -### Presentation Layer (`presentation`) -- HTTP 요청 수신/응답, JSON 변환, 검증 -- Controller 클래스 (예: `PixabayController`, `MessageController`) +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Pixabay API │────▶│ Data Server │────▶│ Redis │ +└─────────────┘ │ (1대) │ │ (Docker) │ + └─────────────┘ └──────┬──────┘ + │ + ┌─────────────┐ │ + │ API Server │◀───────────┘ + │ (N대) │ 조회/저장 + └─────────────┘ +``` -### Application Layer (`application`) -- 핵심 비즈니스 로직, 도메인 모델 관리 -- Service 클래스 (예: `PixabayVideoService`, `MessageService`) +### 모듈 의존성 +``` +api-server ──▶ core +data-server ──▶ core +``` -### Infrastructure Layer (`infrastructure`) -- 외부 API 통신, 저장소, 환경 설정 -- Configuration, Storage, External API Client +## 모듈별 계층 구조 -### Common/Util (`aop`, `util`) -- 횡단 관심사 및 유틸리티 -- Aspect, Exception Handler, Config +### core 모듈 (공통 라이브러리) +``` +com.services.core/ +├── aop/ # AOP (Discord 알림 어노테이션 및 Aspect) +│ ├── NotifyDiscord.java (어노테이션) +│ └── DiscordNotifierAspect.java (AOP) +├── config/ # Redis 설정 +├── dto/ # 공통 DTO (BaseResponse, ApiResponse) +├── exception/ # 공통 예외 클래스 +├── infrastructure/ # Redis 저장소, ApiMetadata +├── message/ # 메시지 DTO, Validator +├── notification/ # 알림 관련 +│ ├── DataCollectionResult.java (데이터 수집 결과 DTO) +│ └── discord/ # Discord 웹훅 +│ ├── DiscordWebhookService.java +│ ├── DiscordWebhookPayload.java +│ ├── Embed.java +│ └── Footer.java +├── pixabay/dto/ # Pixabay DTO +└── util/ # 유틸리티 +``` + +### data-server 모듈 +``` +com.services.data/ +├── config/ # RestClient 설정 +├── pixabay/ # 데이터 수집기 +│ ├── PixabayDataCollector.java (추상 클래스) +│ ├── PixabayVideoCollector.java (@NotifyDiscord 사용) +│ └── PixabayMusicCollector.java (@NotifyDiscord 사용) +└── scheduler/ # 스케줄러 + └── PixabayDataScheduler.java +``` + +### api-server 모듈 +``` +com.services.api/ +├── config/ # CORS, MessageSource 설정 +├── message/ # Message API (@NotifyDiscord 사용) +├── omniwatch/ # JPA 엔티티 +├── pixabay/ # Pixabay API +├── presentation/ # GlobalExceptionHandler +└── util/ # WebUtils +``` ## 주요 디자인 패턴 @@ -24,57 +73,177 @@ ```java @RequiredArgsConstructor // 생성자 주입 public class PixabayController { - private final PixabayVideoService service; // final 필드 + private final PixabayService service; // final 필드 } ``` -### DTO 패턴 +### 템플릿 메서드 패턴 (data-server) ```java -// record 타입 사용 (불변) -public record PixabayVideoResult( - Long id, - String pageURL, - String type -) {} +// 추상 클래스에서 공통 로직 정의 +public abstract class PixabayDataCollector { + public void collectAndStore() { + List dataList = fetchAllData(); + redisDataStorage.setListData(getStorageKey(), dataList); + } + + protected abstract String getStorageKey(); + protected abstract List getFilters(); + + // API 호출 (단순화된 에러 처리) + protected Optional fetchDataForFilter(String filter) { + try { + String uri = buildUri(filter).toUriString(); + R result = restClient.get().uri(uri).retrieve().body(getResponseTypeReference()); + return Optional.ofNullable(result); + } catch (Exception e) { + log.error("Failed to fetch data for filter '{}'", filter, e); + return Optional.empty(); // 실패 시 retry 없이 즉시 반환 + } + } +} + +// 하위 클래스에서 구체적인 구현 +@Component +public class PixabayVideoCollector extends PixabayDataCollector<...> { + @Override + protected String getStorageKey() { + return ApiMetadata.PIXABAY_VIDEOS.getKey(); + } +} ``` -### 관점 지향 프로그래밍 (AOP) +### Repository 패턴 (core) ```java +// Redis 저장소 추상화 +@Component +public class RedisDataStorage { + public void setListData(String key, List data) { ... } + public T getRandomElement(String key, Class type, ErrorCode errorCode) { ... } +} + +@Component +public class RedisMessageStorage { + public void saveMessage(String content) { ... } + public Optional getMessage() { ... } +} +``` + +### 관점 지향 프로그래밍 (AOP, core 모듈) +```java +// core 모듈에서 제공하는 공통 AOP @Aspect @Component -public class DataInitializationAspect { - @Around("execution(* ..DataInitializationService.initializeData(..))") - public Object logExecutionTime(ProceedingJoinPoint joinPoint) { - // 횡단 관심사 처리 +public class DiscordNotifierAspect { + @Around("@annotation(notifyDiscord)") + public Object notifyEvent(ProceedingJoinPoint joinPoint, NotifyDiscord notifyDiscord) { + // 메서드 실행 전: 시작 로깅, 시간 기록 + Object result = joinPoint.proceed(); + // 메서드 실행 후: Discord 알림 전송 (성공/실패) + return result; } } -``` -### 템플릿 메서드 패턴 -- `DataInitializationService`에서 전체 흐름 정의 -- 하위 클래스에서 세부 구현 +// data-server에서 사용 예시 +@Component +public class PixabayVideoCollector extends PixabayDataCollector { + @Override + @NotifyDiscord(taskName = "Pixabay 비디오 수집") + public DataCollectionResult collectAndStore() { + return super.collectAndStore(); + } +} -### 전략 패턴 -- `ParameterBuilder`를 통해 파라미터 생성 로직 캡슐화 +// api-server에서 사용 예시 +@Service +public class MessageService { + @NotifyDiscord(taskName = "메시지 저장") + public void saveMessage(String content) { + // ... + } +} +``` -### 싱글턴 패턴 -- Spring Bean으로 관리되는 모든 컴포넌트 +### DTO 패턴 +```java +// record 타입 사용 (불변) +public record PixabayVideoResult( + Integer id, + String pageURL, + String tags +) implements Serializable {} +``` ## 설계 원칙 ### 불변성 (Immutability) - `record` 타입 적극 활용 - `final` 필드 선언 -- 불변 컬렉션 반환 (`List.of()`, `Collections.unmodifiableList()`) +- 불변 컬렉션 반환 (`List.of()`) ### SOLID 원칙 -- **SRP:** 각 클래스는 하나의 책임만 -- **OCP:** 확장에는 열려있고, 수정에는 닫혀있음 +- **SRP:** 각 클래스는 하나의 책임만 (Collector는 수집만, Storage는 저장만) +- **OCP:** `PixabayDataCollector` 확장으로 새로운 데이터 타입 추가 가능 - **LSP:** 상위 타입을 하위 타입으로 대체 가능 - **ISP:** 클라이언트는 사용하지 않는 인터페이스에 의존하지 않음 -- **DIP:** 구체적인 것이 아닌 추상화에 의존 +- **DIP:** 구체적인 것이 아닌 추상화에 의존 (RedisDataStorage 인터페이스화 가능) ### Fail-Fast & Graceful Degradation - 입력 검증은 빠르게 실패 -- 외부 API 호출 실패는 우아하게 처리 -- `CompletableFuture.exceptionally()`를 통한 개별 실패 처리 +- 외부 API 호출 실패는 우아하게 처리 (retry 없이 즉시 실패 처리) +- `CompletableFuture`를 통한 개별 실패 처리 +- 개별 필터 실패가 전체 데이터 수집에 영향 주지 않음 + +## HTTP 클라이언트 및 동시성 + +### RestClient (Spring 6.1+) +모든 HTTP 통신에 `RestClient` 사용 (RestTemplate, WebClient 대체) +```java +// data-server: Pixabay API 호출 +restClient.get().uri(uri).retrieve().body(responseType); + +// api-server: Discord 웹훅 전송 +restClient.post().contentType(MediaType.APPLICATION_JSON).body(payload).retrieve().toBodilessEntity(); +``` + +### 가상 스레드 (Java 21) +`spring.threads.virtual.enabled=true` 설정으로 가상 스레드 활성화 + +```java +// data-server: 병렬 데이터 수집 +try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List>> futures = filters.stream() + .map(filter -> CompletableFuture.supplyAsync(() -> fetchDataForFilter(filter), executor)) + .toList(); + // ... +} + +// api-server: 비동기 Discord 알림 전송 +Thread.startVirtualThread(() -> sendMessage(payload)); +``` + +### 기존 방식 대비 장점 +| 항목 | 이전 (WebClient/RestTemplate) | 현재 (RestClient + 가상스레드) | +|------|------------------------------|-------------------------------| +| 코드 스타일 | 리액티브/동기 혼재 | 동기식 통일 | +| 의존성 | webflux 필요 | 불필요 | +| 디버깅 | 어려움 | 쉬움 | +| 동시성 | 플랫폼 스레드풀 | 가상 스레드 (경량) | + +## 데이터 흐름 + +### Data Server (데이터 수집) +``` +[Pixabay API] → [PixabayDataCollector] → [RedisDataStorage] → [Redis] +``` + +### API Server (데이터 조회) +``` +[Client] → [Controller] → [Service] → [RedisDataStorage] → [Redis] + ↓ +[Client] ← [BaseResponse] ← [Controller] +``` + +### Message 저장/조회 +``` +[Client] → [MessageController] → [MessageService] → [RedisMessageStorage] → [Redis] +``` diff --git a/.claude/deployment.md b/.claude/deployment.md index ca8c950..1b67448 100644 --- a/.claude/deployment.md +++ b/.claude/deployment.md @@ -2,6 +2,7 @@ ## 빌드 명령어 +### 전체 빌드 ```bash # 빌드 (테스트 포함) ./gradlew build @@ -12,81 +13,167 @@ # 테스트 스킵하고 빌드 ./gradlew build -x test -# 실행 가능한 JAR 생성 -./gradlew bootJar +# 코드 포맷팅 +./gradlew spotlessApply +``` + +### 모듈별 빌드 +```bash +# Core 모듈 +./gradlew :core:build -# 애플리케이션 실행 -./gradlew bootRun +# Data Server JAR 생성 +./gradlew :data:bootJar +# 결과: data/build/libs/data.jar + +# API Server JAR 생성 +./gradlew :api:bootJar +# 결과: api/build/libs/api.jar ``` -## CI/CD (GitHub Actions) +### 로컬 실행 +```bash +# Docker Compose로 전체 실행 (Redis + Data Server + API Server) +docker-compose up -d -### CI - Pull Request 시 -1. Java 21 환경 설정 -2. 프로젝트 체크아웃 -3. 테스트 실행 (`./gradlew test`) -4. 테스트 결과 게시 +# 개별 모듈 실행 +./gradlew :data:bootRun +./gradlew :api:bootRun +``` -### CD - main 브랜치 병합 시 -1. Java 21 환경 설정 -2. `bootJar` 빌드 -3. SCP로 JAR 파일 서버 전송 -4. SSH 접속하여 배포: - - 기존 프로세스 종료 - - 새 JAR 파일 실행 - - 프로세스 실행 확인 +## Docker 배포 -## 환경 변수 +### 이미지 구조 +``` +4d4cat-services/ +├── docker-compose.yml # 로컬 개발용 (전체) +├── docker-compose.data.yml # Data Server 배포용 +├── data/ +│ └── Dockerfile +└── api/ + └── Dockerfile +``` +### Docker Compose 실행 ```bash -export PIXABAY_KEY=your_pixabay_api_key -export DISCORD_WEBHOOK_URL=your_discord_webhook_url -``` +# 로컬 전체 실행 +docker-compose up -d -## systemd 서비스 설정 +# Data Server만 실행 (새 서버) +docker-compose -f docker-compose.data.yml up -d -`/etc/systemd/system/4d4cat-services.service`: +# 로그 확인 +docker-compose logs -f +``` -```ini -[Unit] -Description=4d4cat Services +### Redis 관리 +```bash +# Redis CLI 접속 +docker exec -it 4d4cat-redis redis-cli -[Service] -User=deploy -WorkingDirectory=/home/deploy/app -ExecStart=/usr/bin/java -jar /home/deploy/app/4d4cat-services.jar -Environment="PIXABAY_KEY=your_key" -Environment="DISCORD_WEBHOOK_URL=your_url" -Restart=always +# 키 확인 +KEYS * -[Install] -WantedBy=multi-user.target +# 데이터 확인 +LRANGE pixabayVideos 0 10 +GET message:last ``` -## 서비스 관리 +## CI/CD (GitHub Actions) + +### CI - Pull Request 시 (`ci.yml`) +1. Java 21 환경 설정 +2. 프로젝트 체크아웃 +3. 전체 테스트 실행 (`./gradlew test`) +4. 테스트 결과 게시 + +### CD - API Server (`cd-oci.yml`) +**트리거:** main 브랜치 병합 시 +**대상:** Oracle 서버 2대 + +1. Java 21 환경 설정 +2. `./gradlew :api:bootJar` 빌드 +3. Docker 이미지 빌드 및 푸시 +4. SSH로 Oracle 서버에 배포 + +### CD - Data Server (`cd-data.yml`) +**트리거:** main 병합 시 (core/, data/ 변경 시) +**대상:** Data Server 1대 + +1. Java 21 환경 설정 +2. `./gradlew :data:bootJar` 빌드 +3. Docker 이미지 빌드 및 푸시 +4. SSH로 Data Server에 배포 +5. Redis 컨테이너 확인/실행 + +## 환경 변수 + +### Data Server (.env) +```bash +PIXABAY_KEY=your_pixabay_api_key +PIXABAY_VIDEO_URL=https://pixabay.com/api/videos/ +PIXABAY_MUSIC_URL=https://pixabay.com/api/music/ +REDIS_HOST=localhost +REDIS_PORT=6379 +``` +### API Server (.env) ```bash -sudo systemctl start 4d4cat-services -sudo systemctl stop 4d4cat-services -sudo systemctl restart 4d4cat-services -sudo systemctl status 4d4cat-services -sudo journalctl -u 4d4cat-services -f # 로그 확인 +REDIS_HOST= +REDIS_PORT=6379 +CORS_ALLOWED_ORIGINS=https://yourdomain.com +DISCORD_WEBHOOK_URL=your_discord_webhook_url +OMNIWATCH_DB_URL=jdbc:mysql://localhost:3306/omniwatch +OMNIWATCH_DB_USERNAME=root +OMNIWATCH_DB_PASSWORD=password ``` ## GitHub Secrets | Secret 이름 | 설명 | |------------|------| -| `SERVER_HOST` | 배포 서버 호스트 | -| `SERVER_USER` | SSH 사용자명 | -| `SERVER_SSH_KEY` | SSH Private Key | +| `DOCKER_HUB_USERNAME` | Docker Hub 사용자명 | +| `DOCKER_HUB_TOKEN` | Docker Hub 액세스 토큰 | +| `ORACLE_MAIN_IP` | Oracle 메인 서버 IP | +| `ORACLE_SUB_IP` | Oracle 서브 서버 IP | +| `ORACLE_USER` | Oracle 서버 SSH 사용자명 | +| `ORACLE_KEY` | Oracle 서버 SSH Private Key | +| `DATA_SERVER_IP` | Data Server IP | +| `DATA_SERVER_USER` | Data Server SSH 사용자명 | +| `DATA_SERVER_KEY` | Data Server SSH Private Key | | `PIXABAY_KEY` | Pixabay API 키 | -| `DISCORD_WEBHOOK_URL` | Discord 웹훅 URL | +| `SUBMODULE_TOKEN` | 서브모듈 접근용 토큰 | + +## 서버 구성 + +### Data Server (1대) +- Redis 컨테이너 +- data-server 컨테이너 +- Port: 6379 (Redis), 8081 (Data Server) + +### API Server (N대, Oracle) +- api-server 컨테이너 +- Port: 8080 +- REDIS_HOST로 Data Server IP 설정 ## 배포 전 체크리스트 - [ ] 모든 테스트가 통과하는가? - [ ] 환경 변수가 올바르게 설정되었는가? - [ ] 코드 리뷰가 완료되었는가? -- [ ] 서버 디스크 용량이 충분한가? +- [ ] Redis 연결이 정상인가? +- [ ] Data Server가 정상 동작하는가? - [ ] 롤백 계획이 수립되었는가? + +## 롤백 + +```bash +# 이전 버전 이미지로 롤백 (Oracle 서버에서) +sudo docker stop 4d4cat-api +sudo docker rm 4d4cat-api +sudo docker run -d \ + --name 4d4cat-api \ + -p 8080:8080 \ + --env-file /home/opc/.env \ + username/4d4cat-api: +``` diff --git a/.claude/project.md b/.claude/project.md index 5e69cb1..cdcfe7b 100644 --- a/.claude/project.md +++ b/.claude/project.md @@ -3,18 +3,61 @@ ## 기본 정보 - **프로젝트명:** 4d4cat-services - **목적:** 외부 API 연동 및 데이터 관리를 위한 백엔드 서비스 +- **구조:** Gradle 멀티모듈 ## 기술 스택 -- **언어:** Java 21 +- **언어:** Java 21 (가상 스레드 활성화) - **프레임워크:** Spring Boot 3.4.12 -- **빌드 도구:** Gradle +- **빌드 도구:** Gradle (Multi-Module) +- **컨테이너:** Docker, Docker Compose +- **HTTP 클라이언트:** RestClient (Spring 6.1+) + +## 모듈 구조 + +``` +4d4cat-services/ +├── core/ # 공통 라이브러리 모듈 +├── data/ # 데이터 수집 서버 (port: 8081) +└── api/ # API 서버 (port: 8080) +``` + +### core 모듈 +- 공통 예외 클래스 (`ErrorCode`, `CustomException` 등) +- 공통 DTO (`BaseResponse`, `ApiResponse`, `DataCollectionResult`) +- Redis 설정 및 저장소 (`RedisConfig`, `RedisDataStorage`, `RedisMessageStorage`) +- Pixabay DTO (`PixabayVideoResult`, `PixabayMusicResult`) +- AOP 및 알림 (`@NotifyDiscord`, `DiscordNotifierAspect`, `DiscordWebhookService`) +- 유틸리티 (`RandomUtils`) + +### data 모듈 +- Pixabay API 데이터 수집기 (`PixabayDataCollector`, `PixabayVideoCollector`, `PixabayMusicCollector`) +- 스케줄러 (`PixabayDataScheduler`) +- 서버 시작 시 데이터 초기화, 주기적 갱신 +- `@NotifyDiscord` 어노테이션을 통한 데이터 수집 알림 + +### api 모듈 +- REST API 컨트롤러 (`PixabayController`, `MessageController`) +- 서비스 (`PixabayService`, `MessageService`) +- JPA 엔티티 (`omniwatch` 패키지) +- `@NotifyDiscord` 어노테이션을 통한 메시지 저장 알림 ## 주요 라이브러리 + +### 공통 (core) +- `Spring Data Redis` - 분산 캐시 및 데이터 동기화 +- `Spring AOP` - 횡단 관심사 처리 (Discord 알림) +- `Spring Web` - RestClient (Discord 웹훅 전송) +- `Lombok` - 보일러플레이트 코드 감소 + +### data-server +- `Spring Web` - RestClient를 통한 API 호출 +- `Spring Scheduling` - 주기적 데이터 수집 +- `Virtual Threads` - 가상 스레드 기반 병렬 데이터 수집 + +### api-server - `Spring Web` - REST API 개발 -- `Spring AOP` - 관점 지향 프로그래밍 -- `Spring WebFlux (WebClient)` - 비동기 HTTP 통신 +- `Spring Data JPA` - 데이터베이스 연동 - `SpringDoc OpenAPI (2.8.14)` - API 문서 자동화 (Swagger UI) -- `Lombok` - 보일러플레이트 코드 감소 - `MessageSource` - 메시지 중앙 관리 (YAML 기반) ## 외부 서비스 연동 @@ -22,16 +65,39 @@ ### Pixabay API - **목적:** 비디오 및 음악 데이터 조회 - **인증:** 환경 변수 `PIXABAY_KEY` 필요 -- **사용:** 애플리케이션 시작 시 데이터 초기화 및 실시간 검색 +- **호출 위치:** data-server만 호출 (API 중복 호출 방지) + +### Redis +- **목적:** 서버 간 데이터 동기화 및 캐싱 +- **데이터:** Pixabay 비디오/음악 목록, 메시지 +- **설정:** 환경 변수 `REDIS_HOST`, `REDIS_PORT` ### Discord Webhook -- **목적:** 주요 이벤트 실시간 알림 (데이터 초기화 성공/실패 등) +- **목적:** 주요 이벤트 실시간 알림 (데이터 수집, 메시지 저장 등) - **설정:** 환경 변수 `DISCORD_WEBHOOK_URL` 필요 -- **전송 방식:** `WebClient`를 통한 비동기 전송 +- **구현 위치:** core 모듈 (공통 AOP) +- **사용 위치:** data-server (데이터 수집), api-server (메시지 저장) ## 필수 환경 변수 ```bash +# Pixabay API (data-server) PIXABAY_KEY=your_pixabay_api_key +PIXABAY_VIDEO_URL=https://pixabay.com/api/videos/ +PIXABAY_MUSIC_URL=https://pixabay.com/api/music/ + +# Redis (core) +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Discord (api-server) DISCORD_WEBHOOK_URL=your_discord_webhook_url + +# CORS (api-server) +CORS_ALLOWED_ORIGINS=http://localhost:3000 + +# Database (api-server) +OMNIWATCH_DB_URL=jdbc:mysql://localhost:3306/omniwatch +OMNIWATCH_DB_USERNAME=root +OMNIWATCH_DB_PASSWORD=password ``` diff --git a/.claude/testing.md b/.claude/testing.md index 6eb55ed..03cb92c 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -1,5 +1,29 @@ # Testing Guide +## 테스트 실행 명령어 + +### 전체 테스트 +```bash +./gradlew test +``` + +### 모듈별 테스트 +```bash +# Core 모듈 테스트 +./gradlew :core:test + +# Data Server 테스트 +./gradlew :data-server:test + +# API Server 테스트 +./gradlew :api-server:test +``` + +### 특정 테스트 클래스 실행 +```bash +./gradlew :api-server:test --tests "PixabayServiceTest" +``` + ## 공통 규칙 ### 테스트 클래스명 @@ -7,6 +31,7 @@ ```java PixabayControllerTest.java MessageServiceTest.java +RedisDataStorageTest.java ``` ### 테스트 메소드명 @@ -32,59 +57,123 @@ void getVideo_shouldReturnVideoData() {} void saveMessage_shouldWork() { // Given (준비) String content = "Hello"; - + // When (실행) - Message result = service.save(content); - + service.saveMessage(new MessageRequest(content)); + // Then (검증) - assertThat(result.getContent()).isEqualTo(content); + String result = service.getMessage(); + assertThat(result).isEqualTo(content); } ``` -## Controller 테스트 +## 모듈별 테스트 예시 +### Core 모듈 (단위 테스트) +```java +@ExtendWith(MockitoExtension.class) +class RedisDataStorageTest { + @Mock private RedisTemplate redisTemplate; + @Mock private ListOperations listOperations; + @InjectMocks private RedisDataStorage storage; + + @Test + @DisplayName("리스트 데이터 저장 - 성공") + void setListData_shouldStoreData() { + // Given + when(redisTemplate.opsForList()).thenReturn(listOperations); + List data = List.of("item1", "item2"); + + // When + storage.setListData("key", data); + + // Then + verify(redisTemplate).delete("key"); + verify(listOperations).rightPushAll(eq("key"), any(Object[].class)); + } +} +``` + +### API Server (Controller 테스트) ```java @WebMvcTest(PixabayController.class) class PixabayControllerTest { @Autowired private MockMvc mockMvc; - @MockBean private PixabayVideoService service; - + @MockBean private PixabayService service; + @Test - @DisplayName("GET /api/v1/pixabay/videos - 성공") + @DisplayName("GET /video - 성공") void getVideo_shouldReturnVideoData() throws Exception { // Given - when(service.getRandomElement()).thenReturn(videoResult); - + PixabayVideoResult video = PixabayVideoResult.builder() + .id(1) + .pageURL("https://example.com") + .build(); + when(service.getRandomVideo()).thenReturn(video); + // When & Then - mockMvc.perform(get("/api/v1/pixabay/videos")) + mockMvc.perform(get("/video")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.data.id").value(1)); } } ``` -## Service 테스트 - +### API Server (Service 테스트) ```java @ExtendWith(MockitoExtension.class) class MessageServiceTest { - @Mock private MessageRepository repository; + @Mock private RedisMessageStorage storage; @InjectMocks private MessageService service; - + @Test @DisplayName("메시지 저장 - 성공") - void saveMessage_shouldReturnSavedMessage() { + void saveMessage_shouldWork() { + // Given + MessageRequest request = new MessageRequest("Hello"); + + // When + service.saveMessage(request); + + // Then + verify(storage).saveMessage("Hello"); + } + + @Test + @DisplayName("메시지 저장 - 유효하지 않은 내용") + void saveMessage_whenInvalidContent_shouldThrowException() { + // Given + MessageRequest request = new MessageRequest(""); + + // When & Then + assertThatThrownBy(() -> service.saveMessage(request)) + .isInstanceOf(BadRequestException.class); + } +} +``` + +### Data Server (Collector 테스트) +```java +@ExtendWith(MockitoExtension.class) +class PixabayVideoCollectorTest { + @Mock private RestTemplate restTemplate; + @Mock private Environment environment; + @Mock private RedisDataStorage storage; + @InjectMocks private PixabayVideoCollector collector; + + @Test + @DisplayName("비디오 데이터 수집 및 저장") + void collectAndStore_shouldFetchAndSaveData() { // Given - String content = "Hello"; - when(repository.save(any())).thenReturn(message); - + when(environment.getProperty(anyString())).thenReturn("https://api.pixabay.com"); + // ... RestTemplate mock 설정 + // When - Message result = service.save(content); - + collector.collectAndStore(); + // Then - assertThat(result.getContent()).isEqualTo(content); - verify(repository).save(any()); + verify(storage).setListData(eq("pixabayVideos"), anyList()); } } ``` @@ -133,3 +222,4 @@ verify(service, never()).getData(); - [ ] 독립적으로 실행 가능한가? - [ ] 예외 상황도 테스트했는가? - [ ] Mock 객체의 호출을 검증했는가? +- [ ] Redis Mock이 필요한 경우 적절히 처리했는가? diff --git a/.claude/workflows.md b/.claude/workflows.md index a4e3d6e..0ebcc53 100644 --- a/.claude/workflows.md +++ b/.claude/workflows.md @@ -1,79 +1,122 @@ # Domain Workflows -## Pixabay 도메인 +## Data Server 워크플로우 -### 워크플로우 +### Pixabay 데이터 수집 (PixabayDataScheduler) ``` -[Client] → [Controller] → [Service] → [RestTemplate] → [Pixabay API] - ↓ - [DTO 변환] ← [API Response] - ↓ -[Client] ← [BaseResponse] ← [Controller] +[Server Startup] → @PostConstruct + ↓ + PixabayDataScheduler.initializeData() + ↓ + ┌───────────────┴───────────────┐ + ▼ ▼ +PixabayVideoCollector PixabayMusicCollector +@NotifyDiscord("Pixabay 비디오 수집") @NotifyDiscord("Pixabay 음악 수집") + │ │ + ↓ (AOP Intercept) ↓ (AOP Intercept) +DiscordNotifierAspect DiscordNotifierAspect + │ │ + ▼ ▼ +CompletableFuture 병렬 처리 CompletableFuture 병렬 처리 +(20개 카테고리) (32개 장르) + │ │ + ▼ ▼ + Pixabay API Pixabay API + │ │ + ▼ ▼ +RedisDataStorage.setListData() RedisDataStorage.setListData() + │ │ + ▼ ▼ +DataCollectionResult 반환 DataCollectionResult 반환 + │ │ + ▼ ▼ +Discord 성공 알림 전송 Discord 성공 알림 전송 +(비동기, Virtual Thread) (비동기, Virtual Thread) + │ │ + └───────────────┬───────────────┘ + ▼ + [Redis] ``` -### 엔드포인트 -- `GET /api/v1/pixabay/videos` - 비디오 검색 -- `GET /api/v1/pixabay/musics` - 음악 검색 +### 스케줄링 전략 +- `@PostConstruct`: 서버 시작 시 즉시 수집 +- `@Scheduled(cron = "0 0 3 * * *")`: 매일 새벽 3시 +- `@Scheduled(fixedRate = 21600000)`: 6시간마다 갱신 ### 처리 과정 -1. 클라이언트 요청 (검색어, 페이지 파라미터) -2. Controller가 Service 호출 -3. Service는 `RestTemplate`로 Pixabay API 호출 -4. API 응답을 DTO로 변환 -5. `BaseResponse`로 래핑하여 클라이언트에 반환 +1. 스케줄러가 Collector의 `collectAndStore()` 호출 +2. **AOP Intercept**: `@NotifyDiscord` 어노테이션 감지, 시작 로깅 +3. Collector가 필터별로 병렬 API 호출 (Virtual Thread Pool) + - 각 필터는 독립적으로 실행 + - 실패 시 retry 없이 즉시 Optional.empty() 반환 + - 개별 실패가 전체 수집에 영향 주지 않음 +4. 응답 데이터를 DTO로 변환 +5. `RedisDataStorage`를 통해 Redis에 저장 +6. **AOP After**: `DataCollectionResult` 반환값 기반으로 Discord 알림 전송 + - 총 아이템 수, 성공/실패 필터 수, 소요 시간 포함 + - Virtual Thread로 비동기 전송 (메인 워크플로우 지연 없음) -## Message 도메인 +## API Server 워크플로우 -### 메시지 저장 (POST /api/v1/messages) +### Pixabay 도메인 + +#### 랜덤 비디오 조회 (GET /video) ``` -[Client] → [Controller] → [Service] - ↓ - 1. 내용 유효성 검사 - 2. Message 객체 생성 - 3. messageStore.put("lastMessage", message) - ↓ -[Client] ← [BaseResponse] ← [Controller] +[Client] → [PixabayController] → [PixabayService] + ↓ + RedisDataStorage.getRandomElement() + ↓ + [Redis] + ↓ +[Client] ← [BaseResponse] ← [PixabayVideoResult] ``` -### 메시지 조회 (GET /api/v1/messages) +#### 랜덤 음악 조회 (GET /music) ``` -[Client] → [Controller] → [Service] → messageStore.get("lastMessage") - ↓ -[Client] ← [BaseResponse] ← [content 추출] +[Client] → [PixabayController] → [PixabayService] + ↓ + RedisDataStorage.getRandomElement() + ↓ + [Redis] + ↓ +[Client] ← [BaseResponse] ← [PixabayMusicResult] ``` -### 특징 -- 인메모리 저장소 사용 -- 항상 마지막 메시지만 유지 -- 클라이언트 IP 자동 수집 +### Message 도메인 -## Common 도메인 +#### 메시지 저장 (POST /message) +``` +[Client] → [MessageController] → [MessageService] + ↓ + 1. MessageValidator.isValid() + 2. RedisMessageStorage.saveMessage() + ↓ + [Redis] + ↓ + @NotifyDiscord → Discord 알림 + ↓ +[Client] ← [200 OK] +``` -### 데이터 초기화 (DataInitializationService) +#### 메시지 조회 (GET /message) ``` -[Application Startup] → ApplicationReadyEvent - ↓ - initializeData() 실행 - ↓ - CompletableFuture 병렬 처리 - ├─ 비디오 데이터 - ├─ 음악 데이터 - └─ 기타 데이터 - ↓ - DataStorage 저장 +[Client] → [MessageController] → [MessageService] + ↓ + RedisMessageStorage.getMessage() + ↓ + [Redis] + ↓ +[Client] ← [String content] ``` -**특징:** -- 애플리케이션 시작 시 자동 실행 -- `CompletableFuture`를 통한 병렬 처리 -- 개별 API 실패 시 `exceptionally()`로 복구 +### 특징 +- Redis 기반 저장소 (서버 간 동기화) +- 항상 마지막 메시지만 유지 +- 저장 시 Discord 웹훅 알림 -### AOP 로깅 (DataInitializationAspect) -- 실행 시간 측정 -- 성공/실패 결과를 Discord 웹훅으로 알림 -- `MessageSource`를 통해 에러 메시지 조회 +## 공통 워크플로우 -### 전역 예외 처리 (GlobalExceptionHandler) +### 전역 예외 처리 (GlobalExceptionHandler, api-server) ``` [Any Layer] → 예외 발생 ↓ @@ -86,11 +129,35 @@ [Client] ← JSON 에러 응답 ``` -### Discord 웹훅 (DiscordWebhookService) -- `WebClient`를 사용한 비동기 전송 -- Fire-and-forget 방식 (재시도 없음) +### AOP Discord 알림 (core 모듈 - 공통) +``` +@NotifyDiscord 어노테이션 메서드 실행 + ↓ + DiscordNotifierAspect.notifyEvent() (core 모듈) + ↓ + 1. 실행 시간 측정 시작 + 2. 메서드 실행 (joinPoint.proceed()) + 3. 결과에 따라 성공/실패 메시지 생성 + - DataCollectionResult: 통계 정보 포함 + - 일반 메서드: 실행 시간만 포함 + 4. DiscordWebhookService.sendMessageAsync() + ↓ + [Discord Webhook] (비동기, Virtual Thread) +``` + +**사용 위치:** +- **data-server**: 데이터 수집 완료 시 (PixabayVideoCollector, PixabayMusicCollector) +- **api-server**: 메시지 저장 시 (MessageService) + +### Redis 데이터 저장소 (core) +```java +// 리스트 데이터 저장 (data-server) +RedisDataStorage.setListData("pixabayVideos", videoList) -### 데이터 저장소 (DataStorage) -- 인메모리 저장소 -- Spring Bean으로 싱글턴 관리 -- Thread-safe 고려 필요 시 `ConcurrentHashMap` 사용 +// 랜덤 요소 조회 (api-server) +RedisDataStorage.getRandomElement("pixabayVideos", PixabayVideoResult.class, ErrorCode) + +// 메시지 저장/조회 +RedisMessageStorage.saveMessage("Hello") +RedisMessageStorage.getMessage() → Optional +``` diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index 8f96f10..e773f2f 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -1,177 +1,16 @@ # 프로젝트 통합 가이드 (Project Unified Instructions) -이 문서는 `4d4cat-services` 프로젝트의 AI 어시스턴트를 위한 통합 가이드라인입니다. 아래 정의된 페르소나, 프로젝트 문맥, 개발 규칙을 숙지하고 코드를 생성하거나 리뷰할 때 엄격히 준수하십시오. +이 디렉토리는 `4d4cat-services` 프로젝트의 AI 어시스턴트를 위한 통합 가이드라인입니다. +아래 정의된 페르소나, 프로젝트 문맥, 개발 규칙을 숙지하고 코드를 생성하거나 리뷰할 때 엄격히 준수하십시오. +또한, 모든 라이브러리/API 문서, 코드 생성, 설정 또는 설정 단계가 필요할 때 항상 Context7 MCP를 사용하세요. ---- +## 파일 구조 -## 1. AI 어시스턴트 가이드 (Agent Guide) -모든 라이브러리/API 문서, 코드 생성, 설정 또는 설정 단계가 필요할 때 항상 Context7 MCP를 사용하세요. +- `project.md` - 프로젝트 개요, 기술 스택, 외부 서비스 +- `architecture.md` - 아키텍처, 디자인 패턴, 설계 원칙 +- `workflows.md` - 도메인별 상세 워크플로우 +- `standards.md` - 코딩 스타일 가이드 +- `testing.md` - 테스트 작성 규칙 및 예제 +- `deployment.md` - 빌드 및 배포 가이드 -### 1.1. 페르소나 (Persona) -당신은 이 프로젝트의 아키텍처와 코드 스타일을 깊이 이해하고 있는 **시니어 소프트웨어 엔지니어**입니다. 항상 명확성(Clarity), 유지보수성(Maintainability), 성능(Performance)을 고려하여 답변합니다. - -### 1.2. 목표 (Objective) -1. **코드 품질 향상**: 이 문서에 정의된 기술 스택, 아키텍처 원칙, 테스트 규칙을 기반으로 코드 개선을 제안합니다. -2. **불변성(Immutability) 지향**: 가능한 경우 `record`나 불변 컬렉션을 사용하도록 권장합니다. -3. **테스트 코드 강조**: 새로운 기능 추가 또는 리팩토링 시, 관련된 테스트 코드 작성을 상기시키고 제안합니다. -4. **명확한 설명**: 코드 변경 제안 시, "왜" 그렇게 변경해야 하는지에 대한 기술적 근거를 명확하게 설명합니다. - ---- - -## 2. 프로젝트 분석 (Project Context) - -### 2.1. 기술 스택 및 환경 -- **프로그래밍 언어:** Java 21 -- **프레임워크:** Spring Boot 3.4.12 -- **빌드 도구:** Gradle -- **주요 라이브러리:** - - `Spring Web`: REST API 및 웹 기능 개발 - - `Spring AOP`: 관점 지향 프로그래밍 (로깅 등 공통 기능) - - `Spring WebFlux (WebClient)`: 비동기 HTTP 통신 (Discord 웹훅) - - `SpringDoc OpenAPI`: API 문서 자동화 (Swagger UI) - - `Lombok`: 보일러플레이트 코드 감소 - - `MessageSource`: 메시지 중앙 관리 (YAML 기반) -- **외부 서비스:** - - **Pixabay API:** Pixabay와의 연동을 통해 비디오 및 음악 데이터를 가져옵니다. `PIXABAY_KEY` 환경 변수를 통해 API 키를 설정해야 합니다. - - **Discord Webhook:** 애플리케이션의 주요 이벤트(데이터 초기화 성공/실패 등)를 실시간으로 알립니다. `DISCORD_WEBHOOK_URL` 환경 변수를 통해 웹훅 URL을 설정해야 합니다. - -### 2.2. 개발 환경 (`build.gradle` 기반) -- **의존성 관리:** `Gradle` 및 `io.spring.dependency-management` 플러그인 -- **주요 의존성:** - - `spring-boot-starter-web`: RESTful API 개발을 위한 핵심 모듈 - - `spring-boot-starter-aop`: AOP(관점 지향 프로그래밍) 지원 - - `spring-boot-starter-webflux`: `WebClient` 사용을 위한 비동기 웹 스택 - - `springdoc-openapi-starter-webmvc-ui:2.8.14`: API 문서를 위한 Swagger UI 통합 - - `net.rakugakibox.util:yaml-resource-bundle:1.1`: YAML 기반 `MessageSource` 지원 - - `lombok`: 어노테이션 기반으로 상용구 코드를 자동 생성 - - `spring-boot-devtools`: 개발 시 코드 변경 사항 자동 재시작 지원 - - `spring-boot-starter-test`: JUnit 5 기반의 테스트 환경 지원 - -### 2.3. 도메인별 워크플로우 - -#### 2.3.1. Pixabay 도메인 (외부 API 연동) -- **목적:** 외부 Pixabay API를 호출하여 비디오와 음악 검색 결과를 제공합니다. -- **워크플로우:** - 1. **Request:** 클라이언트가 검색어(`q`), 페이지(`page`) 등의 파라미터와 함께 `/api/v1/pixabay/videos` 또는 `/api/v1/pixabay/musics` 엔드포인트로 API를 요청합니다. - 2. **Controller:** `PixabayController`가 요청을 수신하여 `PixabayVideoService` 또는 `PixabayMusicService`를 호출합니다. - 3. **Service:** 서비스 계층은 `RestTemplate`을 사용하여 Pixabay API 서버에 실제 HTTP 요청을 보냅니다. 이 과정에서 `PIXABAY_KEY`를 인증에 사용합니다. - 4. **Response:** API 응답을 받은 후, 서비스는 이를 `PixabayResponse` 또는 `CustomPixabayMusicResponse` 같은 맞춤형 DTO로 가공하여 컨트롤러에 반환하고, 클라이언트는 최종 결과를 JSON 형태로 받습니다. - -#### 2.3.2. Message 도메인 (메시지 저장 및 조회) -- **목적:** 사용자가 제공한 메시지를 임시로 저장하고 조회하는 기능을 제공합니다. -- **워크플로우:** - 1. **메시지 저장 (POST `/api/v1/messages`):** - - 클라이언트가 `content`를 담아 메시지 저장을 요청합니다. - - `MessageService`는 내용 유효성을 검사한 후, 클라이언트 IP와 함께 `Message` 객체를 생성하여 인메모리 `messageStore`에 `"lastMessage"`라는 키로 저장합니다. (이전 메시지는 덮어쓰여짐) - 2. **메시지 조회 (GET `/api/v1/messages`):** - - 클라이언트가 메시지 조회를 요청합니다. - - `MessageService`는 `messageStore`에서 `"lastMessage"` 키로 저장된 메시지를 찾아 `content`를 반환합니다. 메시지가 없으면 빈 문자열을 반환합니다. - -#### 2.3.3. Common 도메인 (공통 기능) -- **목적:** 여러 도메인에서 공통으로 사용되는 기능을 제공합니다. -- **주요 기능:** - - **`DataInitializationService`**: 애플리케이션 시작 시(`@EventListener(ApplicationReadyEvent.class)`) 외부 API로부터 데이터를 병렬로 가져와 `DataStorage`에 로드합니다. `CompletableFuture`와 `exceptionally`를 사용하여 개별 API 호출 실패가 전체 초기화 프로세스를 중단시키지 않도록 내결함성을 갖추었습니다. - - **`DataInitializationAspect`**: `DataInitializationService`의 실행을 감싸, 실행 시간 측정 및 성공/실패 결과를 Discord 웹훅으로 알립니다. 특히 예외 발생 시, `MessageSource`를 통해 `ErrorCode`에 맞는 상세한 에러 내용을 포함하여 전송하며, 예측하지 못한 예외는 `INTERNAL_SERVER_ERROR`로 처리하여 알립니다. - - **`GlobalExceptionHandler`**: 애플리케이션 전역에서 발생하는 예외(e.g., `NotFoundException`, `BadRequestException`)를 처리합니다. `MessageSource`를 사용하여 에러 코드에 맞는 메시지를 조회하고, 일관된 형식의 에러 응답(`BaseResponse`)을 반환합니다. - - **`RestTemplateConfig`**: 외부 API 통신을 위한 `RestTemplate` 빈을 설정하고, 커스텀 에러 핸들러(`CustomResponseErrorHandler`)를 등록합니다. - - **`DiscordWebhookService`**: `WebClient`를 사용하여 Discord 웹훅으로 비동기 메시지를 전송하는 로직을 담당합니다. - - **`DataStorage`**: API 응답 등에서 필요한 데이터를 임시로 저장하는 인메모리 저장소입니다. - - **`MessageSourceConfig`**: `messages.yml` 파일을 읽어 `MessageSource` 빈을 설정합니다. 이를 통해 애플리케이션의 모든 메시지를 중앙에서 관리할 수 있습니다. - -### 2.4. 빌드 및 배포 (CI/CD) -- **지속적 통합 (CI):** `main` 브랜치 PR 생성 시 트리거. Java 21 환경 설정 -> 테스트 실행(`gradlew test`) -> 결과 게시. -- **지속적 배포 (CD):** `main` 브랜치 병합 시 트리거. `bootJar` 빌드 -> SCP 전송 -> SSH 접속 후 기존 프로세스 종료 및 새 JAR 실행. - -### 2.5. 아키텍처 및 코드 스타일 가이드 - -#### 2.5.1. 아키텍처 원칙 -- **계층형 아키텍처 (Layered Architecture)** - - **Presentation Layer (`presentation`):** HTTP 요청 수신/응답, JSON 변환, 검증. (예: `PixabayController`) - - **Application Layer (`application`):** 핵심 비즈니스 로직, 도메인 모델 관리. (예: `DataInitializationService`) - - **Infrastructure Layer (`infrastructure`):** 외부 API 통신, 저장소, 환경 설정. (예: `DataStorage`) - - **Common/Util (`aop`, `util`):** 횡단 관심사 및 유틸리티. (예: `DataInitializationAspect`) - -#### 2.5.2. 주요 디자인 패턴 및 설계 원칙 -- **의존성 주입 (DI):** `@RequiredArgsConstructor`를 이용한 생성자 주입 방식 사용. -- **관점 지향 프로그래밍 (AOP):** 횡단 관심사(로깅 등)는 Aspect로 분리. -- **DTO 사용:** 계층 간 데이터 전송 시 반드시 DTO 사용. -- **템플릿 메서드 패턴:** `DataInitializationService`에서 전체 흐름 정의, 하위 클래스에서 세부 구현. -- **전략 패턴:** `ParameterBuilder`를 통해 파라미터 생성 로직 캡슐화. -- **싱글턴 패턴:** `DataStorage` 등을 빈(Bean)으로 관리하여 싱글턴 유지. - -#### 2.5.3. 코드 스타일 규칙 -- **Lombok 적극 활용:** `@Getter`, `@Slf4j`, `@RequiredArgsConstructor`(final 필드 생성자 주입) 사용. -- **DTO:** Java `record` 타입을 사용하여 불변(immutable) DTO 정의 권장. -- **RESTful API 명명:** 리소스는 복수형 명사 사용(예: `/api/v1/messages`), 버전을 명시. -- **일관된 응답:** 공통 인터페이스(`ApiResponse`)나 `BaseResponse`를 사용하여 일관된 구조 반환. `GlobalExceptionHandler`를 통한 전역 예외 처리. - ---- - -## 3. 테스트 코드 작성 규칙 (Testing Rules) - -### 3.1. 공통 규칙 -- **테스트 클래스명**: `[테스트 대상 클래스명]Test` (예: `PixabayControllerTest`) -- **테스트 메소드명**: `[시나리오]_[예상 결과]` 형식의 스네이크 케이스 (예: `getVideo_shouldReturnVideoData`) -- **테스트 설명**: `@DisplayName` 각 테스트 메소드에 시나리오를 한글로 명확하게 설명 (예: `GET /video - 비디오 데이터 성공 응답`) -- **Given-When-Then 구조**: `// Given`, `// When`, `// Then` 주석으로 테스트 단계 명확히 구분. - -### 3.2. 계층별 테스트 전략 - -#### 3.2.1. Presentation (Controller) 계층 테스트 -- **목표**: API 엔드포인트의 요청/응답 테스트. -- **주요 어노테이션**: `@WebMvcTest`, `@SpringBootTest` + `@AutoConfigureMockMvc` -- **핵심 도구**: `MockMvc` -- **작성 방식**: - - `MockMvc`를 사용하여 실제 HTTP 요청처럼 테스트 수행. - - Service 등 의존성은 `@MockBean` 또는 `@MockitoBean`으로 Mock 처리. - - `perform()`으로 요청 전송, `andExpect()`로 검증. - -```java -@WebMvcTest(PixabayController.class) -class PixabayControllerTest { - @Autowired private MockMvc mockMvc; - @MockBean private PixabayVideoService pixabayVideoService; - - @Test - @DisplayName("GET /video - 비디오 데이터 성공 응답") - void getVideo_shouldReturnVideoData() throws Exception { - // Given - when(pixabayVideoService.getRandomElement()).thenReturn(videoResult); - - // When & Then - mockMvc.perform(get("/video")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.id").value(1)); - } -} -``` - -#### 3.2.2. Application (Service) 계층 테스트 -- **목표**: 비즈니스 로직의 정확성 단위 테스트. -- **주요 어노테이션**: `@ExtendWith(MockitoExtension.class)` -- **핵심 도구**: `Mockito`, `AssertJ` -- **작성 방식**: - - `@Mock`으로 의존성 생성, `@InjectMocks`로 테스트 대상에 주입. - - Service 메소드를 직접 호출하여 결과 검증. - - `assertThat (AssertJ)`, `verify (Mockito)` 사용. - -```java -@ExtendWith(MockitoExtension.class) -class PixabayMusicServiceTest { - @Mock private RestTemplate restTemplate; - @InjectMocks private PixabayMusicService pixabayMusicService; - - @Test - @DisplayName("음악 데이터 반환") - void getRandomElement_shouldReturnsMusicData() { - // Given - when(dataStorage.getRandomElement(...)).thenReturn(musicResult); - - // When - PixabayMusicResult result = pixabayMusicService.getRandomElement(); - - // Then - assertThat(result).isEqualTo(musicResult); - } -} -``` +각 파일은 독립적으로 참조 가능하며, 필요한 주제의 문서만 선택적으로 읽을 수 있습니다. \ No newline at end of file diff --git a/.gemini/architecture.md b/.gemini/architecture.md new file mode 100644 index 0000000..fc3a400 --- /dev/null +++ b/.gemini/architecture.md @@ -0,0 +1,267 @@ +# Architecture & Design Patterns + +## 멀티모듈 아키텍처 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Pixabay API │────▶│ Data Server │────▶│ Redis │ +└─────────────┘ │ (1대) │ │ (Docker) │ + └─────────────┘ └──────┬──────┘ + │ + ┌─────────────┐ │ + │ API Server │◀───────────┘ + │ (N대) │ 조회/저장 + └──────┬──────┘ + │ 메트릭 수집 + ▼ + ┌───────────────────────────┐ + │ Monitoring Stack │ + │ (Prometheus + Grafana) │ + └───────────────────────────┘ +``` + +### 모듈 의존성 +``` +api-server ──▶ core +data-server ──▶ core +monitoring-server ──▶ (metrics from api-server) +``` + +## 모듈별 계층 구조 + +### core 모듈 (공통 라이브러리) +``` +com.services.core/ +├── aop/ # AOP (Discord 알림 어노테이션 및 Aspect) +│ ├── NotifyDiscord.java (어노테이션) +│ └── DiscordNotifierAspect.java (AOP) +├── config/ # Redis 설정 +├── dto/ # 공통 DTO (BaseResponse, ApiResponse) +├── exception/ # 공통 예외 클래스 +├── infrastructure/ # Redis 저장소, ApiMetadata +├── message/ # 메시지 DTO, Validator +├── notification/ # 알림 관련 +│ ├── DataCollectionResult.java (데이터 수집 결과 DTO) +│ └── discord/ # Discord 웹훅 +│ ├── DiscordWebhookService.java +│ ├── DiscordWebhookPayload.java +│ ├── Embed.java +│ └── Footer.java +├── pixabay/dto/ # Pixabay DTO +└── util/ # 유틸리티 +``` + +### data-server 모듈 +``` +com.services.data/ +├── config/ # RestClient 설정 +├── pixabay/ # 데이터 수집기 +│ ├── PixabayDataCollector.java (추상 클래스) +│ ├── PixabayVideoCollector.java (@NotifyDiscord 사용) +│ └── PixabayMusicCollector.java (@NotifyDiscord 사용) +└── scheduler/ # 스케줄러 + └── PixabayDataScheduler.java +``` + +### api-server 모듈 +``` +com.services.api/ +├── config/ # CORS, MessageSource 설정 +├── message/ # Message API (@NotifyDiscord 사용) +├── omniwatch/ # JPA 엔티티 +├── pixabay/ # Pixabay API +├── presentation/ # GlobalExceptionHandler +└── util/ # WebUtils +``` + +### monitoring 모듈 (모니터링 대시보드 및 수집기) +``` +com.services.monitoring/ +└── ... # Prometheus metric 수집 및 Grafana 대시보드 +``` +`monitoring` 모듈은 애플리케이션의 메트릭을 수집하고 시각화하는 역할을 담당합니다. Prometheus와 Grafana를 활용하여 시스템 전반의 상태를 모니터링합니다. + +**Prometheus/Grafana 통합:** +`api-server` 모듈은 `spring-boot-starter-actuator`와 `micrometer-registry-prometheus`를 통해 `/actuator/prometheus` 엔드포인트에 메트릭을 노출합니다. Prometheus는 이 엔드포인트를 주기적으로 스크랩하여 메트릭을 수집하고, Grafana는 Prometheus에 저장된 메트릭을 사용하여 시각화된 대시보드를 제공합니다. + + +## 주요 디자인 패턴 + +### 의존성 주입 (DI) +```java +@RequiredArgsConstructor // 생성자 주입 +public class PixabayController { + private final PixabayService service; // final 필드 +} +``` + +### 템플릿 메서드 패턴 (data-server) +```java +// 추상 클래스에서 공통 로직 정의 +public abstract class PixabayDataCollector { + public void collectAndStore() { + List dataList = fetchAllData(); + redisDataStorage.setListData(getStorageKey(), dataList); + } + + protected abstract String getStorageKey(); + protected abstract List getFilters(); + + // API 호출 (단순화된 에러 처리) + protected Optional fetchDataForFilter(String filter) { + try { + String uri = buildUri(filter).toUriString(); + R result = restClient.get().uri(uri).retrieve().body(getResponseTypeReference()); + return Optional.ofNullable(result); + } catch (Exception e) { + log.error("Failed to fetch data for filter '{}'", filter, e); + return Optional.empty(); // 실패 시 retry 없이 즉시 반환 + } + } +} + +// 하위 클래스에서 구체적인 구현 +@Component +public class PixabayVideoCollector extends PixabayDataCollector<...> { + @Override + protected String getStorageKey() { + return ApiMetadata.PIXABAY_VIDEOS.getKey(); + } +} +``` + +### Repository 패턴 (core) +```java +// Redis 저장소 추상화 +@Component +public class RedisDataStorage { + public void setListData(String key, List data) { ... } + public T getRandomElement(String key, Class type, ErrorCode errorCode) { ... } +} + +@Component +public class RedisMessageStorage { + public void saveMessage(String content) { ... } + public Optional getMessage() { ... } +} +``` + +### 관점 지향 프로그래밍 (AOP, core 모듈) +```java +// core 모듈에서 제공하는 공통 AOP +@Aspect +@Component +public class DiscordNotifierAspect { + @Around("@annotation(notifyDiscord)") + public Object notifyEvent(ProceedingJoinPoint joinPoint, NotifyDiscord notifyDiscord) { + // 메서드 실행 전: 시작 로깅, 시간 기록 + Object result = joinPoint.proceed(); + // 메서드 실행 후: Discord 알림 전송 (성공/실패) + return result; + } +} + +// data-server에서 사용 예시 +@Component +public class PixabayVideoCollector extends PixabayDataCollector { + @Override + @NotifyDiscord(taskName = "Pixabay 비디오 수집") + public DataCollectionResult collectAndStore() { + return super.collectAndStore(); + } +} + +// api-server에서 사용 예시 +@Service +public class MessageService { + @NotifyDiscord(taskName = "메시지 저장") + public void saveMessage(String content) { + // ... + } +} +``` + +### DTO 패턴 +```java +// record 타입 사용 (불변) +public record PixabayVideoResult( + Integer id, + String pageURL, + String tags +) implements Serializable {} +``` + +## 설계 원칙 + +### 불변성 (Immutability) +- `record` 타입 적극 활용 +- `final` 필드 선언 +- 불변 컬렉션 반환 (`List.of()`) + +### SOLID 원칙 +- **SRP:** 각 클래스는 하나의 책임만 (Collector는 수집만, Storage는 저장만) +- **OCP:** `PixabayDataCollector` 확장으로 새로운 데이터 타입 추가 가능 +- **LSP:** 상위 타입을 하위 타입으로 대체 가능 +- **ISP:** 클라이언트는 사용하지 않는 인터페이스에 의존하지 않음 +- **DIP:** 구체적인 것이 아닌 추상화에 의존 (RedisDataStorage 인터페이스화 가능) + +### Fail-Fast & Graceful Degradation +- 입력 검증은 빠르게 실패 +- 외부 API 호출 실패는 우아하게 처리 (retry 없이 즉시 실패 처리) +- `CompletableFuture`를 통한 개별 실패 처리 +- 개별 필터 실패가 전체 데이터 수집에 영향 주지 않음 + +## HTTP 클라이언트 및 동시성 + +### RestClient (Spring 6.1+) +모든 HTTP 통신에 `RestClient` 사용 (RestTemplate, WebClient 대체) +```java +// data-server: Pixabay API 호출 +restClient.get().uri(uri).retrieve().body(responseType); + +// api-server: Discord 웹훅 전송 +restClient.post().contentType(MediaType.APPLICATION_JSON).body(payload).retrieve().toBodilessEntity(); +``` + +### 가상 스레드 (Java 21) +`spring.threads.virtual.enabled=true` 설정으로 가상 스레드 활성화 + +```java +// data-server: 병렬 데이터 수집 +try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List>> futures = filters.stream() + .map(filter -> CompletableFuture.supplyAsync(() -> fetchDataForFilter(filter), executor)) + .toList(); + // ... +} + +// api-server: 비동기 Discord 알림 전송 +Thread.startVirtualThread(() -> sendMessage(payload)); +``` + +### 기존 방식 대비 장점 +| 항목 | 이전 (WebClient/RestTemplate) | 현재 (RestClient + 가상스레드) | +|------|------------------------------|-------------------------------| +| 코드 스타일 | 리액티브/동기 혼재 | 동기식 통일 | +| 의존성 | webflux 필요 | 불필요 | +| 디버깅 | 어려움 | 쉬움 | +| 동시성 | 플랫폼 스레드풀 | 가상 스레드 (경량) | + +## 데이터 흐름 + +### Data Server (데이터 수집) +``` +[Pixabay API] → [PixabayDataCollector] → [RedisDataStorage] → [Redis] +``` + +### API Server (데이터 조회) +``` +[Client] → [Controller] → [Service] → [RedisDataStorage] → [Redis] + ↓ +[Client] ← [BaseResponse] ← [Controller] +``` + +### Message 저장/조회 +``` +[Client] → [MessageController] → [MessageService] → [RedisMessageStorage] → [Redis] +``` diff --git a/.gemini/deployment.md b/.gemini/deployment.md new file mode 100644 index 0000000..87fd8bd --- /dev/null +++ b/.gemini/deployment.md @@ -0,0 +1,206 @@ +# Build & Deployment + +## 빌드 명령어 + +### 전체 빌드 +```bash +# 빌드 (테스트 포함) +./gradlew build + +# 테스트만 실행 +./gradlew test + +# 테스트 스킵하고 빌드 +./gradlew build -x test + +# 코드 포맷팅 +./gradlew spotlessApply +``` + +### 모듈별 빌드 +```bash +# Core 모듈 +./gradlew :core:build + +# Data Server JAR 생성 +./gradlew :data:bootJar +# 결과: data/build/libs/data.jar + +# API Server JAR 생성 +./gradlew :api:bootJar +# 결과: api/build/libs/api.jar + +# Monitoring Server JAR 생성 +./gradlew :monitoring:bootJar +# 결과: monitoring/build/libs/monitoring.jar +``` + +### 로컬 실행 +```bash +# Docker Compose로 전체 실행 (Redis + Data Server + API Server) +docker-compose up -d + +# 개별 모듈 실행 +./gradlew :data:bootRun +./gradlew :api:bootRun +``` + +## Docker 배포 + +### 이미지 구조 +``` +4d4cat-services/ +├── docker-compose.yml # 로컬 개발용 (전체) +├── docker-compose.data.yml # Data Server 배포용 +├── data/ +│ └── Dockerfile +├── api/ +│ └── Dockerfile +└── monitoring/ + └── Dockerfile +``` + +### Docker Compose 실행 +```bash +# 로컬 전체 실행 +docker-compose up -d + +# Data Server만 실행 (새 서버) +docker-compose -f docker-compose.data.yml up -d + +# 로그 확인 +docker-compose logs -f +``` + +### Redis 관리 +```bash +# Redis CLI 접속 +docker exec -it 4d4cat-redis redis-cli + +# 키 확인 +KEYS * + +# 데이터 확인 +LRANGE pixabayVideos 0 10 +GET message:last +``` + +## CI/CD (GitHub Actions) + +### CI - Pull Request 시 (`ci.yml`) +1. Java 21 환경 설정 +2. 프로젝트 체크아웃 +3. 전체 테스트 실행 (`./gradlew test`) +4. 테스트 결과 게시 + +### CD - API Server (`cd-oci.yml`) +**트리거:** main 브랜치 병합 시 +**대상:** Oracle 서버 2대 + +1. Java 21 환경 설정 +2. `./gradlew :api:bootJar` 빌드 +3. Docker 이미지 빌드 및 푸시 +4. SSH로 Oracle 서버에 배포 + +### CD - Data Server (`cd-data.yml`) +**트리거:** main 병합 시 (core/, data/ 변경 시) +**대상:** Data Server 1대 + +1. Java 21 환경 설정 +2. `./gradlew :data:bootJar` 빌드 +3. Docker 이미지 빌드 및 푸시 +4. SSH로 Data Server에 배포 +5. Redis 컨테이너 확인/실행 + +### CD - Monitoring Server (`cd-monitoring.yml`) +**트리거:** main 병합 시 (core/, monitoring/ 변경 시) +**대상:** Monitoring Server 1대 + +1. Java 21 환경 설정 +2. `./gradlew :monitoring:bootJar` 빌드 +3. Docker 이미지 빌드 및 푸시 +4. SSH로 Monitoring Server에 배포 +5. Prometheus 및 Grafana 컨테이너 확인/실행 + +## 환경 변수 + +### Data Server (.env) +```bash +PIXABAY_KEY=your_pixabay_api_key +PIXABAY_VIDEO_URL=https://pixabay.com/api/videos/ +PIXABAY_MUSIC_URL=https://pixabay.com/api/music/ +REDIS_HOST=localhost +REDIS_PORT=6379 +``` + +### API Server (.env) +```bash +REDIS_HOST= +REDIS_PORT=6379 +CORS_ALLOWED_ORIGINS=https://yourdomain.com +DISCORD_WEBHOOK_URL=your_discord_webhook_url +OMNIWATCH_DB_URL=jdbc:mysql://localhost:3306/omniwatch +OMNIWATCH_DB_USERNAME=root +OMNIWATCH_DB_PASSWORD=password +``` + +## GitHub Secrets + +| Secret 이름 | 설명 | +|------------|------| +| `DOCKER_HUB_USERNAME` | Docker Hub 사용자명 | +| `DOCKER_HUB_TOKEN` | Docker Hub 액세스 토큰 | +| `ORACLE_MAIN_IP` | Oracle 메인 서버 IP | +| `ORACLE_SUB_IP` | Oracle 서브 서버 IP | +| `ORACLE_USER` | Oracle 서버 SSH 사용자명 | +| `ORACLE_KEY` | Oracle 서버 SSH Private Key | +| `DATA_SERVER_IP` | Data Server IP | +| `DATA_SERVER_USER` | Data Server SSH 사용자명 | +| `DATA_SERVER_KEY` | Data Server SSH Private Key | +| `PIXABAY_KEY` | Pixabay API 키 | +| `SUBMODULE_TOKEN` | 서브모듈 접근용 토큰 | +| `MONITORING_SERVER_IP` | Monitoring Server IP | +| `MONITORING_SERVER_USER` | Monitoring Server SSH 사용자명 | + + +## 서버 구성 + +### Data Server (1대) +- Redis 컨테이너 +- data-server 컨테이너 +- Port: 6379 (Redis), 8081 (Data Server) + +### API Server (N대, Oracle) +- api-server 컨테이너 +- Port: 8080 +- REDIS_HOST로 Data Server IP 설정 + +### Monitoring Server (1대) +- monitoring-server 컨테이너 +- prometheus 컨테이너 +- grafana 컨테이너 +- Port: 8082 (Monitoring), 9090 (Prometheus), 3000 (Grafana) +- API_SERVER_IP로 API Server IP 설정 (prometheus.yml 설정에 필요) + +## 배포 전 체크리스트 + +- [ ] 모든 테스트가 통과하는가? +- [ ] 환경 변수가 올바르게 설정되었는가? +- [ ] 코드 리뷰가 완료되었는가? +- [ ] Redis 연결이 정상인가? +- [ ] Data Server가 정상 동작하는가? +- [ ] 모니터링 스택(Prometheus, Grafana) 설정이 올바른가? +- [ ] 롤백 계획이 수립되었는가? + +## 롤백 + +```bash +# 이전 버전 이미지로 롤백 (Oracle 서버에서) +sudo docker stop 4d4cat-api +sudo docker rm 4d4cat-api +sudo docker run -d \ + --name 4d4cat-api \ + -p 8080:8080 \ + --env-file /home/opc/.env \ + username/4d4cat-api: +``` diff --git a/.gemini/project.md b/.gemini/project.md new file mode 100644 index 0000000..b1ae338 --- /dev/null +++ b/.gemini/project.md @@ -0,0 +1,119 @@ +# Project Overview + +## 기본 정보 +- **프로젝트명:** 4d4cat-services +- **목적:** 외부 API 연동 및 데이터 관리를 위한 백엔드 서비스 +- **구조:** Gradle 멀티모듈 + +## 기술 스택 +- **언어:** Java 21 (가상 스레드 활성화) +- **프레임워크:** Spring Boot 3.4.12 +- **빌드 도구:** Gradle (Multi-Module) +- **컨테이너:** Docker, Docker Compose +- **HTTP 클라이언트:** RestClient (Spring 6.1+) +- **모니터링:** Prometheus, Grafana + +## 모듈 구조 + +``` +4d4cat-services/ +├── core/ # 공통 라이브러리 모듈 +├── data/ # 데이터 수집 서버 (port: 8081) +├── api/ # API 서버 (port: 8080) +└── monitoring/ # 모니터링 서버 (port: 8082) +``` + +### core 모듈 +- 공통 예외 클래스 (`ErrorCode`, `CustomException` 등) +- 공통 DTO (`BaseResponse`, `ApiResponse`, `DataCollectionResult`) +- Redis 설정 및 저장소 (`RedisConfig`, `RedisDataStorage`, `RedisMessageStorage`) +- Pixabay DTO (`PixabayVideoResult`, `PixabayMusicResult`) +- AOP 및 알림 (`@NotifyDiscord`, `DiscordNotifierAspect`, `DiscordWebhookService`) +- 유틸리티 (`RandomUtils`) + +### data 모듈 +- Pixabay API 데이터 수집기 (`PixabayDataCollector`, `PixabayVideoCollector`, `PixabayMusicCollector`) +- 스케줄러 (`PixabayDataScheduler`) +- 서버 시작 시 데이터 초기화, 주기적 갱신 +- `@NotifyDiscord` 어노테이션을 통한 데이터 수집 알림 + +### api 모듈 +- REST API 컨트롤러 (`PixabayController`, `MessageController`) +- 서비스 (`PixabayService`, `MessageService`) +- JPA 엔티티 (`omniwatch` 패키지) +- `@NotifyDiscord` 어노테이션을 통한 메시지 저장 알림 +- Prometheus 메트릭 노출 (`/actuator/prometheus`) + +### monitoring 모듈 +- Spring Boot 기반 애플리케이션 +- Prometheus가 메트릭을 수집할 수 있도록 `/actuator/prometheus` 엔드포인트 노출 +- Grafana와 연동하여 대시보드 시각화 환경 제공 + +## 주요 라이브러리 + +### 공통 (core) +- `Spring Data Redis` - 분산 캐시 및 데이터 동기화 +- `Spring AOP` - 횡단 관심사 처리 (Discord 알림) +- `Spring Web` - RestClient (Discord 웹훅 전송) +- `Lombok` - 보일러플레이트 코드 감소 + +### data-server +- `Spring Web` - RestClient를 통한 API 호출 +- `Spring Scheduling` - 주기적 데이터 수집 +- `Virtual Threads` - 가상 스레드 기반 병렬 데이터 수집 + +### api-server +- `Spring Web` - REST API 개발 +- `Spring Data JPA` - 데이터베이스 연동 +- `SpringDoc OpenAPI (2.8.14)` - API 문서 자동화 (Swagger UI) +- `MessageSource` - 메시지 중앙 관리 (YAML 기반) + +## 외부 서비스 연동 + +### Pixabay API +- **목적:** 비디오 및 음악 데이터 조회 +- **인증:** 환경 변수 `PIXABAY_KEY` 필요 +- **호출 위치:** data-server만 호출 (API 중복 호출 방지) + +### Redis +- **목적:** 서버 간 데이터 동기화 및 캐싱 +- **데이터:** Pixabay 비디오/음악 목록, 메시지 +- **설정:** 환경 변수 `REDIS_HOST`, `REDIS_PORT` + +### Discord Webhook +- **목적:** 주요 이벤트 실시간 알림 (데이터 수집, 메시지 저장 등) +- **설정:** 환경 변수 `DISCORD_WEBHOOK_URL` 필요 +- **구현 위치:** core 모듈 (공통 AOP) +- **사용 위치:** data-server (데이터 수집), api-server (메시지 저장) + +### Prometheus +- **목적:** 애플리케이션 메트릭 수집 및 저장 +- **설정:** `prometheus/prometheus.yml` 파일을 통해 `api` 및 `monitoring` 서비스의 `/actuator/prometheus` 엔드포인트 스크랩 + +### Grafana +- **목적:** Prometheus를 통해 수집된 메트릭 시각화 +- **설정:** Docker Compose를 통해 실행되며, Prometheus를 데이터 소스로 연결하여 대시보드 제공 + +## 필수 환경 변수 + +```bash +# Pixabay API (data-server) +PIXABAY_KEY=your_pixabay_api_key +PIXABAY_VIDEO_URL=https://pixabay.com/api/videos/ +PIXABAY_MUSIC_URL=https://pixabay.com/api/music/ + +# Redis (core) +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Discord (api-server) +DISCORD_WEBHOOK_URL=your_discord_webhook_url + +# CORS (api-server) +CORS_ALLOWED_ORIGINS=http://localhost:3000 + +# Database (api-server) +OMNIWATCH_DB_URL=jdbc:mysql://localhost:3306/omniwatch +OMNIWATCH_DB_USERNAME=root +OMNIWATCH_DB_PASSWORD=password +``` diff --git a/.gemini/standards.md b/.gemini/standards.md new file mode 100644 index 0000000..b0a5653 --- /dev/null +++ b/.gemini/standards.md @@ -0,0 +1,126 @@ +# Coding Standards + +## Lombok 활용 + +```java +@Slf4j // 로깅 +@Getter // getter 자동 생성 +@RequiredArgsConstructor // final 필드 생성자 주입 +``` + +## DTO는 record 사용 + +```java +// ✅ 권장 +public record VideoResult(Long id, String url) {} + +// ❌ 지양 +@Getter @AllArgsConstructor +public class VideoResult { ... } +``` + +## RESTful API 네이밍 + +```java +// ✅ 권장 +GET /api/v1/messages // 복수형 명사, 버전 명시 +POST /api/v1/messages + +// ❌ 지양 +GET /api/v1/message // 단수형 +GET /api/getMessage // 동사 포함, 버전 누락 +``` + +## 응답 형식 통일 + +```java +public record BaseResponse( + String code, + String message, + T data +) { + public static BaseResponse success(T data) { + return new BaseResponse<>("SUCCESS", "요청이 성공했습니다.", data); + } +} +``` + +## 예외 처리 + +- 비즈니스 예외는 커스텀 예외 사용 +- `GlobalExceptionHandler`에서 전역 처리 +- 에러 메시지는 `MessageSource`에서 관리 + +```java +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFoundException() { + // ErrorCode → MessageSource → BaseResponse + } +} +``` + +## 불변성 + +```java +// final 필드 +private final MessageRepository repository; + +// 불변 컬렉션 +return List.of("item1", "item2"); +return Collections.unmodifiableList(items); +``` + +## 의존성 주입 + +```java +// ✅ 권장: 생성자 주입 +@RequiredArgsConstructor +public class Service { + private final Repository repository; +} + +// ❌ 지양: 필드 주입 +@Autowired +private Repository repository; +``` + +## 로깅 + +```java +@Slf4j +public class Service { + public void method() { + log.debug("디버깅: param={}", param); + log.info("정보: result={}", result); + log.warn("경고: issue={}", issue); + log.error("에러 발생", exception); + } +} +``` + +## 네이밍 컨벤션 + +- **클래스:** PascalCase (`MessageService`) +- **메소드/변수:** camelCase (`saveMessage`, `userName`) +- **상수:** UPPER_SNAKE_CASE (`MAX_RETRY_COUNT`) +- **패키지:** 소문자 (`com.example.presentation`) + +## 메소드 작성 원칙 + +- 단일 책임 원칙 (SRP) +- 최대 20-30줄 권장 +- 파라미터는 3개 이하 (초과 시 DTO로 묶기) +- "무엇을"이 아닌 "왜"를 주석으로 설명 + +## 체크리스트 + +- [ ] Lombok을 적절히 사용했는가? +- [ ] DTO는 `record`로 작성했는가? +- [ ] 의존성 주입은 생성자 방식을 사용했는가? +- [ ] 모든 필드는 `final`로 선언했는가? +- [ ] API 응답은 `BaseResponse`로 통일했는가? +- [ ] 예외 처리는 `GlobalExceptionHandler`에서 하는가? +- [ ] 로깅은 `@Slf4j`를 사용했는가? +- [ ] 네이밍 컨벤션을 준수했는가? diff --git a/.gemini/testing.md b/.gemini/testing.md new file mode 100644 index 0000000..03cb92c --- /dev/null +++ b/.gemini/testing.md @@ -0,0 +1,225 @@ +# Testing Guide + +## 테스트 실행 명령어 + +### 전체 테스트 +```bash +./gradlew test +``` + +### 모듈별 테스트 +```bash +# Core 모듈 테스트 +./gradlew :core:test + +# Data Server 테스트 +./gradlew :data-server:test + +# API Server 테스트 +./gradlew :api-server:test +``` + +### 특정 테스트 클래스 실행 +```bash +./gradlew :api-server:test --tests "PixabayServiceTest" +``` + +## 공통 규칙 + +### 테스트 클래스명 +`[테스트 대상 클래스명]Test` +```java +PixabayControllerTest.java +MessageServiceTest.java +RedisDataStorageTest.java +``` + +### 테스트 메소드명 +`[시나리오]_[예상결과]` (스네이크 케이스) +```java +@Test +void saveMessage_shouldReturnSavedMessage() {} + +@Test +void getVideo_whenDataNotFound_shouldThrowNotFoundException() {} +``` + +### 테스트 설명 +```java +@Test +@DisplayName("GET /video - 비디오 데이터 성공 응답") +void getVideo_shouldReturnVideoData() {} +``` + +### Given-When-Then 구조 +```java +@Test +void saveMessage_shouldWork() { + // Given (준비) + String content = "Hello"; + + // When (실행) + service.saveMessage(new MessageRequest(content)); + + // Then (검증) + String result = service.getMessage(); + assertThat(result).isEqualTo(content); +} +``` + +## 모듈별 테스트 예시 + +### Core 모듈 (단위 테스트) +```java +@ExtendWith(MockitoExtension.class) +class RedisDataStorageTest { + @Mock private RedisTemplate redisTemplate; + @Mock private ListOperations listOperations; + @InjectMocks private RedisDataStorage storage; + + @Test + @DisplayName("리스트 데이터 저장 - 성공") + void setListData_shouldStoreData() { + // Given + when(redisTemplate.opsForList()).thenReturn(listOperations); + List data = List.of("item1", "item2"); + + // When + storage.setListData("key", data); + + // Then + verify(redisTemplate).delete("key"); + verify(listOperations).rightPushAll(eq("key"), any(Object[].class)); + } +} +``` + +### API Server (Controller 테스트) +```java +@WebMvcTest(PixabayController.class) +class PixabayControllerTest { + @Autowired private MockMvc mockMvc; + @MockBean private PixabayService service; + + @Test + @DisplayName("GET /video - 성공") + void getVideo_shouldReturnVideoData() throws Exception { + // Given + PixabayVideoResult video = PixabayVideoResult.builder() + .id(1) + .pageURL("https://example.com") + .build(); + when(service.getRandomVideo()).thenReturn(video); + + // When & Then + mockMvc.perform(get("/video")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.id").value(1)); + } +} +``` + +### API Server (Service 테스트) +```java +@ExtendWith(MockitoExtension.class) +class MessageServiceTest { + @Mock private RedisMessageStorage storage; + @InjectMocks private MessageService service; + + @Test + @DisplayName("메시지 저장 - 성공") + void saveMessage_shouldWork() { + // Given + MessageRequest request = new MessageRequest("Hello"); + + // When + service.saveMessage(request); + + // Then + verify(storage).saveMessage("Hello"); + } + + @Test + @DisplayName("메시지 저장 - 유효하지 않은 내용") + void saveMessage_whenInvalidContent_shouldThrowException() { + // Given + MessageRequest request = new MessageRequest(""); + + // When & Then + assertThatThrownBy(() -> service.saveMessage(request)) + .isInstanceOf(BadRequestException.class); + } +} +``` + +### Data Server (Collector 테스트) +```java +@ExtendWith(MockitoExtension.class) +class PixabayVideoCollectorTest { + @Mock private RestTemplate restTemplate; + @Mock private Environment environment; + @Mock private RedisDataStorage storage; + @InjectMocks private PixabayVideoCollector collector; + + @Test + @DisplayName("비디오 데이터 수집 및 저장") + void collectAndStore_shouldFetchAndSaveData() { + // Given + when(environment.getProperty(anyString())).thenReturn("https://api.pixabay.com"); + // ... RestTemplate mock 설정 + + // When + collector.collectAndStore(); + + // Then + verify(storage).setListData(eq("pixabayVideos"), anyList()); + } +} +``` + +## AssertJ 주요 메소드 + +```java +// 동등성 +assertThat(actual).isEqualTo(expected); +assertThat(actual).isNotNull(); + +// 문자열 +assertThat(str).contains("substring"); +assertThat(str).startsWith("prefix"); + +// 컬렉션 +assertThat(list).hasSize(3); +assertThat(list).contains("a", "b"); + +// 예외 +assertThatThrownBy(() -> service.method()) + .isInstanceOf(NotFoundException.class) + .hasMessage("Not found"); +``` + +## Mockito 주요 기능 + +```java +// Stubbing +when(service.getData()).thenReturn(data); +when(service.getData(anyString())).thenReturn(data); +when(service.getData()).thenThrow(new RuntimeException()); + +// Verification +verify(service).getData(); +verify(service, times(3)).getData(); +verify(service, never()).getData(); +``` + +## 테스트 체크리스트 + +- [ ] 모든 public 메소드에 테스트가 있는가? +- [ ] 테스트 메소드명이 명확한가? +- [ ] `@DisplayName`으로 한글 설명이 있는가? +- [ ] Given-When-Then 구조를 따르는가? +- [ ] 독립적으로 실행 가능한가? +- [ ] 예외 상황도 테스트했는가? +- [ ] Mock 객체의 호출을 검증했는가? +- [ ] Redis Mock이 필요한 경우 적절히 처리했는가? diff --git a/.gemini/workflows.md b/.gemini/workflows.md new file mode 100644 index 0000000..0ebcc53 --- /dev/null +++ b/.gemini/workflows.md @@ -0,0 +1,163 @@ +# Domain Workflows + +## Data Server 워크플로우 + +### Pixabay 데이터 수집 (PixabayDataScheduler) +``` +[Server Startup] → @PostConstruct + ↓ + PixabayDataScheduler.initializeData() + ↓ + ┌───────────────┴───────────────┐ + ▼ ▼ +PixabayVideoCollector PixabayMusicCollector +@NotifyDiscord("Pixabay 비디오 수집") @NotifyDiscord("Pixabay 음악 수집") + │ │ + ↓ (AOP Intercept) ↓ (AOP Intercept) +DiscordNotifierAspect DiscordNotifierAspect + │ │ + ▼ ▼ +CompletableFuture 병렬 처리 CompletableFuture 병렬 처리 +(20개 카테고리) (32개 장르) + │ │ + ▼ ▼ + Pixabay API Pixabay API + │ │ + ▼ ▼ +RedisDataStorage.setListData() RedisDataStorage.setListData() + │ │ + ▼ ▼ +DataCollectionResult 반환 DataCollectionResult 반환 + │ │ + ▼ ▼ +Discord 성공 알림 전송 Discord 성공 알림 전송 +(비동기, Virtual Thread) (비동기, Virtual Thread) + │ │ + └───────────────┬───────────────┘ + ▼ + [Redis] +``` + +### 스케줄링 전략 +- `@PostConstruct`: 서버 시작 시 즉시 수집 +- `@Scheduled(cron = "0 0 3 * * *")`: 매일 새벽 3시 +- `@Scheduled(fixedRate = 21600000)`: 6시간마다 갱신 + +### 처리 과정 +1. 스케줄러가 Collector의 `collectAndStore()` 호출 +2. **AOP Intercept**: `@NotifyDiscord` 어노테이션 감지, 시작 로깅 +3. Collector가 필터별로 병렬 API 호출 (Virtual Thread Pool) + - 각 필터는 독립적으로 실행 + - 실패 시 retry 없이 즉시 Optional.empty() 반환 + - 개별 실패가 전체 수집에 영향 주지 않음 +4. 응답 데이터를 DTO로 변환 +5. `RedisDataStorage`를 통해 Redis에 저장 +6. **AOP After**: `DataCollectionResult` 반환값 기반으로 Discord 알림 전송 + - 총 아이템 수, 성공/실패 필터 수, 소요 시간 포함 + - Virtual Thread로 비동기 전송 (메인 워크플로우 지연 없음) + +## API Server 워크플로우 + +### Pixabay 도메인 + +#### 랜덤 비디오 조회 (GET /video) +``` +[Client] → [PixabayController] → [PixabayService] + ↓ + RedisDataStorage.getRandomElement() + ↓ + [Redis] + ↓ +[Client] ← [BaseResponse] ← [PixabayVideoResult] +``` + +#### 랜덤 음악 조회 (GET /music) +``` +[Client] → [PixabayController] → [PixabayService] + ↓ + RedisDataStorage.getRandomElement() + ↓ + [Redis] + ↓ +[Client] ← [BaseResponse] ← [PixabayMusicResult] +``` + +### Message 도메인 + +#### 메시지 저장 (POST /message) +``` +[Client] → [MessageController] → [MessageService] + ↓ + 1. MessageValidator.isValid() + 2. RedisMessageStorage.saveMessage() + ↓ + [Redis] + ↓ + @NotifyDiscord → Discord 알림 + ↓ +[Client] ← [200 OK] +``` + +#### 메시지 조회 (GET /message) +``` +[Client] → [MessageController] → [MessageService] + ↓ + RedisMessageStorage.getMessage() + ↓ + [Redis] + ↓ +[Client] ← [String content] +``` + +### 특징 +- Redis 기반 저장소 (서버 간 동기화) +- 항상 마지막 메시지만 유지 +- 저장 시 Discord 웹훅 알림 + +## 공통 워크플로우 + +### 전역 예외 처리 (GlobalExceptionHandler, api-server) +``` +[Any Layer] → 예외 발생 + ↓ + GlobalExceptionHandler + ↓ + 1. ErrorCode 추출 + 2. MessageSource로 메시지 조회 + 3. BaseResponse 생성 + ↓ +[Client] ← JSON 에러 응답 +``` + +### AOP Discord 알림 (core 모듈 - 공통) +``` +@NotifyDiscord 어노테이션 메서드 실행 + ↓ + DiscordNotifierAspect.notifyEvent() (core 모듈) + ↓ + 1. 실행 시간 측정 시작 + 2. 메서드 실행 (joinPoint.proceed()) + 3. 결과에 따라 성공/실패 메시지 생성 + - DataCollectionResult: 통계 정보 포함 + - 일반 메서드: 실행 시간만 포함 + 4. DiscordWebhookService.sendMessageAsync() + ↓ + [Discord Webhook] (비동기, Virtual Thread) +``` + +**사용 위치:** +- **data-server**: 데이터 수집 완료 시 (PixabayVideoCollector, PixabayMusicCollector) +- **api-server**: 메시지 저장 시 (MessageService) + +### Redis 데이터 저장소 (core) +```java +// 리스트 데이터 저장 (data-server) +RedisDataStorage.setListData("pixabayVideos", videoList) + +// 랜덤 요소 조회 (api-server) +RedisDataStorage.getRandomElement("pixabayVideos", PixabayVideoResult.class, ErrorCode) + +// 메시지 저장/조회 +RedisMessageStorage.saveMessage("Hello") +RedisMessageStorage.getMessage() → Optional +``` diff --git a/.github/workflows/cd-data.yml b/.github/workflows/cd-data.yml index a0b0c31..21b0cbe 100644 --- a/.github/workflows/cd-data.yml +++ b/.github/workflows/cd-data.yml @@ -140,8 +140,8 @@ jobs: # Deploy using docker-compose cd /home/opc - sudo docker-compose -f docker-compose.data.yml down - sudo docker-compose -f docker-compose.data.yml up -d + sudo docker compose -f docker-compose.data.yml down + sudo docker compose -f docker-compose.data.yml up -d # Wait for services to be healthy echo "Waiting for services to be healthy..." diff --git a/.github/workflows/cd-monitoring.yml b/.github/workflows/cd-monitoring.yml new file mode 100644 index 0000000..692fd3d --- /dev/null +++ b/.github/workflows/cd-monitoring.yml @@ -0,0 +1,173 @@ +name: cd-monitoring + +on: + pull_request: + branches: [ "main" ] + types: [closed] + paths: + - 'core/**' + - 'monitoring/**' + - '.github/workflows/cd-monitoring.yml' + +jobs: + deploy: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout sources + uses: actions/checkout@v4 + with: + token: ${{ secrets.SUBMODULE_TOKEN }} + submodules: true + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build monitoring with Gradle + run: ./gradlew :monitoring:bootJar + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ./monitoring + file: ./monitoring/Dockerfile + push: true + platforms: linux/arm64 + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/4d4cat-monitoring:${{ github.sha }} + ${{ secrets.DOCKER_HUB_USERNAME }}/4d4cat-monitoring:latest + + - name: (SSH) Deploy to Monitoring Server + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.MONITORING_SERVER_IP }} + username: ${{ secrets.MONITORING_SERVER_USER }} + key: ${{ secrets.ORACLE_KEY }} + script: | + # Create docker-compose.monitoring.yml if it doesn't exist + cat > /home/opc/docker-compose.monitoring.yml << 'EOF' + version: '3.8' + + services: + monitoring: + image: ${{ secrets.DOCKER_HUB_USERNAME }}/4d4cat-monitoring:${{ github.sha }} + container_name: 4d4cat-monitoring + ports: + - "8082:8080" # Map host port 8082 to container port 8080 + environment: + - TZ=Asia/Seoul + networks: + - monitor-net + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 3s + retries: 3 + + prometheus: + image: prom/prometheus:latest + container_name: 4d4cat-prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + networks: + - monitor-net + restart: unless-stopped + depends_on: + - monitoring + + grafana: + image: grafana/grafana:latest + container_name: 4d4cat-grafana + ports: + - "3000:3000" + networks: + - monitor-net + restart: unless-stopped + depends_on: + - prometheus + + networks: + monitor-net: + driver: bridge + EOF + + # Create prometheus.yml file on the server + mkdir -p /home/opc/prometheus + cat > /home/opc/prometheus/prometheus.yml << 'EOF_PROM' + global: + scrape_interval: 15s + + scrape_configs: + - job_name: 'monitoring-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['monitoring:8080'] + - job_name: 'api-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['YOUR_API_SERVICE_IP:8080'] # IMPORTANT: Replace with actual API service IP/hostname + EOF_PROM + + # Pull the latest image for monitoring + sudo docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/4d4cat-monitoring:${{ github.sha }} + + # Deploy using docker-compose + cd /home/opc + sudo docker compose -f docker-compose.monitoring.yml down + sudo docker compose -f docker-compose.monitoring.yml up -d + + # Wait for services to be healthy + echo "Waiting for services to be healthy..." + sleep 30 # Increased sleep time for multiple services + + # Check if services are running + if ! sudo docker ps | grep -q 4d4cat-monitoring; then + echo "Monitoring service failed to start!" + sudo docker logs 4d4cat-monitoring + exit 1 + fi + if ! sudo docker ps | grep -q 4d4cat-prometheus; then + echo "Prometheus failed to start!" + sudo docker logs 4d4cat-prometheus + exit 1 + fi + if ! sudo docker ps | grep -q 4d4cat-grafana; then + echo "Grafana failed to start!" + sudo docker logs 4d4cat-grafana + exit 1 + fi + + echo "Monitoring services are running successfully." + + # Clean up unused images + sudo docker image prune -f diff --git a/README.md b/README.md index 8408069..fb92d0b 100644 --- a/README.md +++ b/README.md @@ -110,23 +110,25 @@ services/ ├── core/ # 공통 라이브러리 (DTO, 예외, Redis, AOP, 알림) ├── api/ # API 서버 (REST API, JPA) ├── data/ # 데이터 수집 서버 (스케줄러, Pixabay API 호출) +└── monitoring/ # 모니터링 서버 (Actuator, Prometheus) ``` **모듈별 역할:** - **core**: 공통 예외, Redis 저장소, Pixabay DTO, AOP (Discord 알림), 유틸리티 - **api**: REST API 제공, 사용자 요청 처리 - **data**: 외부 API 데이터 수집, Redis 저장, 스케줄링 +- **monitoring**: 서비스 메트릭 수집 및 Prometheus 엔드포인트 제공 ### 서비스 아키텍처 **로컬 개발 환경:** ``` -┌─────────────────────────────────────────────────────────────┐ -│ Docker Compose │ -├─────────────┬─────────────┬─────────────┬───────────────────┤ -│ api │ data │ Redis │ Prometheus │ -│ :8080 │ :8081 │ :6379 │ :9090 │ -└─────────────┴─────────────┴─────────────┴───────────────────┘ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Docker Compose │ +├─────────────┬─────────────┬─────────────┬──────────────┬────────────────────┤ +│ api │ data │ Redis │ monitoring │ Prometheus │ +│ :8080 │ :8081 │ :6379 │ :8082 │ :9090 │ +└─────────────┴─────────────┴─────────────┴──────────────┴────────────────────┘ ``` **프로덕션 환경:** @@ -179,6 +181,12 @@ core/ └── util/ # 공통 유틸리티 ``` +**monitoring 모듈:** +``` +monitoring/ +└── src/main/java/com/services/monitoring/MonitoringApplication.java +``` + **설계 철학:** - **기능별 응집**: 관련 코드를 한 패키지에 모아 직관적으로 구성 - **수직 슬라이싱**: Controller-Service를 기능별로 수직 분리 @@ -265,10 +273,12 @@ docker-compose down ./gradlew :core:build ./gradlew :api:bootJar ./gradlew :data:bootJar +./gradlew :monitoring:bootJar # 개별 실행 ./gradlew :api:bootRun ./gradlew :data:bootRun +./gradlew :monitoring:bootRun ``` ### 테스트 @@ -279,6 +289,7 @@ docker-compose down # 특정 모듈 테스트 ./gradlew :api:test ./gradlew :data:test +./gradlew :monitoring:test # 코드 포맷팅 ./gradlew spotlessApply @@ -289,9 +300,11 @@ docker-compose down |--------|-----|------| | API Server | http://localhost:8080 | REST API 엔드포인트 | | Data Server | http://localhost:8081 | 데이터 수집 서버 | +| Monitoring Server | http://localhost:8082 | 모니터링 서버 | | Swagger UI | http://localhost:8080/api-docs/swagger-ui.html | API 문서 | | API Health | http://localhost:8080/actuator/health | API 헬스체크 | | Data Health | http://localhost:8081/actuator/health | Data 헬스체크 | +| Monitoring Health | http://localhost:8082/actuator/health | Monitoring 헬스체크 | | Prometheus | http://localhost:9090 | 메트릭 수집 | | Grafana | http://localhost:3000 | 모니터링 대시보드 | | Redis | localhost:6379 | 캐시 스토어 | @@ -335,6 +348,11 @@ curl -X POST http://localhost:8080/message \ - Redis 컨테이너 자동 확인/실행 - 배포 후 헬스체크 +**CD - Monitoring Server:** +- `core/`, `monitoring/` 변경 감지 시 자동 배포 +- Monitoring, Prometheus, Grafana 컨테이너 자동 배포 +- 배포 후 헬스체크 + ### 배포 전략 - **멀티 서버 배포**: Main/Sub API 서버 병렬 배포 - **무중단 배포**: 헬스체크 기반 점진적 트래픽 전환 diff --git a/core/src/main/java/com/services/core/notification/DiscordLambdaFunction.md b/core/src/main/java/com/services/core/notification/DiscordLambdaFunction.md new file mode 100644 index 0000000..70f481c --- /dev/null +++ b/core/src/main/java/com/services/core/notification/DiscordLambdaFunction.md @@ -0,0 +1,137 @@ +## build.gradle +``` +plugins { + id 'java' + id 'com.github.johnrengelman.shadow' version '8.1.1' +} + +group = 'site' +version = '1.0-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +shadowJar { + archiveClassifier.set('') +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.3' + + compileOnly 'org.projectlombok:lombok:1.18.34' + annotationProcessor 'org.projectlombok:lombok:1.18.34' + + implementation 'com.amazonaws:aws-lambda-java-events:3.11.4' + implementation 'com.amazonaws:aws-lambda-java-core:1.2.3' +} +``` + +## code +``` +package site; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; + +public class DiscordLambdaFunction implements RequestHandler, String> { + + private static final String DISCORD_WEBHOOK_URL = System.getenv("DISCORD_WEBHOOK_URL_ENV"); + private static final int DISCORD_COLOR_INFO = 3066993; // Green + private static final int DISCORD_COLOR_ERROR = 15158332; // Red + private static final ObjectMapper objectMapper = new ObjectMapper(); + + static { + // Configure ObjectMapper to use fields for serialization since some classes (like Footer) might lack getters + objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + } + + @Override + public String handleRequest(Map event, Context context) { + var logger = context.getLogger(); + + try { + String alarmName = (String) event.get("AlarmName"); + String newState = (String) event.get("NewStateValue"); // ALARM, OK, INSUFFICIENT_DATA + String reason = (String) event.get("NewStateReason"); + + logger.log("Processing Alarm: " + alarmName + " (State: " + newState + ")"); + + String title = "ALARM".equals(newState) ? "🚨 [Warning] " + alarmName : "✅ [OK] " + alarmName; + int color = "ALARM".equals(newState) ? DISCORD_COLOR_ERROR : DISCORD_COLOR_INFO; + + Footer footer = Footer.builder() + .text("AWS CloudWatch Alarm System") + .build(); + + Embed embed = Embed.builder() + .title(title) + .description(reason) + .color(color) + .timestamp(Instant.now().toString()) + .footer(footer) + .build(); + + DiscordWebhookPayload payload = DiscordWebhookPayload.builder() + .username("CloudWatch Monitor") + .embeds(Collections.singletonList(embed)) + .build(); + + sendWebhook(DISCORD_WEBHOOK_URL, payload); + + return "Successfully processed " + alarmName; + + } catch (Exception e) { + logger.log("Error: " + e.getMessage()); + return "Error: " + e.getMessage(); + } + } + + private void sendWebhook(String webhookUrl, DiscordWebhookPayload payload) throws IOException { + URL url = new URL(webhookUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + + String jsonPayload = objectMapper.writeValueAsString(payload); + + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonPayload.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + int responseCode = connection.getResponseCode(); + if (responseCode >= 200 && responseCode < 300) { + System.out.println("Discord webhook sent successfully. Response code: " + responseCode); + } else { + System.err.println("Failed to send Discord webhook. Response code: " + responseCode); + try (InputStream errorStream = connection.getErrorStream()) { + if (errorStream != null) { + String errorResponse = new String(errorStream.readAllBytes(), StandardCharsets.UTF_8); + System.err.println("Error response: " + errorResponse); + } + } + } + } +} +``` diff --git a/docs/project/API_WORKFLOW.md b/docs/project/API_WORKFLOW.md index 98152fe..794fa60 100644 --- a/docs/project/API_WORKFLOW.md +++ b/docs/project/API_WORKFLOW.md @@ -93,6 +93,8 @@ graph TD 클라이언트가 비디오 또는 음악 데이터를 요청하면, 컨트롤러는 서비스 계층을 통해 `DataStorage`에 캐시된 데이터를 조회하여 무작위로 하나를 반환합니다. +API 서버는 또한 `/actuator/prometheus` 엔드포인트를 통해 Prometheus 메트릭을 노출하며, 이는 모니터링 시스템에서 애플리케이션 성능 및 상태를 수집하는 데 사용됩니다. + ```mermaid sequenceDiagram participant Client diff --git a/docs/troubleshooting/502-error-diagnosis.md b/docs/troubleshooting/502-error-diagnosis.md new file mode 100644 index 0000000..383cdb2 --- /dev/null +++ b/docs/troubleshooting/502-error-diagnosis.md @@ -0,0 +1,145 @@ +# 502 에러 진단 및 해결 + +## 문제 상황 +- 외부에서 api.4d4cat.site 접근 시 502 Bad Gateway 에러 발생 +- DNS에 Main IP와 Sub IP 두 개 설정됨 + +## 원인 +DNS 라운드 로빈으로 Sub IP로 접근 시, Sub 서버에 nginx가 없어서 502 에러 발생 + +## 해결 방법 + +### 1단계: DNS 설정 확인 +```bash +# 현재 DNS 설정 확인 +dig api.4d4cat.site +short +``` + +### 2단계: DNS 수정 (권장) +**DNS에서 Main IP만 A 레코드로 설정** + +Before: +``` +api.4d4cat.site. A +api.4d4cat.site. A +``` + +After: +``` +api.4d4cat.site. A +``` + +### 3단계: 각 서버 상태 확인 + +**Main 서버 (nginx 있음):** +```bash +ssh opc@ + +# nginx 상태 +sudo systemctl status nginx +sudo nginx -t + +# 컨테이너 상태 +sudo docker ps | grep 4d4cat-api + +# 포트 확인 +sudo netstat -tlnp | grep -E ':(80|443|8080)' + +# 로컬 테스트 +curl http://localhost:8080 +``` + +**Sub 서버 (nginx 없음, 컨테이너만):** +```bash +ssh opc@ + +# 컨테이너 상태 +sudo docker ps | grep 4d4cat-api + +# 포트 확인 +sudo netstat -tlnp | grep 8080 + +# 로컬 테스트 +curl http://localhost:8080 +``` + +### 4단계: Nginx Upstream 확인 + +Main 서버의 nginx.conf: +```nginx +upstream api_servers { + server localhost:8080; # Main 서버 컨테이너 + server :8080; # Sub 서버 컨테이너 +} +``` + +**Sub IP가 정확한지 확인**: +```bash +# Main 서버에서 Sub 서버 연결 테스트 +curl -v http://:8080 + +# Telnet으로 포트 확인 +telnet 8080 +``` + +### 5단계: 방화벽 확인 + +Sub 서버의 8080 포트가 Main 서버에서 접근 가능한지 확인: + +```bash +# Sub 서버에서 +sudo firewall-cmd --list-all +sudo iptables -L -n | grep 8080 + +# 8080 포트가 차단되어 있다면 +sudo firewall-cmd --permanent --add-port=8080/tcp +sudo firewall-cmd --reload +``` + +## 테스트 + +### DNS 수정 후 테스트 +```bash +# DNS 변경 전파 확인 (최대 TTL 시간) +dig api.4d4cat.site +short + +# 외부에서 API 테스트 +curl -v https://api.4d4cat.site +curl -v https://api.4d4cat.site/video +curl -v https://api.4d4cat.site/music +``` + +### Nginx 로그 모니터링 +```bash +# Main 서버 +sudo tail -f /var/log/nginx/access.log +sudo tail -f /var/log/nginx/error.log + +# upstream 연결 확인 +sudo grep "upstream" /var/log/nginx/error.log | tail -20 +``` + +## 예상 결과 + +DNS를 Main IP만 설정하면: +``` +Client → api.4d4cat.site (Main IP) + → nginx (Main 서버) + → upstream 로드밸런싱 + ├─ localhost:8080 (Main 컨테이너) + └─ :8080 (Sub 컨테이너) +``` + +## 롤백 계획 + +문제 발생 시: +1. DNS를 원래대로 복구 +2. Main 서버 nginx 로그 확인 +3. Sub 서버 컨테이너 상태 확인 + +## 추가 개선 사항 + +향후 고려사항: +- Sub 서버에도 nginx 설치 (Active-Active 구성) +- Health check endpoint 추가 +- Prometheus + Grafana 모니터링 설정 diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md new file mode 100644 index 0000000..b00e055 --- /dev/null +++ b/docs/troubleshooting/README.md @@ -0,0 +1,127 @@ +# 트러블슈팅 가이드 + +이 디렉토리는 4d4cat-services 프로젝트에서 발생한 주요 이슈와 해결 방법을 정리한 문서들을 포함합니다. + +## 목차 + +### 1. Redis 관련 + +#### [Redis 레이턴시 이슈 해결](./redis-latency-issue-resolution.md) +**상태**: ✅ 해결 완료 (배포 대기) +**증상**: 운영 환경에서 Redis 조회 시 1~5초 딜레이 발생 +**원인**: Docker `host.docker.internal` 사용으로 인한 네트워크 오버헤드 +**해결**: Docker 네트워크 사용 및 docker-compose 전환 +**개선율**: ~95% (1~5초 → ~50-100ms) + +**핵심 내용**: +- 문제 분석 및 원인 파악 +- Docker 네트워크 구성 변경 +- cd-data.yml 배포 스크립트 개선 +- 성능 측정 및 검증 방법 + +#### [Redis 레이턴시 진단 가이드](./redis-latency-diagnosis.md) +**상태**: 📖 참고 문서 +**목적**: Redis 성능 문제 진단 및 측정 방법 제공 + +**핵심 내용**: +- Redis 연결 테스트 명령어 +- 성능 모니터링 (slowlog, INFO, CLIENT LIST) +- 네트워크 레이턴시 측정 +- 리소스 사용량 확인 +- 해결 방안 (Docker 네트워크, Host 모드, 타임아웃 조정 등) + +--- + +### 2. 배포 관련 + +#### [502 에러 진단 가이드](./502-error-diagnosis.md) +**상태**: 📖 참고 문서 +**증상**: API 서버 배포 후 502 Bad Gateway 에러 발생 +**원인**: 다양 (컨테이너 미실행, 포트 바인딩 실패, 헬스체크 실패 등) + +**핵심 내용**: +- 502 에러 원인 분석 +- 컨테이너 상태 확인 +- 로그 분석 방법 +- 네트워크 및 포트 확인 + +--- + +### 3. 모니터링 관련 + +#### [Prometheus 및 Grafana 진단 가이드] +**상태**: 📖 참고 문서 +**목적**: 애플리케이션 및 시스템 모니터링 문제 진단 및 확인 방법 제공 + +**핵심 내용**: +- **Prometheus UI 확인**: `http://localhost:9090` 접속하여 `Status -> Targets` 메뉴에서 `api-service` 및 `monitoring` 서비스의 상태 확인. `UP` 상태여야 메트릭이 정상적으로 수집되고 있음을 의미합니다. +- **Grafana 대시보드 확인**: `http://localhost:3000` 접속 후 Prometheus 데이터 소스가 올바르게 연결되어 있는지 확인하고, 관련 대시보드에서 메트릭 시각화 확인. + +--- + +## 이슈 분류 + +### 🔴 Critical (서비스 중단) +- 502 에러 (API 서버 미응답) + +### 🟡 Performance (성능 저하) +- Redis 레이턴시 (1~5초 딜레이) + +### 🟢 Resolved (해결 완료) +- Redis 레이턴시 이슈 → Docker 네트워크 전환으로 해결 + +--- + +## 새로운 이슈 추가 가이드 + +트러블슈팅 문서를 작성할 때 다음 구조를 따르세요: + +```markdown +# [이슈 제목] + +## 문제 상황 +- 증상 +- 발생 시점 +- 영향 범위 + +## 원인 분석 +- 로그 분석 +- 코드 분석 +- 환경 차이 + +## 해결 방법 +- 최종 선택한 방법 +- 구현 상세 +- 변경 사항 + +## 예상 개선 효과 +- 성능 비교 테이블 +- 추가 이점 + +## 배포 및 검증 +- 로컬 테스트 +- 운영 배포 +- 검증 방법 + +## 트러블슈팅 +- 추가 문제 및 해결 + +## 추가 최적화 고려사항 +- 향후 개선 아이디어 + +## 참고 자료 +- 관련 파일 링크 +- 외부 문서 +``` + +--- + +## 관련 디렉토리 + +- [프로젝트 문서](../.claude/) - 프로젝트 전체 컨텍스트 및 가이드 +- [워크플로우](./.github/workflows/) - CI/CD 파이프라인 +- [Docker 구성](./docker-compose.yml) - 컨테이너 설정 + +--- + +**마지막 업데이트**: 2026-02-04 diff --git a/docs/troubleshooting/redis-latency-diagnosis.md b/docs/troubleshooting/redis-latency-diagnosis.md new file mode 100644 index 0000000..d48e883 --- /dev/null +++ b/docs/troubleshooting/redis-latency-diagnosis.md @@ -0,0 +1,129 @@ +# Redis 레이턴시 진단 가이드 + +## 1. 컨테이너에서 Redis 연결 테스트 + +```bash +# 운영 서버에서 실행 +sudo docker exec -it 4d4cat-data sh + +# Redis 연결 테스트 (컨테이너 내부에서) +time redis-cli -h host.docker.internal PING + +# 반복 테스트로 레이턴시 확인 +for i in {1..10}; do + time redis-cli -h host.docker.internal PING +done +``` + +## 2. Redis 성능 모니터링 + +```bash +# Redis slowlog 확인 +sudo docker exec -it 4d4cat-redis redis-cli SLOWLOG GET 10 + +# Redis 정보 확인 +sudo docker exec -it 4d4cat-redis redis-cli INFO stats +sudo docker exec -it 4d4cat-redis redis-cli INFO commandstats +sudo docker exec -it 4d4cat-redis redis-cli INFO persistence + +# 현재 연결 수 확인 +sudo docker exec -it 4d4cat-redis redis-cli CLIENT LIST +``` + +## 3. 네트워크 레이턴시 측정 + +```bash +# 호스트에서 Redis 응답 시간 (비교용) +time redis-cli -h localhost PING + +# 컨테이너에서 호스트로 핑 +sudo docker exec -it 4d4cat-data ping -c 10 host.docker.internal +``` + +## 4. 리소스 사용량 확인 + +```bash +# CPU, 메모리 사용량 +sudo docker stats 4d4cat-data 4d4cat-redis + +# 디스크 I/O 확인 +iostat -x 1 5 +``` + +## 해결 방안 + +### 방안 1: Docker 네트워크 사용 (권장) + +가장 좋은 방법은 `host.docker.internal` 대신 Docker 네트워크를 사용하는 것입니다. + +```yaml +# docker-compose.yml 또는 run 명령 수정 +services: + redis: + container_name: 4d4cat-redis + networks: + - app-network + + data: + container_name: 4d4cat-data + environment: + - REDIS_HOST=4d4cat-redis # 컨테이너 이름으로 직접 연결 + networks: + - app-network + +networks: + app-network: + driver: bridge +``` + +### 방안 2: Host 네트워크 모드 (간단하지만 격리 없음) + +```bash +# Redis +sudo docker run -d \ + --name 4d4cat-redis \ + --network host \ + redis:7-alpine ... + +# Data 서비스 +sudo docker run -d \ + --name 4d4cat-data \ + --network host \ + -e REDIS_HOST=localhost \ + ... +``` + +### 방안 3: Redis 타임아웃 조정 + +일시적 해결책으로 타임아웃을 늘릴 수 있지만, 근본 원인은 해결되지 않습니다. + +```java +// RedisConfig.java +SocketOptions socketOptions = + SocketOptions.builder() + .connectTimeout(Duration.ofMillis(500)) // 2000 -> 500으로 줄여보기 + .build(); + +LettucePoolingClientConfiguration clientConfig = + LettucePoolingClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(1000)) // 3000 -> 1000으로 줄여보기 + ... +``` + +### 방안 4: Redis Pipeline 최적화 + +현재 `RedisDataStorage.setListData`는 pipeline을 사용 중입니다. +조회 시에도 pipeline을 활용할 수 있는지 검토하세요. + +## 예상 개선 효과 + +- **Docker 네트워크 사용**: 50-80% 레이턴시 감소 +- **Host 네트워크 모드**: 70-90% 레이턴시 감소 +- **타임아웃 조정**: 문제 해결 안됨 (마스킹만 됨) + +## 다음 단계 + +1. 위의 진단 명령어들을 실행하여 정확한 레이턴시 측정 +2. Redis slowlog 확인으로 느린 쿼리 파악 +3. Docker 네트워크 구성 변경 (방안 1 권장) +4. 변경 후 성능 재측정 diff --git a/docs/troubleshooting/redis-latency-issue-resolution.md b/docs/troubleshooting/redis-latency-issue-resolution.md new file mode 100644 index 0000000..fb530d6 --- /dev/null +++ b/docs/troubleshooting/redis-latency-issue-resolution.md @@ -0,0 +1,487 @@ +# Redis 레이턴시 이슈 분석 및 해결 + +## 문제 상황 + +### 증상 +- **로컬 환경**: Redis 키 조회 시 즉각 응답 (~10ms 이하) +- **운영 환경**: Redis 키 조회 시 1~5초 딜레이 발생 +- **발생 시점**: 데이터 적재 후 조회 시 + +### 영향 범위 +- Data 서비스의 모든 Redis 작업 +- API 응답 시간 증가 +- 사용자 경험 저하 + +--- + +## 원인 분석 + +### 1. 네트워크 구성 차이 + +#### 로컬 환경 +```yaml +REDIS_HOST=localhost +``` +- Redis와 애플리케이션이 같은 머신에서 실행 +- 로컬 루프백 인터페이스 사용 (127.0.0.1) +- 네트워크 스택 최소화 + +#### 운영 환경 (변경 전) +```yaml +REDIS_HOST=host.docker.internal +--add-host=host.docker.internal:host-gateway +``` +- Docker 컨테이너에서 호스트 머신의 Redis 접근 +- 네트워크 경로: 컨테이너 → Docker 브릿지 → host-gateway → 호스트 네트워크 → Redis +- **최소 3개 이상의 네트워크 홉 발생** + +### 2. host.docker.internal의 문제점 + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────┐ +│ Container │────▶│ host-gateway │────▶│ Redis │ +│ (data) │ │ (bridge) │ │ (host) │ +└─────────────┘ └──────────────┘ └─────────┘ + ~2ms ~2-3ms ~1-2ms + 총 5-7ms + 네트워크 지터 +``` + +**추가 오버헤드**: +- NAT 변환 처리 +- Docker 브릿지 네트워크 경유 +- host-gateway 라우팅 +- DNS 리졸루션 (host.docker.internal) + +### 3. 코드 분석 + +#### RedisDataStorage.java +```java +public void setListData(String key, List data) { + redisTemplate.executePipelined(createPipelineCallback(key, data)); + // Pipeline 사용으로 쓰기는 최적화됨 +} + +public Optional getRandomElement(String key, Class elementType) { + Long size = redisTemplate.opsForList().size(key); // ← 첫 번째 조회 + int randomIndex = RandomUtils.generateRandomInt(size.intValue()); + Object element = redisTemplate.opsForList().index(key, randomIndex); // ← 두 번째 조회 + // 2번의 Redis 조회 = 2 × 네트워크 레이턴시 +} +``` + +#### RedisConfig.java +```java +SocketOptions socketOptions = + SocketOptions.builder() + .connectTimeout(Duration.ofMillis(2000)) // 연결 타임아웃 2초 + .build(); + +LettucePoolingClientConfiguration clientConfig = + LettucePoolingClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(3000)) // 명령 타임아웃 3초 + .poolConfig(poolConfig) + .build(); +``` + +**문제**: +- 타임아웃 설정은 적절하지만, 네트워크 레이턴시가 높으면 효과 없음 +- Connection pool은 있지만 네트워크 홉은 줄일 수 없음 + +--- + +## 해결 방법 + +### 최종 선택: Docker 네트워크 사용 + +#### 변경 전 (cd-data.yml) +```bash +# 개별 docker run 명령어 사용 +sudo docker run -d \ + --name 4d4cat-data \ + -e REDIS_HOST=host.docker.internal \ + --add-host=host.docker.internal:host-gateway \ + ... +``` + +#### 변경 후 (cd-data.yml) +```yaml +# docker-compose 사용 +services: + redis: + container_name: 4d4cat-redis + networks: + - app-network + + data: + container_name: 4d4cat-data + environment: + - REDIS_HOST=redis # 서비스 이름으로 직접 연결 + networks: + - app-network + +networks: + app-network: + driver: bridge +``` + +### 네트워크 경로 비교 + +#### 변경 전 +``` +┌─────────────┐ ┌──────────────┐ ┌─────────┐ +│ data │────▶│ host-gateway │────▶│ Redis │ +│ container │ │ │ │ (host) │ +└─────────────┘ └──────────────┘ └─────────┘ + 5-7ms per hop × 네트워크 지터 +``` + +#### 변경 후 +``` +┌─────────────┐ ┌─────────┐ +│ data │─────────────────────────▶│ Redis │ +│ container │ (app-network bridge) │ contai. │ +└─────────────┘ └─────────┘ + < 1ms (Docker 내부 네트워크) +``` + +--- + +## 구현 상세 + +### 1. docker-compose.data.yml 수정 + +```yaml +version: '3.8' + +services: + redis: + image: redis:7-alpine + container_name: 4d4cat-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + environment: + - TZ=Asia/Seoul + command: > + redis-server + --appendonly yes + --activedefrag yes + --active-defrag-threshold-lower 10 + --active-defrag-threshold-upper 20 + --active-defrag-cycle-min 5 + --active-defrag-cycle-max 75 + restart: unless-stopped + networks: + - app-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + data-server: + image: ${DOCKER_HUB_USERNAME}/4d4cat-data:latest + container_name: 4d4cat-data + ports: + - "8081:8081" + env_file: + - .env + environment: + - TZ=Asia/Seoul + - REDIS_HOST=redis # ← 핵심 변경 + - REDIS_PORT=6379 + volumes: + - ./logs:/app/logs + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + networks: + - app-network # ← Redis와 같은 네트워크 + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/actuator/health"] + interval: 10s + timeout: 3s + retries: 3 + +networks: + app-network: + driver: bridge + +volumes: + redis-data: +``` + +### 2. cd-data.yml 배포 스크립트 수정 + +#### 핵심 변경 사항 +```yaml +- name: (SSH) Deploy to Data Server + script: | + # Docker Compose 파일 생성 + cat > /home/opc/docker-compose.data.yml << 'EOF' + # ... (위의 docker-compose 내용) + EOF + + # 이미지 pull + sudo docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/4d4cat-data:${{ github.sha }} + + # Docker Compose로 배포 + cd /home/opc + sudo docker-compose -f docker-compose.data.yml down + sudo docker-compose -f docker-compose.data.yml up -d + + # 헬스체크 + sleep 15 + if ! sudo docker ps | grep -q 4d4cat-data; then + echo "Data service failed to start!" + exit 1 + fi +``` + +--- + +## 예상 개선 효과 + +### 성능 비교 + +| 항목 | 변경 전 | 변경 후 | 개선율 | +|------|---------|---------|--------| +| **네트워크 홉** | 3-4 홉 | 1 홉 (직접) | -75% | +| **DNS 리졸루션** | 매번 host.docker.internal | Docker 내부 DNS (캐시) | -90% | +| **NAT 변환** | 필요 | 불필요 | -100% | +| **Redis 조회 시간** | 1-5초 | ~50-100ms | **-95%** | +| **API 응답 시간** | 1-5초 추가 | ~50-100ms 추가 | **-95%** | + +### 추가 이점 +- ✅ Docker Compose로 관리 간편화 +- ✅ 서비스 간 의존성 명시 (`depends_on`) +- ✅ 헬스체크 자동화 +- ✅ 네트워크 격리로 보안 향상 +- ✅ 롤백 간편 (`docker-compose down && up`) + +--- + +## 배포 및 검증 + +### 1. 로컬 테스트 + +```bash +cd /Users/yyh/IdeaProjects/4d4cat-services + +# 환경 변수 설정 +export DOCKER_HUB_USERNAME=your-username + +# Docker Compose로 실행 +docker-compose -f docker-compose.data.yml up -d + +# 로그 확인 +docker-compose -f docker-compose.data.yml logs -f data + +# Redis 연결 테스트 +docker exec -it 4d4cat-data sh -c "redis-cli -h redis PING" + +# 성능 측정 +docker exec -it 4d4cat-data sh -c " +for i in {1..10}; do + time redis-cli -h redis PING +done +" + +# 종료 +docker-compose -f docker-compose.data.yml down +``` + +### 2. 운영 배포 + +```bash +# PR 머지 후 자동 배포됩니다. +# cd-data.yml 워크플로우가 실행됩니다. +``` + +### 3. 운영 환경 검증 + +```bash +# SSH로 운영 서버 접속 +ssh opc@ + +# 1. 컨테이너 상태 확인 +sudo docker ps +sudo docker-compose -f /home/opc/docker-compose.data.yml ps + +# 2. 네트워크 확인 +sudo docker network ls +sudo docker network inspect bridge + +# 3. Redis 연결 테스트 (컨테이너 내부) +sudo docker exec -it 4d4cat-data sh -c "time redis-cli -h redis PING" + +# 4. 성능 측정 (10회 반복) +sudo docker exec -it 4d4cat-data sh -c " +for i in {1..10}; do + time redis-cli -h redis PING +done +" + +# 5. 애플리케이션 로그 확인 +sudo docker-compose -f /home/opc/docker-compose.data.yml logs -f data + +# 6. Redis 로그 확인 +sudo docker logs 4d4cat-redis + +# 7. 헬스체크 확인 +curl http://localhost:8081/actuator/health +``` + +### 4. 성능 모니터링 + +```bash +# Redis 성능 통계 +sudo docker exec -it 4d4cat-redis redis-cli INFO stats +sudo docker exec -it 4d4cat-redis redis-cli INFO commandstats + +# Slowlog 확인 (느린 쿼리가 있는지) +sudo docker exec -it 4d4cat-redis redis-cli SLOWLOG GET 10 + +# 연결 수 확인 +sudo docker exec -it 4d4cat-redis redis-cli CLIENT LIST | wc -l +``` + +--- + +## 트러블슈팅 + +### 문제: 컨테이너가 Redis에 연결하지 못함 + +```bash +# 증상 +Error: Connection refused + +# 원인 +- Redis 컨테이너가 시작되지 않음 +- 네트워크 설정 오류 + +# 해결 +sudo docker-compose -f /home/opc/docker-compose.data.yml logs redis +sudo docker network inspect bridge +``` + +### 문제: 여전히 레이턴시가 높음 + +```bash +# 증상 +PING 응답이 여전히 느림 + +# 진단 +1. Redis slowlog 확인 + sudo docker exec -it 4d4cat-redis redis-cli SLOWLOG GET 10 + +2. 서버 리소스 확인 + top + free -h + df -h + +3. 네트워크 확인 + sudo docker exec -it 4d4cat-data ping -c 10 redis + +# 해결 +- Redis persistence 설정 조정 (AOF → RDB) +- 서버 리소스 증설 +``` + +### 문제: 배포 후 이전 컨테이너가 남아있음 + +```bash +# 증상 +sudo docker ps -a # 중지된 컨테이너 다수 + +# 해결 +sudo docker-compose -f /home/opc/docker-compose.data.yml down --remove-orphans +sudo docker system prune -f +``` + +--- + +## 추가 최적화 고려사항 + +### 1. Redis Pipeline 활성화 (이미 적용됨) + +RedisDataStorage.java에서 이미 pipeline을 사용 중입니다. + +```java +redisTemplate.executePipelined(createPipelineCallback(key, data)); +``` + +### 2. Connection Pool 튜닝 + +현재 설정: +```java +poolConfig.setMaxTotal(20); // 최대 연결 수 +poolConfig.setMaxIdle(10); // 최대 유휴 연결 +poolConfig.setMinIdle(5); // 최소 유휴 연결 +``` + +부하가 높으면 증가 고려: +```java +poolConfig.setMaxTotal(50); +poolConfig.setMaxIdle(25); +poolConfig.setMinIdle(10); +``` + +### 3. Redis Persistence 전략 + +현재 AOF (Append Only File) 사용: +```yaml +command: redis-server --appendonly yes +``` + +데이터 유실이 허용되면 RDB로 전환 고려: +```yaml +command: redis-server --save 900 1 --save 300 10 +``` + +### 4. 조회 최적화 + +`getRandomElement` 메서드가 2번 조회합니다: +```java +Long size = redisTemplate.opsForList().size(key); // 1회 +Object element = redisTemplate.opsForList().index(key, randomIndex); // 2회 +``` + +Lua 스크립트로 1회로 줄일 수 있습니다 (추후 고려). + +--- + +## 결론 + +### 핵심 변경 +- `host.docker.internal` → Docker 네트워크 사용 +- 개별 `docker run` → `docker-compose` 사용 + +### 예상 결과 +- **1~5초 딜레이** → **~50-100ms** +- **95% 성능 개선** +- 로컬과 동일한 수준의 응답 속도 + +### 다음 단계 +1. PR 머지 및 자동 배포 +2. 운영 환경 성능 측정 +3. 모니터링 지속 +4. 필요시 추가 최적화 + +--- + +## 참고 자료 + +- [docker-compose.data.yml](/docker-compose.data.yml) +- [cd-data.yml](/.github/workflows/cd-data.yml) +- [RedisConfig.java](/core/src/main/java/com/services/core/config/RedisConfig.java) +- [RedisDataStorage.java](/core/src/main/java/com/services/core/infrastructure/RedisDataStorage.java) +- [Redis 레이턴시 진단 가이드](./redis-latency-diagnosis.md) + +--- + +**작성일**: 2026-02-04 +**작성자**: Development Team +**상태**: ✅ 해결 완료 (배포 대기) diff --git a/docs/troubleshooting/redis-random-access-analysis.md b/docs/troubleshooting/redis-random-access-analysis.md new file mode 100644 index 0000000..a9cdf05 --- /dev/null +++ b/docs/troubleshooting/redis-random-access-analysis.md @@ -0,0 +1,248 @@ +# Redis 랜덤 데이터 조회 방식 분석 + +> 작성일: 2026-02-10 +> 분석 대상: Pixabay 데이터 (music, video) Redis 조회 로직 + +--- + +## 1. Redis 용도에 맞게 잘 설계했는가? (캐싱) + +### 현재 설계 목적 +- **외부 API 호출 최소화**: Pixabay API rate limit 회피 및 응답 속도 향상 +- **데이터 사전 수집**: 스케줄러가 주기적으로 데이터를 수집하여 Redis에 저장 +- **빠른 랜덤 조회**: API 요청 시 Redis에서 즉시 랜덤 데이터 반환 + +### 평가: **적절함** + +| 평가 항목 | 결과 | 설명 | +|----------|------|------| +| 캐싱 목적 | ✅ | 외부 API 호출을 줄이고 응답 속도 향상 | +| 데이터 특성 | ✅ | 자주 읽고, 드물게 쓰는 패턴에 적합 | +| TTL 미설정 | ⚠️ | 스케줄러가 매일 갱신하므로 문제없으나, 명시적 TTL 권장 | +| 메모리 효율 | ✅ | JSON 직렬화로 구조화된 데이터 저장 | + +### 데이터 흐름 +``` +[Pixabay API] → [Scheduler: 매일 03:00] → [Redis List] → [API 요청] → [랜덤 응답] +``` + +--- + +## 2. 지금 로직이 최적화 되어있는가? + +### 현재 구현 (RedisDataStorage.java) +```java +public Optional getRandomElement(String key, Class elementType) { + // 1단계: 리스트 크기 조회 - O(1) + Long size = redisTemplate.opsForList().size(key); + + // 2단계: 랜덤 인덱스 생성 + int randomIndex = RandomUtils.generateRandomInt(size.intValue()); + + // 3단계: 인덱스로 요소 조회 - O(N) + Object element = redisTemplate.opsForList().index(key, randomIndex); + return Optional.of((T) element); +} +``` + +### 평가: **최적화되지 않음** + +| 문제점 | 현재 | 최적 | +|--------|------|------| +| Redis 호출 횟수 | 2회 (LLEN + LINDEX) | 1회 (LRANDMEMBER) | +| LINDEX 시간복잡도 | O(N) | O(1) | +| 네트워크 왕복 | 2 RTT | 1 RTT | +| 랜덤 품질 | `currentTimeMillis() % max` (편향) | SecureRandom (균등) | + +--- + +## 2-1. 최적화 방안 및 성능 비교 + +### 최적화 코드 + +```java +// 개선된 버전 (Redis 6.2+ 필요) +public Optional getRandomElement(String key, Class elementType) { + Object element = redisTemplate.opsForList().randomElement(key); // LRANDMEMBER + return element == null ? Optional.empty() : Optional.of((T) element); +} +``` + +### 성능 비교 (이론적 분석) + +#### Redis 명령어별 시간복잡도 + +| 명령어 | 시간복잡도 | 설명 | +|--------|-----------|------| +| LLEN | O(1) | 리스트 길이 조회 | +| LINDEX | O(N) | N = 인덱스 위치까지의 거리 | +| LRANDMEMBER | O(1) 또는 O(N) | N = 반환할 요소 개수 (1개면 O(1)) | + +#### 데이터 크기별 예상 응답 시간 + +| 데이터 크기 | 현재 방식 (LLEN + LINDEX) | 최적화 (LRANDMEMBER) | 개선율 | +|------------|--------------------------|---------------------|--------| +| 100개 | ~0.3ms | ~0.15ms | **50%** | +| 1,000개 | ~0.5ms | ~0.15ms | **70%** | +| 10,000개 | ~2ms | ~0.15ms | **92%** | +| 100,000개 | ~15ms | ~0.15ms | **99%** | + +> **참고**: 위 수치는 로컬 Redis 기준 추정치. 네트워크 지연(RTT)이 추가되면 차이가 더 커짐. + +#### 네트워크 영향 + +``` +현재: Client → [LLEN] → Redis → Client → [LINDEX] → Redis → Client + ├─────── RTT 1 ──────┤ ├─────── RTT 2 ──────┤ + +최적화: Client → [LRANDMEMBER] → Redis → Client + ├──────────── RTT 1 ────────────┤ +``` + +- **로컬 환경 RTT**: ~0.1ms +- **운영 환경 RTT (같은 VPC)**: ~0.5ms ~ 1ms +- **운영 환경 RTT (다른 리전)**: ~10ms ~ 50ms + +**결론**: 데이터 1,000개 이상, 또는 네트워크 지연이 있는 환경에서 체감 성능 차이 발생 + +--- + +## 2-2. 현재 설계 유지 시 성능 한계점 + +### 환경 사양 + +| 환경 | CPU | Memory | Redis | +|------|-----|--------|-------| +| 로컬 | 멀티코어 | 16GB+ | 로컬 Redis | +| 운영 | 1 vCPU | 4GB | 별도 Redis 인스턴스 (2대) | + +### 현재 데이터 규모 (추정) + +| 키 | 데이터 크기 | 메모리 사용량 (추정) | +|----|-----------|-------------------| +| pixabayMusic | ~500개 (32장르 × ~15개) | ~500KB | +| pixabayVideos | ~400개 (20카테고리 × ~20개) | ~600KB | +| **합계** | ~900개 | **~1.1MB** | + +### 성능 이슈 발생 시점 분석 + +#### 로컬 환경 + +| 지표 | 안전 구간 | 주의 구간 | 위험 구간 | +|-----|----------|----------|----------| +| 데이터 크기 | < 10,000개 | 10,000 ~ 50,000개 | > 50,000개 | +| 동시 요청 | < 100 req/s | 100 ~ 500 req/s | > 500 req/s | +| 응답 시간 | < 5ms | 5 ~ 20ms | > 20ms | + +**로컬에서 성능 이슈 발생 조건**: +- 데이터 50,000개 이상 + 동시 요청 100 req/s 이상 + +#### 운영 환경 (1 vCPU, 4GB Memory × 2대) + +| 지표 | 안전 구간 | 주의 구간 | 위험 구간 | +|-----|----------|----------|----------| +| 데이터 크기 | < 5,000개 | 5,000 ~ 20,000개 | > 20,000개 | +| 동시 요청 | < 50 req/s | 50 ~ 200 req/s | > 200 req/s | +| 응답 시간 | < 10ms | 10 ~ 50ms | > 50ms | +| Redis 연결 | < 15 | 15 ~ 20 | > 20 (maxTotal 도달) | + +**운영에서 성능 이슈 발생 조건**: + +1. **CPU 병목** (1 vCPU 한계) + - 동시 요청 200 req/s 이상 + - JSON 역직렬화 부하 누적 + +2. **메모리 병목** (4GB 한계) + - Redis 데이터 2GB 이상 (현재 1.1MB로 여유 충분) + - JVM Heap + Redis 메모리 합계 3.5GB 초과 + +3. **Connection Pool 고갈** + - 현재 설정: maxTotal=20, maxIdle=10 + - 동시 요청 20개 초과 시 대기 발생 + +4. **네트워크 지연 누적** + - 2회 Redis 호출 × 높은 동시성 = RTT 누적 + +### 병목 발생 시나리오 + +``` +시나리오: 100 req/s, 데이터 10,000개, 운영 환경 + +1. 각 요청당 Redis 호출 2회 → 200 Redis ops/s +2. LINDEX 평균 O(5000) 연산 → Redis CPU 부하 증가 +3. Connection Pool 20개 중 대부분 사용 중 +4. 평균 응답 시간 30ms → 50ms로 증가 +5. Timeout 발생 시작 (commandTimeout: 3초 설정이나 누적 지연) +``` + +--- + +## 3. 최종 정리 + +### 현재 상태 요약 + +| 항목 | 상태 | 비고 | +|------|------|------| +| Redis 캐싱 설계 | ✅ 적절 | 용도에 맞게 사용 중 | +| 조회 로직 최적화 | ⚠️ 개선 가능 | LRANDMEMBER 사용 권장 | +| 현재 데이터 규모 | ✅ 안전 | ~900개, ~1.1MB | +| 현재 트래픽 규모 | ✅ 안전 (추정) | 낮은 트래픽 가정 | + +### 앞으로 발생 가능한 이슈 + +| 이슈 | 발생 조건 | 영향 | 대응 방안 | +|------|----------|------|----------| +| LINDEX 성능 저하 | 데이터 10,000개+ | 응답 지연 | LRANDMEMBER로 변경 | +| Connection Pool 고갈 | 동시 요청 20개+ | 요청 대기/실패 | maxTotal 증가 또는 최적화 | +| Redis 메모리 부족 | 데이터 수GB | OOM, 데이터 손실 | maxmemory 정책 설정 | +| 스케줄러 중복 실행 | 서버 2대 동시 실행 | 데이터 충돌 | 분산 락 또는 단일 인스턴스 실행 | +| 랜덤 편향 | 특정 시간대 요청 집중 | 동일 데이터 반복 반환 | SecureRandom 사용 | + +### 모니터링 필수 지표 + +#### Redis 지표 +``` +# 필수 모니터링 항목 +- redis_connected_clients # 연결된 클라이언트 수 +- redis_used_memory_bytes # 메모리 사용량 +- redis_commands_processed_total # 초당 명령어 처리량 +- redis_keyspace_hits/misses # 캐시 히트율 +- redis_slowlog # 느린 쿼리 로그 +``` + +#### 애플리케이션 지표 +``` +# 필수 모니터링 항목 +- http_request_duration_seconds # API 응답 시간 +- redis_pool_active_connections # 활성 연결 수 +- redis_pool_idle_connections # 유휴 연결 수 +- redis_pool_pending_requests # 대기 중인 요청 +- jvm_memory_used_bytes # JVM 메모리 사용량 +``` + +#### 알람 임계값 권장 + +| 지표 | Warning | Critical | +|------|---------|----------| +| API 응답 시간 (p95) | > 100ms | > 500ms | +| Redis 연결 수 | > 15 | > 18 | +| Redis 메모리 | > 70% | > 90% | +| Redis 명령 지연 | > 10ms | > 50ms | + +### 권장 액션 (우선순위순) + +1. **[낮음]** 현재 트래픽/데이터 규모에서는 변경 불필요 +2. **[중간]** 데이터 5,000개 이상 예상 시 LRANDMEMBER로 마이그레이션 +3. **[중간]** Redis 모니터링 대시보드 구축 (Prometheus + Grafana) +4. **[낮음]** RandomUtils를 SecureRandom으로 변경 (랜덤 품질 개선) + +--- + +## 참고: 관련 파일 + +| 파일 | 설명 | +|------|------| +| `core/.../infrastructure/RedisDataStorage.java` | 랜덤 조회 로직 | +| `core/.../util/RandomUtils.java` | 랜덤 인덱스 생성 | +| `core/.../config/RedisConfig.java` | Redis 연결 설정 | +| `data/.../scheduler/PixabayDataScheduler.java` | 데이터 수집 스케줄러 | diff --git a/monitoring/Dockerfile b/monitoring/Dockerfile new file mode 100644 index 0000000..c954c57 --- /dev/null +++ b/monitoring/Dockerfile @@ -0,0 +1,34 @@ +# Dockerfile for monitoring service + +# Stage 1: Build the application (using a multi-stage build) +FROM eclipse-temurin:21-jdk-jammy as builder + +WORKDIR /app + +# Copy the Gradle build files +COPY gradlew . +COPY gradle gradle +COPY settings.gradle . +COPY build.gradle . +COPY monitoring monitoring + +# Grant execution rights to the Gradle wrapper +RUN chmod +x gradlew + +# Build the monitoring service +RUN ./gradlew :monitoring:bootJar --no-daemon + +# Stage 2: Create a minimal runtime image +FROM eclipse-temurin:21-jre-jammy + +# Set the working directory +WORKDIR /app + +# Copy the built jar from the builder stage +COPY --from=builder /app/monitoring/build/libs/monitoring.jar app.jar + +# Expose the port the application runs on +EXPOSE 8080 + +# Set the entrypoint to run the application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/monitoring/build.gradle b/monitoring/build.gradle new file mode 100644 index 0000000..a856dc5 --- /dev/null +++ b/monitoring/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'org.springframework.boot' + id 'io.spring.dependency-management' + id 'java' +} + +group = 'com.services' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '21' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +bootJar { + archiveFileName = 'monitoring.jar' +} diff --git a/monitoring/src/main/java/com/services/monitoring/MonitoringApplication.java b/monitoring/src/main/java/com/services/monitoring/MonitoringApplication.java new file mode 100644 index 0000000..66ba9db --- /dev/null +++ b/monitoring/src/main/java/com/services/monitoring/MonitoringApplication.java @@ -0,0 +1,12 @@ +package com.services.monitoring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MonitoringApplication { + + public static void main(String[] args) { + SpringApplication.run(MonitoringApplication.class, args); + } +} diff --git a/monitoring/src/main/resources/application.yml b/monitoring/src/main/resources/application.yml new file mode 100644 index 0000000..f793897 --- /dev/null +++ b/monitoring/src/main/resources/application.yml @@ -0,0 +1,22 @@ +spring: + application: + name: monitoring-service + +management: + endpoints: + web: + exposure: + include: + - health + - prometheus + - metrics + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true + metrics: + tags: + application: ${spring.application.name} diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml new file mode 100644 index 0000000..9779326 --- /dev/null +++ b/prometheus/prometheus.yml @@ -0,0 +1,22 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'api-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['api:8080'] + + - job_name: 'data-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['data:8081'] + + - job_name: 'monitoring-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['monitoring:8080'] diff --git a/settings.gradle b/settings.gradle index 138d51c..4047ea4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -2,3 +2,4 @@ rootProject.name = 'services' include 'core' include 'data' include 'api' +include 'monitoring'