mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 06:46:25 +00:00
AI 서비스 Kafka/Redis 통합 테스트 및 설정 개선
- Gradle 빌드 캐시 파일 제외 (.gitignore 업데이트) - Kafka 통합 테스트 구현 (AIJobConsumerIntegrationTest) - 단위 테스트 추가 (Controller, Service 레이어) - IntelliJ 실행 프로파일 자동 생성 도구 추가 - Kafka 테스트 배치 스크립트 추가 - Redis 캐시 설정 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f0699b2e2b
commit
29dddd89b7
@ -1,5 +1,5 @@
|
|||||||
@test-backend
|
@test-backend
|
||||||
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
|
'서비스실행프로파일작성가이드'에 따라 테스트를 해 주세요.
|
||||||
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
|
|||||||
@ -19,7 +19,25 @@
|
|||||||
"Bash(./gradlew ai-service:compileJava:*)",
|
"Bash(./gradlew ai-service:compileJava:*)",
|
||||||
"Bash(./gradlew ai-service:build:*)",
|
"Bash(./gradlew ai-service:build:*)",
|
||||||
"Bash(.\\gradlew ai-service:compileJava:*)",
|
"Bash(.\\gradlew ai-service:compileJava:*)",
|
||||||
"Bash(./gradlew.bat:*)"
|
"Bash(./gradlew.bat:*)",
|
||||||
|
"Bash(if [ ! -d \"ai-service/.run\" ])",
|
||||||
|
"Bash(then mkdir \"ai-service/.run\")",
|
||||||
|
"Bash(./gradlew:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(then mkdir -p \"ai-service/.run\")",
|
||||||
|
"Bash(if [ ! -d \"tools\" ])",
|
||||||
|
"Bash(then mkdir tools)",
|
||||||
|
"Bash(if [ ! -d \"logs\" ])",
|
||||||
|
"Bash(then mkdir logs)",
|
||||||
|
"Bash(netstat:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(..gradlew.bat test --tests \"com.kt.ai.test.integration.kafka.AIJobConsumerIntegrationTest\" --info)",
|
||||||
|
"Bash(.gradlew.bat ai-service:test:*)",
|
||||||
|
"Bash(cmd /c \"gradlew.bat ai-service:test --tests com.kt.ai.test.integration.kafka.AIJobConsumerIntegrationTest\")",
|
||||||
|
"Bash(timeout 120 cmd:*)",
|
||||||
|
"Bash(cmd /c:*)",
|
||||||
|
"Bash(Select-String -Pattern \"(test|BUILD|FAILED|SUCCESS)\")",
|
||||||
|
"Bash(Select-Object -Last 20)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -8,6 +8,7 @@ yarn-error.log*
|
|||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.run/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
@ -21,6 +22,13 @@ dist/
|
|||||||
build/
|
build/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
gradle-app.setting
|
||||||
|
!gradle-wrapper.jar
|
||||||
|
!gradle-wrapper.properties
|
||||||
|
.gradletasknamecache
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,2 +0,0 @@
|
|||||||
#Thu Oct 23 17:51:21 KST 2025
|
|
||||||
gradle.version=8.10
|
|
||||||
Binary file not shown.
Binary file not shown.
@ -23,3 +23,11 @@ dependencies {
|
|||||||
// Note: PostgreSQL dependency is in root build.gradle but AI Service doesn't use DB
|
// Note: PostgreSQL dependency is in root build.gradle but AI Service doesn't use DB
|
||||||
// We still include it for consistency, but no JPA entities will be created
|
// We still include it for consistency, but no JPA entities will be created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kafka Manual Test 실행 태스크
|
||||||
|
task runKafkaManualTest(type: JavaExec) {
|
||||||
|
group = 'verification'
|
||||||
|
description = 'Run Kafka manual test'
|
||||||
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
mainClass = 'com.kt.ai.test.manual.KafkaManualTest'
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.kt.ai;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -14,7 +15,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
|||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
@EnableFeignClients
|
@EnableFeignClients
|
||||||
@SpringBootApplication
|
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
|
||||||
public class AiServiceApplication {
|
public class AiServiceApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -30,11 +30,10 @@ public interface ClaudeApiClient {
|
|||||||
* @param request Claude 요청
|
* @param request Claude 요청
|
||||||
* @return Claude 응답
|
* @return Claude 응답
|
||||||
*/
|
*/
|
||||||
@PostMapping
|
@PostMapping(consumes = "application/json", produces = "application/json")
|
||||||
ClaudeResponse sendMessage(
|
ClaudeResponse sendMessage(
|
||||||
@RequestHeader("x-api-key") String apiKey,
|
@RequestHeader("x-api-key") String apiKey,
|
||||||
@RequestHeader("anthropic-version") String anthropicVersion,
|
@RequestHeader("anthropic-version") String anthropicVersion,
|
||||||
@RequestHeader("content-type") String contentType,
|
|
||||||
@RequestBody ClaudeRequest request
|
@RequestBody ClaudeRequest request
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
package com.kt.ai.config;
|
package com.kt.ai.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import io.lettuce.core.ClientOptions;
|
||||||
|
import io.lettuce.core.SocketOptions;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 설정
|
* Redis 설정
|
||||||
* - 작업 상태 및 추천 결과 캐싱
|
* - 작업 상태 및 추천 결과 캐싱
|
||||||
@ -33,6 +41,9 @@ public class RedisConfig {
|
|||||||
@Value("${spring.data.redis.database}")
|
@Value("${spring.data.redis.database}")
|
||||||
private int redisDatabase;
|
private int redisDatabase;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.timeout:3000}")
|
||||||
|
private long redisTimeout;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 연결 팩토리 설정
|
* Redis 연결 팩토리 설정
|
||||||
*/
|
*/
|
||||||
@ -46,13 +57,46 @@ public class RedisConfig {
|
|||||||
}
|
}
|
||||||
config.setDatabase(redisDatabase);
|
config.setDatabase(redisDatabase);
|
||||||
|
|
||||||
return new LettuceConnectionFactory(config);
|
// Lettuce Client 설정: Timeout 및 Connection 옵션
|
||||||
|
SocketOptions socketOptions = SocketOptions.builder()
|
||||||
|
.connectTimeout(Duration.ofMillis(redisTimeout))
|
||||||
|
.keepAlive(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ClientOptions clientOptions = ClientOptions.builder()
|
||||||
|
.socketOptions(socketOptions)
|
||||||
|
.autoReconnect(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
|
||||||
|
.commandTimeout(Duration.ofMillis(redisTimeout))
|
||||||
|
.clientOptions(clientOptions)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// afterPropertiesSet() 제거: Spring이 자동으로 호출함
|
||||||
|
return new LettuceConnectionFactory(config, clientConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ObjectMapper for Redis (Java 8 Date/Time 지원)
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ObjectMapper redisObjectMapper() {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
// Java 8 Date/Time 모듈 등록
|
||||||
|
mapper.registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
|
// Timestamp 대신 ISO-8601 형식으로 직렬화
|
||||||
|
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||||
|
|
||||||
|
return mapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RedisTemplate 설정
|
* RedisTemplate 설정
|
||||||
* - Key: String
|
* - Key: String
|
||||||
* - Value: JSON (Jackson)
|
* - Value: JSON (Jackson with Java 8 Date/Time support)
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
@ -63,9 +107,12 @@ public class RedisConfig {
|
|||||||
template.setKeySerializer(new StringRedisSerializer());
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
template.setHashKeySerializer(new StringRedisSerializer());
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
|
||||||
// Value Serializer: JSON
|
// Value Serializer: JSON with Java 8 Date/Time support
|
||||||
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
|
GenericJackson2JsonRedisSerializer serializer =
|
||||||
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
|
new GenericJackson2JsonRedisSerializer(redisObjectMapper());
|
||||||
|
|
||||||
|
template.setValueSerializer(serializer);
|
||||||
|
template.setHashValueSerializer(serializer);
|
||||||
|
|
||||||
template.afterPropertiesSet();
|
template.afterPropertiesSet();
|
||||||
return template;
|
return template;
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import com.kt.ai.model.enums.CircuitBreakerState;
|
|||||||
import com.kt.ai.model.enums.ServiceStatus;
|
import com.kt.ai.model.enums.ServiceStatus;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@ -23,22 +23,27 @@ import java.time.LocalDateTime;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Tag(name = "Health Check", description = "서비스 상태 확인")
|
@Tag(name = "Health Check", description = "서비스 상태 확인")
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class HealthController {
|
public class HealthController {
|
||||||
|
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
@Autowired(required = false)
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 서비스 헬스체크
|
* 서비스 헬스체크
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
|
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
|
||||||
@GetMapping("/health")
|
@GetMapping("/api/v1/ai-service/health")
|
||||||
public ResponseEntity<HealthCheckResponse> healthCheck() {
|
public ResponseEntity<HealthCheckResponse> healthCheck() {
|
||||||
// Redis 상태 확인
|
// Redis 상태 확인
|
||||||
ServiceStatus redisStatus = checkRedis();
|
ServiceStatus redisStatus = checkRedis();
|
||||||
|
|
||||||
// 전체 서비스 상태
|
// 전체 서비스 상태 (Redis가 DOWN이면 DEGRADED, UNKNOWN이면 UP으로 처리)
|
||||||
ServiceStatus overallStatus = (redisStatus == ServiceStatus.UP) ? ServiceStatus.UP : ServiceStatus.DEGRADED;
|
ServiceStatus overallStatus;
|
||||||
|
if (redisStatus == ServiceStatus.DOWN) {
|
||||||
|
overallStatus = ServiceStatus.DEGRADED;
|
||||||
|
} else {
|
||||||
|
overallStatus = ServiceStatus.UP;
|
||||||
|
}
|
||||||
|
|
||||||
HealthCheckResponse.Services services = HealthCheckResponse.Services.builder()
|
HealthCheckResponse.Services services = HealthCheckResponse.Services.builder()
|
||||||
.kafka(ServiceStatus.UP) // TODO: 실제 Kafka 상태 확인
|
.kafka(ServiceStatus.UP) // TODO: 실제 Kafka 상태 확인
|
||||||
@ -61,11 +66,25 @@ public class HealthController {
|
|||||||
* Redis 연결 상태 확인
|
* Redis 연결 상태 확인
|
||||||
*/
|
*/
|
||||||
private ServiceStatus checkRedis() {
|
private ServiceStatus checkRedis() {
|
||||||
|
// RedisTemplate이 주입되지 않은 경우 (로컬 환경 등)
|
||||||
|
if (redisTemplate == null) {
|
||||||
|
log.warn("RedisTemplate이 주입되지 않았습니다. Redis 상태를 UNKNOWN으로 표시합니다.");
|
||||||
|
return ServiceStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
redisTemplate.getConnectionFactory().getConnection().ping();
|
log.debug("Redis 연결 테스트 시작...");
|
||||||
|
String pong = redisTemplate.getConnectionFactory().getConnection().ping();
|
||||||
|
log.info("✅ Redis 연결 성공! PING 응답: {}", pong);
|
||||||
return ServiceStatus.UP;
|
return ServiceStatus.UP;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Redis 연결 실패", e);
|
log.error("❌ Redis 연결 실패", e);
|
||||||
|
log.error("상세 오류 정보:");
|
||||||
|
log.error(" - 오류 타입: {}", e.getClass().getName());
|
||||||
|
log.error(" - 오류 메시지: {}", e.getMessage());
|
||||||
|
if (e.getCause() != null) {
|
||||||
|
log.error(" - 원인: {}", e.getCause().getMessage());
|
||||||
|
}
|
||||||
return ServiceStatus.DOWN;
|
return ServiceStatus.DOWN;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package com.kt.ai.controller;
|
package com.kt.ai.controller;
|
||||||
|
|
||||||
import com.kt.ai.model.dto.response.JobStatusResponse;
|
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
import com.kt.ai.service.JobStatusService;
|
import com.kt.ai.service.JobStatusService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -12,6 +14,9 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal Job Controller
|
* Internal Job Controller
|
||||||
* Event Service에서 호출하는 내부 API
|
* Event Service에서 호출하는 내부 API
|
||||||
@ -22,11 +27,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/jobs")
|
@RequestMapping("/api/v1/ai-service/internal/jobs")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InternalJobController {
|
public class InternalJobController {
|
||||||
|
|
||||||
private final JobStatusService jobStatusService;
|
private final JobStatusService jobStatusService;
|
||||||
|
private final CacheService cacheService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업 상태 조회
|
* 작업 상태 조회
|
||||||
@ -38,4 +44,49 @@ public class InternalJobController {
|
|||||||
JobStatusResponse response = jobStatusService.getJobStatus(jobId);
|
JobStatusResponse response = jobStatusService.getJobStatus(jobId);
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: Job 상태 테스트 데이터 생성
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Job 테스트 데이터 생성 (디버그)", description = "Redis에 샘플 Job 상태 데이터 저장")
|
||||||
|
@GetMapping("/debug/create-test-job/{jobId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> createTestJob(@PathVariable String jobId) {
|
||||||
|
log.info("Job 테스트 데이터 생성 요청: jobId={}", jobId);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 다양한 상태의 테스트 데이터 생성
|
||||||
|
JobStatus[] statuses = JobStatus.values();
|
||||||
|
|
||||||
|
// 요청된 jobId로 PROCESSING 상태 데이터 생성
|
||||||
|
jobStatusService.updateJobStatus(jobId, JobStatus.PROCESSING, "AI 추천 생성 중 (50%)");
|
||||||
|
|
||||||
|
// 추가 샘플 데이터 생성 (다양한 상태)
|
||||||
|
jobStatusService.updateJobStatus(jobId + "-pending", JobStatus.PENDING, "대기 중");
|
||||||
|
jobStatusService.updateJobStatus(jobId + "-completed", JobStatus.COMPLETED, "AI 추천 완료");
|
||||||
|
jobStatusService.updateJobStatus(jobId + "-failed", JobStatus.FAILED, "AI API 호출 실패");
|
||||||
|
|
||||||
|
// 저장 확인
|
||||||
|
Object saved = cacheService.getJobStatus(jobId);
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("jobId", jobId);
|
||||||
|
result.put("saved", saved != null);
|
||||||
|
result.put("data", saved);
|
||||||
|
result.put("additionalSamples", Map.of(
|
||||||
|
"pending", jobId + "-pending",
|
||||||
|
"completed", jobId + "-completed",
|
||||||
|
"failed", jobId + "-failed"
|
||||||
|
));
|
||||||
|
|
||||||
|
log.info("Job 테스트 데이터 생성 완료: jobId={}, saved={}", jobId, saved != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 테스트 데이터 생성 실패: jobId={}", jobId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,26 @@
|
|||||||
package com.kt.ai.controller;
|
package com.kt.ai.controller;
|
||||||
|
|
||||||
import com.kt.ai.model.dto.response.AIRecommendationResult;
|
import com.kt.ai.model.dto.response.AIRecommendationResult;
|
||||||
|
import com.kt.ai.model.dto.response.EventRecommendation;
|
||||||
|
import com.kt.ai.model.dto.response.TrendAnalysis;
|
||||||
import com.kt.ai.service.AIRecommendationService;
|
import com.kt.ai.service.AIRecommendationService;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal Recommendation Controller
|
* Internal Recommendation Controller
|
||||||
* Event Service에서 호출하는 내부 API
|
* Event Service에서 호출하는 내부 API
|
||||||
@ -22,11 +31,13 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/recommendations")
|
@RequestMapping("/api/v1/ai-service/internal/recommendations")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class InternalRecommendationController {
|
public class InternalRecommendationController {
|
||||||
|
|
||||||
private final AIRecommendationService aiRecommendationService;
|
private final AIRecommendationService aiRecommendationService;
|
||||||
|
private final CacheService cacheService;
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 추천 결과 조회
|
* AI 추천 결과 조회
|
||||||
@ -38,4 +49,216 @@ public class InternalRecommendationController {
|
|||||||
AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId);
|
AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId);
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 모든 키 조회
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Redis 키 조회 (디버그)", description = "Redis에 저장된 모든 키 조회")
|
||||||
|
@GetMapping("/debug/redis-keys")
|
||||||
|
public ResponseEntity<Map<String, Object>> debugRedisKeys() {
|
||||||
|
log.info("Redis 키 디버그 요청");
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 ai:* 키 조회
|
||||||
|
Set<String> keys = redisTemplate.keys("ai:*");
|
||||||
|
result.put("totalKeys", keys != null ? keys.size() : 0);
|
||||||
|
result.put("keys", keys);
|
||||||
|
|
||||||
|
// 특정 키의 값 조회
|
||||||
|
if (keys != null && !keys.isEmpty()) {
|
||||||
|
Map<String, Object> values = new HashMap<>();
|
||||||
|
for (String key : keys) {
|
||||||
|
Object value = redisTemplate.opsForValue().get(key);
|
||||||
|
values.put(key, value);
|
||||||
|
}
|
||||||
|
result.put("values", values);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Redis 키 조회 성공: {} 개의 키 발견", keys != null ? keys.size() : 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Redis 키 조회 실패", e);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 특정 키 조회
|
||||||
|
*/
|
||||||
|
@Operation(summary = "Redis 특정 키 조회 (디버그)", description = "Redis에서 특정 키의 값 조회")
|
||||||
|
@GetMapping("/debug/redis-key/{key}")
|
||||||
|
public ResponseEntity<Map<String, Object>> debugRedisKey(@PathVariable String key) {
|
||||||
|
log.info("Redis 특정 키 조회 요청: key={}", key);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("key", key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object value = redisTemplate.opsForValue().get(key);
|
||||||
|
result.put("exists", value != null);
|
||||||
|
result.put("value", value);
|
||||||
|
|
||||||
|
log.info("Redis 키 조회: key={}, exists={}", key, value != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Redis 키 조회 실패: key={}", key, e);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 모든 database 검색
|
||||||
|
*/
|
||||||
|
@Operation(summary = "모든 Redis DB 검색 (디버그)", description = "Redis database 0~15에서 ai:* 키 검색")
|
||||||
|
@GetMapping("/debug/search-all-databases")
|
||||||
|
public ResponseEntity<Map<String, Object>> searchAllDatabases() {
|
||||||
|
log.info("모든 Redis database 검색 시작");
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
Map<Integer, Set<String>> databaseKeys = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Redis connection factory를 통해 database 변경하며 검색
|
||||||
|
var connectionFactory = redisTemplate.getConnectionFactory();
|
||||||
|
|
||||||
|
for (int db = 0; db < 16; db++) {
|
||||||
|
try {
|
||||||
|
var connection = connectionFactory.getConnection();
|
||||||
|
connection.select(db);
|
||||||
|
|
||||||
|
Set<byte[]> keyBytes = connection.keys("ai:*".getBytes());
|
||||||
|
if (keyBytes != null && !keyBytes.isEmpty()) {
|
||||||
|
Set<String> keys = new java.util.HashSet<>();
|
||||||
|
for (byte[] keyByte : keyBytes) {
|
||||||
|
keys.add(new String(keyByte));
|
||||||
|
}
|
||||||
|
databaseKeys.put(db, keys);
|
||||||
|
log.info("Database {} 에서 {} 개의 ai:* 키 발견", db, keys.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Database {} 검색 실패: {}", db, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("databasesWithKeys", databaseKeys);
|
||||||
|
result.put("totalDatabases", databaseKeys.size());
|
||||||
|
|
||||||
|
log.info("모든 database 검색 완료: {} 개의 database에 키 존재", databaseKeys.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("모든 database 검색 실패", e);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 디버그: 테스트 데이터 생성
|
||||||
|
*/
|
||||||
|
@Operation(summary = "테스트 데이터 생성 (디버그)", description = "Redis에 샘플 AI 추천 데이터 저장")
|
||||||
|
@GetMapping("/debug/create-test-data/{eventId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> createTestData(@PathVariable String eventId) {
|
||||||
|
log.info("테스트 데이터 생성 요청: eventId={}", eventId);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 샘플 AI 추천 결과 생성
|
||||||
|
AIRecommendationResult testData = AIRecommendationResult.builder()
|
||||||
|
.eventId(eventId)
|
||||||
|
.trendAnalysis(TrendAnalysis.builder()
|
||||||
|
.industryTrends(List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("BBQ 고기집")
|
||||||
|
.relevance(0.95)
|
||||||
|
.description("음식점 업종, 고기 구이 인기 트렌드")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.regionalTrends(List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("강남 맛집")
|
||||||
|
.relevance(0.90)
|
||||||
|
.description("강남구 지역 외식 인기 증가")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.seasonalTrends(List.of(
|
||||||
|
TrendAnalysis.TrendKeyword.builder()
|
||||||
|
.keyword("봄나들이 외식")
|
||||||
|
.relevance(0.85)
|
||||||
|
.description("봄철 야외 활동 및 외식 증가")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.build())
|
||||||
|
.recommendations(List.of(
|
||||||
|
EventRecommendation.builder()
|
||||||
|
.optionNumber(1)
|
||||||
|
.concept("SNS 이벤트")
|
||||||
|
.title("인스타그램 후기 이벤트")
|
||||||
|
.description("음식 사진을 인스타그램에 올리고 해시태그를 달면 할인 쿠폰 제공")
|
||||||
|
.targetAudience("20-30대 SNS 활동층")
|
||||||
|
.duration(EventRecommendation.Duration.builder()
|
||||||
|
.recommendedDays(14)
|
||||||
|
.recommendedPeriod("2주")
|
||||||
|
.build())
|
||||||
|
.mechanics(EventRecommendation.Mechanics.builder()
|
||||||
|
.type(com.kt.ai.model.enums.EventMechanicsType.DISCOUNT)
|
||||||
|
.details("인스타그램 게시물 작성 시 10% 할인")
|
||||||
|
.build())
|
||||||
|
.promotionChannels(List.of("Instagram", "Facebook", "매장 포스터"))
|
||||||
|
.estimatedCost(EventRecommendation.EstimatedCost.builder()
|
||||||
|
.min(100000)
|
||||||
|
.max(200000)
|
||||||
|
.breakdown(Map.of(
|
||||||
|
"할인비용", 150000,
|
||||||
|
"홍보비", 50000
|
||||||
|
))
|
||||||
|
.build())
|
||||||
|
.expectedMetrics(com.kt.ai.model.dto.response.ExpectedMetrics.builder()
|
||||||
|
.newCustomers(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||||
|
.min(30.0)
|
||||||
|
.max(50.0)
|
||||||
|
.build())
|
||||||
|
.revenueIncrease(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||||
|
.min(10.0)
|
||||||
|
.max(20.0)
|
||||||
|
.build())
|
||||||
|
.roi(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||||
|
.min(100.0)
|
||||||
|
.max(150.0)
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
.differentiator("SNS를 활용한 바이럴 마케팅")
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.generatedAt(java.time.LocalDateTime.now())
|
||||||
|
.expiresAt(java.time.LocalDateTime.now().plusDays(1))
|
||||||
|
.aiProvider(com.kt.ai.model.enums.AIProvider.CLAUDE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Redis에 저장
|
||||||
|
cacheService.saveRecommendation(eventId, testData);
|
||||||
|
|
||||||
|
// 저장 확인
|
||||||
|
Object saved = cacheService.getRecommendation(eventId);
|
||||||
|
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("eventId", eventId);
|
||||||
|
result.put("saved", saved != null);
|
||||||
|
result.put("data", saved);
|
||||||
|
|
||||||
|
log.info("테스트 데이터 생성 완료: eventId={}, saved={}", eventId, saved != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("테스트 데이터 생성 실패: eventId={}", eventId, e);
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@ -89,6 +90,29 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정적 리소스를 찾을 수 없는 예외 처리 (favicon.ico 등)
|
||||||
|
* WARN 레벨로 로깅하여 에러 로그 오염 방지
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFoundException ex) {
|
||||||
|
// favicon.ico 등 브라우저가 자동으로 요청하는 리소스는 DEBUG 레벨로 로깅
|
||||||
|
String resourcePath = ex.getResourcePath();
|
||||||
|
if (resourcePath != null && (resourcePath.contains("favicon") || resourcePath.endsWith(".ico"))) {
|
||||||
|
log.debug("Static resource not found (expected): {}", resourcePath);
|
||||||
|
} else {
|
||||||
|
log.warn("Static resource not found: {}", resourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorResponse error = ErrorResponse.builder()
|
||||||
|
.code("RESOURCE_NOT_FOUND")
|
||||||
|
.message("요청하신 리소스를 찾을 수 없습니다")
|
||||||
|
.timestamp(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 일반 예외 처리
|
* 일반 예외 처리
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -20,5 +20,10 @@ public enum ServiceStatus {
|
|||||||
/**
|
/**
|
||||||
* 성능 저하
|
* 성능 저하
|
||||||
*/
|
*/
|
||||||
DEGRADED
|
DEGRADED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 알 수 없음 (설정되지 않음)
|
||||||
|
*/
|
||||||
|
UNKNOWN
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,7 +184,6 @@ public class AIRecommendationService {
|
|||||||
ClaudeResponse response = claudeApiClient.sendMessage(
|
ClaudeResponse response = claudeApiClient.sendMessage(
|
||||||
apiKey,
|
apiKey,
|
||||||
anthropicVersion,
|
anthropicVersion,
|
||||||
"application/json",
|
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -93,7 +93,6 @@ public class TrendAnalysisService {
|
|||||||
ClaudeResponse response = claudeApiClient.sendMessage(
|
ClaudeResponse response = claudeApiClient.sendMessage(
|
||||||
apiKey,
|
apiKey,
|
||||||
anthropicVersion,
|
anthropicVersion,
|
||||||
"application/json",
|
|
||||||
request
|
request
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,10 @@ spring:
|
|||||||
# Redis Configuration
|
# Redis Configuration
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:20.214.210.71}
|
host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
|
||||||
port: ${REDIS_PORT:6379}
|
port: ${REDIS_PORT:6379}
|
||||||
password: ${REDIS_PASSWORD:}
|
password: ${REDIS_PASSWORD:}
|
||||||
database: ${REDIS_DATABASE:3} # AI Service uses database 3
|
database: ${REDIS_DATABASE:0} # AI Service uses database 3
|
||||||
timeout: ${REDIS_TIMEOUT:3000}
|
timeout: ${REDIS_TIMEOUT:3000}
|
||||||
lettuce:
|
lettuce:
|
||||||
pool:
|
pool:
|
||||||
@ -33,26 +33,6 @@ spring:
|
|||||||
listener:
|
listener:
|
||||||
ack-mode: manual
|
ack-mode: manual
|
||||||
|
|
||||||
# JPA Configuration (Not used but included for consistency)
|
|
||||||
jpa:
|
|
||||||
open-in-view: false
|
|
||||||
show-sql: false
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
format_sql: true
|
|
||||||
use_sql_comments: false
|
|
||||||
|
|
||||||
# Database Configuration (Not used but included for consistency)
|
|
||||||
datasource:
|
|
||||||
url: jdbc:postgresql://${DB_HOST:4.230.112.141}:${DB_PORT:5432}/${DB_NAME:aidb}
|
|
||||||
username: ${DB_USERNAME:eventuser}
|
|
||||||
password: ${DB_PASSWORD:}
|
|
||||||
driver-class-name: org.postgresql.Driver
|
|
||||||
hikari:
|
|
||||||
maximum-pool-size: 10
|
|
||||||
minimum-idle: 2
|
|
||||||
connection-timeout: 30000
|
|
||||||
|
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
server:
|
server:
|
||||||
port: ${SERVER_PORT:8083}
|
port: ${SERVER_PORT:8083}
|
||||||
@ -119,6 +99,13 @@ logging:
|
|||||||
pattern:
|
pattern:
|
||||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
|
file:
|
||||||
|
name: ${LOG_FILE:logs/ai-service.log}
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-history: 7
|
||||||
|
total-size-cap: 100MB
|
||||||
|
|
||||||
# Kafka Topics Configuration
|
# Kafka Topics Configuration
|
||||||
kafka:
|
kafka:
|
||||||
@ -131,8 +118,10 @@ ai:
|
|||||||
claude:
|
claude:
|
||||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||||
api-key: ${CLAUDE_API_KEY:}
|
api-key: ${CLAUDE_API_KEY:}
|
||||||
|
anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
|
||||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
||||||
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
||||||
|
temperature: ${CLAUDE_TEMPERATURE:0.7}
|
||||||
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
|
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
|
||||||
gpt4:
|
gpt4:
|
||||||
api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions}
|
api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions}
|
||||||
|
|||||||
@ -0,0 +1,127 @@
|
|||||||
|
package com.kt.ai.test.integration.kafka;
|
||||||
|
|
||||||
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
|
import com.kt.ai.service.JobStatusService;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.awaitility.Awaitility.await;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AIJobConsumer Kafka 통합 테스트
|
||||||
|
*
|
||||||
|
* 실제 Kafka 브로커가 실행 중이어야 합니다.
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@DisplayName("AIJobConsumer Kafka 통합 테스트")
|
||||||
|
class AIJobConsumerIntegrationTest {
|
||||||
|
|
||||||
|
@Value("${spring.kafka.bootstrap-servers}")
|
||||||
|
private String bootstrapServers;
|
||||||
|
|
||||||
|
@Value("${kafka.topics.ai-job}")
|
||||||
|
private String aiJobTopic;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobStatusService jobStatusService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CacheService cacheService;
|
||||||
|
|
||||||
|
private KafkaTestProducer testProducer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
testProducer = new KafkaTestProducer(bootstrapServers, aiJobTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (testProducer != null) {
|
||||||
|
testProducer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid AI job message, When send to Kafka, Then consumer processes and saves to Redis")
|
||||||
|
void givenValidAIJobMessage_whenSendToKafka_thenConsumerProcessesAndSavesToRedis() {
|
||||||
|
// Given
|
||||||
|
String jobId = "test-job-" + System.currentTimeMillis();
|
||||||
|
String eventId = "test-event-" + System.currentTimeMillis();
|
||||||
|
AIJobMessage message = KafkaTestProducer.createSampleMessage(jobId, eventId);
|
||||||
|
|
||||||
|
// When
|
||||||
|
testProducer.sendAIJobMessage(message);
|
||||||
|
|
||||||
|
// Then - Kafka Consumer가 메시지를 처리하고 Redis에 저장할 때까지 대기
|
||||||
|
await()
|
||||||
|
.atMost(30, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(1, TimeUnit.SECONDS)
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
// Job 상태가 Redis에 저장되었는지 확인
|
||||||
|
Object jobStatus = cacheService.getJobStatus(jobId);
|
||||||
|
assertThat(jobStatus).isNotNull();
|
||||||
|
System.out.println("Job 상태 확인: " + jobStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 최종 상태 확인 (COMPLETED 또는 FAILED)
|
||||||
|
await()
|
||||||
|
.atMost(60, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(2, TimeUnit.SECONDS)
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
Object jobStatus = cacheService.getJobStatus(jobId);
|
||||||
|
assertThat(jobStatus).isNotNull();
|
||||||
|
|
||||||
|
// AI 추천 결과도 저장되었는지 확인 (COMPLETED 상태인 경우)
|
||||||
|
Object recommendation = cacheService.getRecommendation(eventId);
|
||||||
|
System.out.println("AI 추천 결과: " + (recommendation != null ? "있음" : "없음"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given multiple messages, When send to Kafka, Then all messages are processed")
|
||||||
|
void givenMultipleMessages_whenSendToKafka_thenAllMessagesAreProcessed() {
|
||||||
|
// Given
|
||||||
|
int messageCount = 3;
|
||||||
|
String[] jobIds = new String[messageCount];
|
||||||
|
String[] eventIds = new String[messageCount];
|
||||||
|
|
||||||
|
// When - 여러 메시지 전송
|
||||||
|
for (int i = 0; i < messageCount; i++) {
|
||||||
|
jobIds[i] = "batch-job-" + i + "-" + System.currentTimeMillis();
|
||||||
|
eventIds[i] = "batch-event-" + i + "-" + System.currentTimeMillis();
|
||||||
|
AIJobMessage message = KafkaTestProducer.createSampleMessage(jobIds[i], eventIds[i]);
|
||||||
|
testProducer.sendAIJobMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then - 모든 메시지가 처리되었는지 확인
|
||||||
|
await()
|
||||||
|
.atMost(90, TimeUnit.SECONDS)
|
||||||
|
.pollInterval(2, TimeUnit.SECONDS)
|
||||||
|
.untilAsserted(() -> {
|
||||||
|
int processedCount = 0;
|
||||||
|
for (int i = 0; i < messageCount; i++) {
|
||||||
|
Object jobStatus = cacheService.getJobStatus(jobIds[i]);
|
||||||
|
if (jobStatus != null) {
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(processedCount).isEqualTo(messageCount);
|
||||||
|
System.out.println("처리된 메시지 수: " + processedCount + "/" + messageCount);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.kt.ai.test.integration.kafka;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.kafka.clients.producer.KafkaProducer;
|
||||||
|
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||||
|
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||||
|
import org.apache.kafka.clients.producer.RecordMetadata;
|
||||||
|
import org.apache.kafka.common.serialization.StringSerializer;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 테스트용 Producer 유틸리티
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class KafkaTestProducer {
|
||||||
|
|
||||||
|
private final KafkaProducer<String, String> producer;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final String topic;
|
||||||
|
|
||||||
|
public KafkaTestProducer(String bootstrapServers, String topic) {
|
||||||
|
this.topic = topic;
|
||||||
|
this.objectMapper = new ObjectMapper();
|
||||||
|
this.objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||||
|
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
||||||
|
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
||||||
|
props.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||||
|
props.put(ProducerConfig.RETRIES_CONFIG, 3);
|
||||||
|
|
||||||
|
this.producer = new KafkaProducer<>(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Job 메시지 전송
|
||||||
|
*/
|
||||||
|
public RecordMetadata sendAIJobMessage(AIJobMessage message) {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(message);
|
||||||
|
ProducerRecord<String, String> record = new ProducerRecord<>(topic, message.getJobId(), json);
|
||||||
|
|
||||||
|
Future<RecordMetadata> future = producer.send(record);
|
||||||
|
RecordMetadata metadata = future.get();
|
||||||
|
|
||||||
|
log.info("Kafka 메시지 전송 성공: topic={}, partition={}, offset={}, jobId={}",
|
||||||
|
metadata.topic(), metadata.partition(), metadata.offset(), message.getJobId());
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Kafka 메시지 전송 실패: jobId={}", message.getJobId(), e);
|
||||||
|
throw new RuntimeException("Kafka 메시지 전송 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테스트용 샘플 메시지 생성
|
||||||
|
*/
|
||||||
|
public static AIJobMessage createSampleMessage(String jobId, String eventId) {
|
||||||
|
return AIJobMessage.builder()
|
||||||
|
.jobId(jobId)
|
||||||
|
.eventId(eventId)
|
||||||
|
.objective("신규 고객 유치")
|
||||||
|
.industry("음식점")
|
||||||
|
.region("강남구")
|
||||||
|
.storeName("테스트 BBQ 레스토랑")
|
||||||
|
.targetAudience("20-30대 직장인")
|
||||||
|
.budget(500000)
|
||||||
|
.requestedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Producer 종료
|
||||||
|
*/
|
||||||
|
public void close() {
|
||||||
|
if (producer != null) {
|
||||||
|
producer.close();
|
||||||
|
log.info("Kafka Producer 종료");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
package com.kt.ai.test.manual;
|
||||||
|
|
||||||
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
|
import com.kt.ai.test.integration.kafka.KafkaTestProducer;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kafka 수동 테스트
|
||||||
|
*
|
||||||
|
* 이 클래스는 main 메서드를 실행하여 Kafka에 메시지를 직접 전송할 수 있습니다.
|
||||||
|
* IDE에서 직접 실행하거나 Gradle로 실행할 수 있습니다.
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
public class KafkaManualTest {
|
||||||
|
|
||||||
|
// Kafka 설정 (환경에 맞게 수정)
|
||||||
|
private static final String BOOTSTRAP_SERVERS = "20.249.182.13:9095,4.217.131.59:9095";
|
||||||
|
private static final String TOPIC = "ai-event-generation-job";
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("=== Kafka 수동 테스트 시작 ===");
|
||||||
|
System.out.println("Bootstrap Servers: " + BOOTSTRAP_SERVERS);
|
||||||
|
System.out.println("Topic: " + TOPIC);
|
||||||
|
|
||||||
|
KafkaTestProducer producer = new KafkaTestProducer(BOOTSTRAP_SERVERS, TOPIC);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테스트 메시지 1: 기본 메시지
|
||||||
|
AIJobMessage message1 = createTestMessage(
|
||||||
|
"manual-job-001",
|
||||||
|
"manual-event-001",
|
||||||
|
"신규 고객 유치",
|
||||||
|
"음식점",
|
||||||
|
"강남구",
|
||||||
|
"테스트 BBQ 레스토랑",
|
||||||
|
500000
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println("\n[메시지 1] 전송 중...");
|
||||||
|
producer.sendAIJobMessage(message1);
|
||||||
|
System.out.println("[메시지 1] 전송 완료");
|
||||||
|
|
||||||
|
// 테스트 메시지 2: 다른 업종
|
||||||
|
AIJobMessage message2 = createTestMessage(
|
||||||
|
"manual-job-002",
|
||||||
|
"manual-event-002",
|
||||||
|
"재방문 유도",
|
||||||
|
"카페",
|
||||||
|
"서초구",
|
||||||
|
"테스트 카페",
|
||||||
|
300000
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println("\n[메시지 2] 전송 중...");
|
||||||
|
producer.sendAIJobMessage(message2);
|
||||||
|
System.out.println("[메시지 2] 전송 완료");
|
||||||
|
|
||||||
|
// 테스트 메시지 3: 저예산
|
||||||
|
AIJobMessage message3 = createTestMessage(
|
||||||
|
"manual-job-003",
|
||||||
|
"manual-event-003",
|
||||||
|
"매출 증대",
|
||||||
|
"소매점",
|
||||||
|
"마포구",
|
||||||
|
"테스트 편의점",
|
||||||
|
100000
|
||||||
|
);
|
||||||
|
|
||||||
|
System.out.println("\n[메시지 3] 전송 중...");
|
||||||
|
producer.sendAIJobMessage(message3);
|
||||||
|
System.out.println("[메시지 3] 전송 완료");
|
||||||
|
|
||||||
|
System.out.println("\n=== 모든 메시지 전송 완료 ===");
|
||||||
|
System.out.println("\n다음 API로 결과를 확인하세요:");
|
||||||
|
System.out.println("- Job 상태: GET http://localhost:8083/api/v1/ai-service/internal/jobs/{jobId}/status");
|
||||||
|
System.out.println("- AI 추천: GET http://localhost:8083/api/v1/ai-service/internal/recommendations/{eventId}");
|
||||||
|
System.out.println("\n예시:");
|
||||||
|
System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status");
|
||||||
|
System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("에러 발생: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
producer.close();
|
||||||
|
System.out.println("\n=== Kafka Producer 종료 ===");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AIJobMessage createTestMessage(
|
||||||
|
String jobId,
|
||||||
|
String eventId,
|
||||||
|
String objective,
|
||||||
|
String industry,
|
||||||
|
String region,
|
||||||
|
String storeName,
|
||||||
|
int budget
|
||||||
|
) {
|
||||||
|
return AIJobMessage.builder()
|
||||||
|
.jobId(jobId)
|
||||||
|
.eventId(eventId)
|
||||||
|
.objective(objective)
|
||||||
|
.industry(industry)
|
||||||
|
.region(region)
|
||||||
|
.storeName(storeName)
|
||||||
|
.targetAudience("20-40대 고객")
|
||||||
|
.budget(budget)
|
||||||
|
.requestedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
package com.kt.ai.test.unit.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.kt.ai.controller.InternalJobController;
|
||||||
|
import com.kt.ai.exception.JobNotFoundException;
|
||||||
|
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
|
import com.kt.ai.service.JobStatusService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InternalJobController 단위 테스트
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@WebMvcTest(controllers = InternalJobController.class,
|
||||||
|
excludeAutoConfiguration = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})
|
||||||
|
@DisplayName("InternalJobController 단위 테스트")
|
||||||
|
class InternalJobControllerUnitTest {
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
private static final String VALID_JOB_ID = "job-123";
|
||||||
|
private static final String INVALID_JOB_ID = "job-999";
|
||||||
|
private static final String BASE_URL = "/api/v1/ai-service/internal/jobs";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JobStatusService jobStatusService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private CacheService cacheService;
|
||||||
|
|
||||||
|
private JobStatusResponse sampleJobStatusResponse;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
sampleJobStatusResponse = JobStatusResponse.builder()
|
||||||
|
.jobId(VALID_JOB_ID)
|
||||||
|
.status(JobStatus.PROCESSING)
|
||||||
|
.progress(50)
|
||||||
|
.message("AI 추천 생성 중 (50%)")
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GET /{jobId}/status 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given existing job, When get status, Then return 200 with job status")
|
||||||
|
void givenExistingJob_whenGetStatus_thenReturn200WithJobStatus() throws Exception {
|
||||||
|
// Given
|
||||||
|
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.jobId", is(VALID_JOB_ID)))
|
||||||
|
.andExpect(jsonPath("$.status", is("PROCESSING")))
|
||||||
|
.andExpect(jsonPath("$.progress", is(50)))
|
||||||
|
.andExpect(jsonPath("$.message", is("AI 추천 생성 중 (50%)")))
|
||||||
|
.andExpect(jsonPath("$.createdAt", notNullValue()));
|
||||||
|
|
||||||
|
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given non-existing job, When get status, Then return 404")
|
||||||
|
void givenNonExistingJob_whenGetStatus_thenReturn404() throws Exception {
|
||||||
|
// Given
|
||||||
|
when(jobStatusService.getJobStatus(INVALID_JOB_ID))
|
||||||
|
.thenThrow(new JobNotFoundException(INVALID_JOB_ID));
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
mockMvc.perform(get(BASE_URL + "/{jobId}/status", INVALID_JOB_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code", is("JOB_NOT_FOUND")))
|
||||||
|
.andExpect(jsonPath("$.message", containsString(INVALID_JOB_ID)));
|
||||||
|
|
||||||
|
verify(jobStatusService, times(1)).getJobStatus(INVALID_JOB_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given completed job, When get status, Then return COMPLETED status with 100% progress")
|
||||||
|
void givenCompletedJob_whenGetStatus_thenReturnCompletedStatus() throws Exception {
|
||||||
|
// Given
|
||||||
|
JobStatusResponse completedResponse = JobStatusResponse.builder()
|
||||||
|
.jobId(VALID_JOB_ID)
|
||||||
|
.status(JobStatus.COMPLETED)
|
||||||
|
.progress(100)
|
||||||
|
.message("AI 추천 완료")
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(completedResponse);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status", is("COMPLETED")))
|
||||||
|
.andExpect(jsonPath("$.progress", is(100)));
|
||||||
|
|
||||||
|
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given failed job, When get status, Then return FAILED status")
|
||||||
|
void givenFailedJob_whenGetStatus_thenReturnFailedStatus() throws Exception {
|
||||||
|
// Given
|
||||||
|
JobStatusResponse failedResponse = JobStatusResponse.builder()
|
||||||
|
.jobId(VALID_JOB_ID)
|
||||||
|
.status(JobStatus.FAILED)
|
||||||
|
.progress(0)
|
||||||
|
.message("AI API 호출 실패")
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(failedResponse);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status", is("FAILED")))
|
||||||
|
.andExpect(jsonPath("$.progress", is(0)))
|
||||||
|
.andExpect(jsonPath("$.message", containsString("실패")));
|
||||||
|
|
||||||
|
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 디버그 엔드포인트 테스트 (선택사항) ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid jobId, When create test job, Then return 200 with test data")
|
||||||
|
void givenValidJobId_whenCreateTestJob_thenReturn200WithTestData() throws Exception {
|
||||||
|
// Given
|
||||||
|
doNothing().when(jobStatusService).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString());
|
||||||
|
when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
mockMvc.perform(get(BASE_URL + "/debug/create-test-job/{jobId}", VALID_JOB_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.success", is(true)))
|
||||||
|
.andExpect(jsonPath("$.jobId", is(VALID_JOB_ID)))
|
||||||
|
.andExpect(jsonPath("$.saved", is(true)))
|
||||||
|
.andExpect(jsonPath("$.additionalSamples", notNullValue()));
|
||||||
|
|
||||||
|
// updateJobStatus가 4번 호출되어야 함 (main + 3 additional samples)
|
||||||
|
verify(jobStatusService, times(4)).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString());
|
||||||
|
verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,268 @@
|
|||||||
|
package com.kt.ai.test.unit.service;
|
||||||
|
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CacheService 단위 테스트
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("CacheService 단위 테스트")
|
||||||
|
class CacheServiceUnitTest {
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
private static final String VALID_KEY = "test:key";
|
||||||
|
private static final String VALID_VALUE = "test-value";
|
||||||
|
private static final long VALID_TTL = 3600L;
|
||||||
|
private static final String VALID_JOB_ID = "job-123";
|
||||||
|
private static final String VALID_EVENT_ID = "evt-001";
|
||||||
|
private static final String VALID_INDUSTRY = "음식점";
|
||||||
|
private static final String VALID_REGION = "강남구";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ValueOperations<String, Object> valueOperations;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private CacheService cacheService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// TTL 값 설정
|
||||||
|
ReflectionTestUtils.setField(cacheService, "recommendationTtl", 86400L);
|
||||||
|
ReflectionTestUtils.setField(cacheService, "jobStatusTtl", 86400L);
|
||||||
|
ReflectionTestUtils.setField(cacheService, "trendTtl", 3600L);
|
||||||
|
|
||||||
|
// RedisTemplate Mock 설정 (lenient를 사용하여 모든 테스트에서 사용하지 않아도 됨)
|
||||||
|
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== set() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid key and value, When set, Then success")
|
||||||
|
void givenValidKeyAndValue_whenSet_thenSuccess() {
|
||||||
|
// Given
|
||||||
|
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(valueOperations, times(1))
|
||||||
|
.set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given Redis exception, When set, Then log error and continue")
|
||||||
|
void givenRedisException_whenSet_thenLogErrorAndContinue() {
|
||||||
|
// Given
|
||||||
|
doThrow(new RuntimeException("Redis connection failed"))
|
||||||
|
.when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||||
|
|
||||||
|
// When & Then (예외가 전파되지 않아야 함)
|
||||||
|
cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL);
|
||||||
|
|
||||||
|
verify(valueOperations, times(1))
|
||||||
|
.set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== get() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given existing key, When get, Then return value")
|
||||||
|
void givenExistingKey_whenGet_thenReturnValue() {
|
||||||
|
// Given
|
||||||
|
when(valueOperations.get(VALID_KEY)).thenReturn(VALID_VALUE);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.get(VALID_KEY);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(VALID_VALUE);
|
||||||
|
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given non-existing key, When get, Then return null")
|
||||||
|
void givenNonExistingKey_whenGet_thenReturnNull() {
|
||||||
|
// Given
|
||||||
|
when(valueOperations.get(VALID_KEY)).thenReturn(null);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.get(VALID_KEY);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNull();
|
||||||
|
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given Redis exception, When get, Then return null")
|
||||||
|
void givenRedisException_whenGet_thenReturnNull() {
|
||||||
|
// Given
|
||||||
|
when(valueOperations.get(VALID_KEY))
|
||||||
|
.thenThrow(new RuntimeException("Redis connection failed"));
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.get(VALID_KEY);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNull();
|
||||||
|
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== delete() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid key, When delete, Then invoke RedisTemplate delete")
|
||||||
|
void givenValidKey_whenDelete_thenInvokeRedisTemplateDelete() {
|
||||||
|
// Given - No specific setup needed
|
||||||
|
|
||||||
|
// When
|
||||||
|
cacheService.delete(VALID_KEY);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(redisTemplate, times(1)).delete(VALID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== saveJobStatus() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid job status, When save, Then success")
|
||||||
|
void givenValidJobStatus_whenSave_thenSuccess() {
|
||||||
|
// Given
|
||||||
|
Object jobStatus = "PROCESSING";
|
||||||
|
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
cacheService.saveJobStatus(VALID_JOB_ID, jobStatus);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(valueOperations, times(1))
|
||||||
|
.set("ai:job:status:" + VALID_JOB_ID, jobStatus, 86400L, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== getJobStatus() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given existing job, When get status, Then return status")
|
||||||
|
void givenExistingJob_whenGetStatus_thenReturnStatus() {
|
||||||
|
// Given
|
||||||
|
Object expectedStatus = "COMPLETED";
|
||||||
|
when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(expectedStatus);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.getJobStatus(VALID_JOB_ID);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(expectedStatus);
|
||||||
|
verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given non-existing job, When get status, Then return null")
|
||||||
|
void givenNonExistingJob_whenGetStatus_thenReturnNull() {
|
||||||
|
// Given
|
||||||
|
when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(null);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.getJobStatus(VALID_JOB_ID);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNull();
|
||||||
|
verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== saveRecommendation() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid recommendation, When save, Then success")
|
||||||
|
void givenValidRecommendation_whenSave_thenSuccess() {
|
||||||
|
// Given
|
||||||
|
Object recommendation = "recommendation-data";
|
||||||
|
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
cacheService.saveRecommendation(VALID_EVENT_ID, recommendation);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(valueOperations, times(1))
|
||||||
|
.set("ai:recommendation:" + VALID_EVENT_ID, recommendation, 86400L, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== getRecommendation() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given existing recommendation, When get, Then return recommendation")
|
||||||
|
void givenExistingRecommendation_whenGet_thenReturnRecommendation() {
|
||||||
|
// Given
|
||||||
|
Object expectedRecommendation = "recommendation-data";
|
||||||
|
when(valueOperations.get("ai:recommendation:" + VALID_EVENT_ID))
|
||||||
|
.thenReturn(expectedRecommendation);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.getRecommendation(VALID_EVENT_ID);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(expectedRecommendation);
|
||||||
|
verify(valueOperations, times(1)).get("ai:recommendation:" + VALID_EVENT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== saveTrend() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given valid trend, When save, Then success")
|
||||||
|
void givenValidTrend_whenSave_thenSuccess() {
|
||||||
|
// Given
|
||||||
|
Object trend = "trend-data";
|
||||||
|
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
cacheService.saveTrend(VALID_INDUSTRY, VALID_REGION, trend);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
verify(valueOperations, times(1))
|
||||||
|
.set("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION, trend, 3600L, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== getTrend() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given existing trend, When get, Then return trend")
|
||||||
|
void givenExistingTrend_whenGet_thenReturnTrend() {
|
||||||
|
// Given
|
||||||
|
Object expectedTrend = "trend-data";
|
||||||
|
when(valueOperations.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION))
|
||||||
|
.thenReturn(expectedTrend);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Object result = cacheService.getTrend(VALID_INDUSTRY, VALID_REGION);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(expectedTrend);
|
||||||
|
verify(valueOperations, times(1))
|
||||||
|
.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
package com.kt.ai.test.unit.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.kt.ai.exception.JobNotFoundException;
|
||||||
|
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||||
|
import com.kt.ai.model.enums.JobStatus;
|
||||||
|
import com.kt.ai.service.CacheService;
|
||||||
|
import com.kt.ai.service.JobStatusService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JobStatusService 단위 테스트
|
||||||
|
*
|
||||||
|
* @author AI Service Team
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("JobStatusService 단위 테스트")
|
||||||
|
class JobStatusServiceUnitTest {
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
private static final String VALID_JOB_ID = "job-123";
|
||||||
|
private static final String INVALID_JOB_ID = "job-999";
|
||||||
|
private static final String VALID_MESSAGE = "AI 추천 생성 중";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CacheService cacheService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private JobStatusService jobStatusService;
|
||||||
|
|
||||||
|
private JobStatusResponse sampleJobStatusResponse;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
sampleJobStatusResponse = JobStatusResponse.builder()
|
||||||
|
.jobId(VALID_JOB_ID)
|
||||||
|
.status(JobStatus.PROCESSING)
|
||||||
|
.progress(50)
|
||||||
|
.message(VALID_MESSAGE)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== getJobStatus() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given existing job, When get status, Then return job status")
|
||||||
|
void givenExistingJob_whenGetStatus_thenReturnJobStatus() {
|
||||||
|
// Given
|
||||||
|
Map<String, Object> cachedData = createCachedJobStatusData();
|
||||||
|
when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(cachedData);
|
||||||
|
when(objectMapper.convertValue(cachedData, JobStatusResponse.class))
|
||||||
|
.thenReturn(sampleJobStatusResponse);
|
||||||
|
|
||||||
|
// When
|
||||||
|
JobStatusResponse result = jobStatusService.getJobStatus(VALID_JOB_ID);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||||
|
assertThat(result.getStatus()).isEqualTo(JobStatus.PROCESSING);
|
||||||
|
assertThat(result.getProgress()).isEqualTo(50);
|
||||||
|
assertThat(result.getMessage()).isEqualTo(VALID_MESSAGE);
|
||||||
|
|
||||||
|
verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||||
|
verify(objectMapper, times(1)).convertValue(cachedData, JobStatusResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given non-existing job, When get status, Then throw JobNotFoundException")
|
||||||
|
void givenNonExistingJob_whenGetStatus_thenThrowJobNotFoundException() {
|
||||||
|
// Given
|
||||||
|
when(cacheService.getJobStatus(INVALID_JOB_ID)).thenReturn(null);
|
||||||
|
|
||||||
|
// When & Then
|
||||||
|
assertThatThrownBy(() -> jobStatusService.getJobStatus(INVALID_JOB_ID))
|
||||||
|
.isInstanceOf(JobNotFoundException.class)
|
||||||
|
.hasMessageContaining(INVALID_JOB_ID);
|
||||||
|
|
||||||
|
verify(cacheService, times(1)).getJobStatus(INVALID_JOB_ID);
|
||||||
|
verify(objectMapper, never()).convertValue(any(), eq(JobStatusResponse.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== updateJobStatus() 메서드 테스트 ==========
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given PENDING status, When update, Then save with 0% progress")
|
||||||
|
void givenPendingStatus_whenUpdate_thenSaveWithZeroProgress() {
|
||||||
|
// Given
|
||||||
|
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PENDING, "대기 중");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||||
|
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||||
|
|
||||||
|
JobStatusResponse saved = captor.getValue();
|
||||||
|
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(JobStatus.PENDING);
|
||||||
|
assertThat(saved.getProgress()).isEqualTo(0);
|
||||||
|
assertThat(saved.getMessage()).isEqualTo("대기 중");
|
||||||
|
assertThat(saved.getCreatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given PROCESSING status, When update, Then save with 50% progress")
|
||||||
|
void givenProcessingStatus_whenUpdate_thenSaveWithFiftyProgress() {
|
||||||
|
// Given
|
||||||
|
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PROCESSING, VALID_MESSAGE);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||||
|
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||||
|
|
||||||
|
JobStatusResponse saved = captor.getValue();
|
||||||
|
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(JobStatus.PROCESSING);
|
||||||
|
assertThat(saved.getProgress()).isEqualTo(50);
|
||||||
|
assertThat(saved.getMessage()).isEqualTo(VALID_MESSAGE);
|
||||||
|
assertThat(saved.getCreatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given COMPLETED status, When update, Then save with 100% progress")
|
||||||
|
void givenCompletedStatus_whenUpdate_thenSaveWithHundredProgress() {
|
||||||
|
// Given
|
||||||
|
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.COMPLETED, "AI 추천 완료");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||||
|
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||||
|
|
||||||
|
JobStatusResponse saved = captor.getValue();
|
||||||
|
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(JobStatus.COMPLETED);
|
||||||
|
assertThat(saved.getProgress()).isEqualTo(100);
|
||||||
|
assertThat(saved.getMessage()).isEqualTo("AI 추천 완료");
|
||||||
|
assertThat(saved.getCreatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Given FAILED status, When update, Then save with 0% progress")
|
||||||
|
void givenFailedStatus_whenUpdate_thenSaveWithZeroProgress() {
|
||||||
|
// Given
|
||||||
|
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||||
|
|
||||||
|
// When
|
||||||
|
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.FAILED, "AI API 호출 실패");
|
||||||
|
|
||||||
|
// Then
|
||||||
|
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||||
|
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||||
|
|
||||||
|
JobStatusResponse saved = captor.getValue();
|
||||||
|
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(JobStatus.FAILED);
|
||||||
|
assertThat(saved.getProgress()).isEqualTo(0);
|
||||||
|
assertThat(saved.getMessage()).isEqualTo("AI API 호출 실패");
|
||||||
|
assertThat(saved.getCreatedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Helper Methods ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache에 저장된 Job 상태 데이터 생성 (LinkedHashMap 형태)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> createCachedJobStatusData() {
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("jobId", VALID_JOB_ID);
|
||||||
|
data.put("status", JobStatus.PROCESSING.name());
|
||||||
|
data.put("progress", 50);
|
||||||
|
data.put("message", VALID_MESSAGE);
|
||||||
|
data.put("createdAt", LocalDateTime.now().toString());
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
ai-service/src/test/resources/application-test.yml
Normal file
69
ai-service/src/test/resources/application-test.yml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: ai-service-test
|
||||||
|
|
||||||
|
# Redis Configuration (테스트용)
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:20.214.210.71}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:Hi5Jessica!}
|
||||||
|
database: ${REDIS_DATABASE:3}
|
||||||
|
timeout: 3000
|
||||||
|
|
||||||
|
# Kafka Configuration (테스트용)
|
||||||
|
kafka:
|
||||||
|
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
|
||||||
|
consumer:
|
||||||
|
group-id: ai-service-test-consumers
|
||||||
|
auto-offset-reset: earliest
|
||||||
|
enable-auto-commit: false
|
||||||
|
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||||
|
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||||
|
properties:
|
||||||
|
spring.json.trusted.packages: "*"
|
||||||
|
listener:
|
||||||
|
ack-mode: manual
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
server:
|
||||||
|
port: 0 # 랜덤 포트 사용
|
||||||
|
|
||||||
|
# JWT Configuration (테스트용)
|
||||||
|
jwt:
|
||||||
|
secret: test-jwt-secret-key-for-testing-only
|
||||||
|
access-token-validity: 1800
|
||||||
|
refresh-token-validity: 86400
|
||||||
|
|
||||||
|
# Kafka Topics
|
||||||
|
kafka:
|
||||||
|
topics:
|
||||||
|
ai-job: ai-event-generation-job
|
||||||
|
ai-job-dlq: ai-event-generation-job-dlq
|
||||||
|
|
||||||
|
# AI API Configuration (테스트용 - Mock 사용)
|
||||||
|
ai:
|
||||||
|
provider: CLAUDE
|
||||||
|
claude:
|
||||||
|
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||||
|
api-key: ${CLAUDE_API_KEY:test-key}
|
||||||
|
anthropic-version: 2023-06-01
|
||||||
|
model: claude-3-5-sonnet-20241022
|
||||||
|
max-tokens: 4096
|
||||||
|
temperature: 0.7
|
||||||
|
timeout: 300000
|
||||||
|
|
||||||
|
# Cache TTL
|
||||||
|
cache:
|
||||||
|
ttl:
|
||||||
|
recommendation: 86400
|
||||||
|
job-status: 86400
|
||||||
|
trend: 3600
|
||||||
|
fallback: 604800
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
com.kt.ai: DEBUG
|
||||||
|
org.springframework.kafka: DEBUG
|
||||||
175
claude/make-run-profile.md
Normal file
175
claude/make-run-profile.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# 서비스실행프로파일작성가이드
|
||||||
|
|
||||||
|
[요청사항]
|
||||||
|
- <수행원칙>을 준용하여 수행
|
||||||
|
- <수행순서>에 따라 수행
|
||||||
|
- [결과파일] 안내에 따라 파일 작성
|
||||||
|
|
||||||
|
[가이드]
|
||||||
|
<수행원칙>
|
||||||
|
- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
|
||||||
|
- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
|
||||||
|
- MQ 이용 시 'MQ설치결과서'의 연결 정보를 실행 프로파일의 환경변수로 등록
|
||||||
|
<수행순서>
|
||||||
|
- 준비:
|
||||||
|
- 데이터베이스설치결과서(develop/database/exec/db-exec-dev.md) 분석
|
||||||
|
- 캐시설치결과서(develop/database/exec/cache-exec-dev.md) 분석
|
||||||
|
- MQ설치결과서(develop/mq/mq-exec-dev.md) 분석 - 연결 정보 확인
|
||||||
|
- kubectl get svc -n tripgen-dev | grep LoadBalancer 실행하여 External IP 목록 확인
|
||||||
|
- 실행:
|
||||||
|
- 각 서비스별를 서브에이젼트로 병렬 수행
|
||||||
|
- 설정 Manifest 수정
|
||||||
|
- 하드코딩 되어 있는 값이 있으면 환경변수로 변환
|
||||||
|
- 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함
|
||||||
|
- 민감한 정보의 디퐅트값은 생략하거나 간략한 값으로 지정
|
||||||
|
- '<로그설정>'을 참조하여 Log 파일 설정
|
||||||
|
- '<실행프로파일 작성 가이드>'에 따라 서비스 실행프로파일 작성
|
||||||
|
- LoadBalancer External IP를 DB_HOST, REDIS_HOST로 설정
|
||||||
|
- MQ 연결 정보를 application.yml의 환경변수명에 맞춰 설정
|
||||||
|
- 서비스 실행 및 오류 수정
|
||||||
|
- 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드
|
||||||
|
- python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석
|
||||||
|
nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!"
|
||||||
|
- 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용**
|
||||||
|
- 오류 수정 후 필요 시 실행파일의 환경변수를 올바르게 변경
|
||||||
|
- 서비스 정상 시작 확인 후 서비스 중지
|
||||||
|
- 결과: {service-name}/.run
|
||||||
|
<서비스 중지 방법>
|
||||||
|
- Window
|
||||||
|
- netstat -ano | findstr :{PORT}
|
||||||
|
- powershell "Stop-Process -Id {Process number} -Force"
|
||||||
|
- Linux/Mac
|
||||||
|
- netstat -ano | grep {PORT}
|
||||||
|
- kill -9 {Process number}
|
||||||
|
<로그설정>
|
||||||
|
- **application.yml 로그 파일 설정**:
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: ${LOG_FILE:logs/trip-service.log}
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-history: 7
|
||||||
|
total-size-cap: 100MB
|
||||||
|
```
|
||||||
|
|
||||||
|
<실행프로파일 작성 가이드>
|
||||||
|
- {service-name}/.run/{service-name}.run.xml 파일로 작성
|
||||||
|
- Spring Boot가 아니고 **Gradle 실행 프로파일**이어야 함: '[실행프로파일 예시]' 참조
|
||||||
|
- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
|
||||||
|
- kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
|
||||||
|
- 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
|
||||||
|
- 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
|
||||||
|
- MQ 연결 설정:
|
||||||
|
- MQ설치결과서(develop/mq/mq-exec-dev.md)에서 연결 정보 확인
|
||||||
|
- MQ 유형에 따른 연결 정보 설정 예시:
|
||||||
|
- RabbitMQ: RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD
|
||||||
|
- Kafka: KAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL
|
||||||
|
- Azure Service Bus: SERVICE_BUS_CONNECTION_STRING
|
||||||
|
- AWS SQS: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||||
|
- Redis (Pub/Sub): REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
|
||||||
|
- ActiveMQ: ACTIVEMQ_BROKER_URL, ACTIVEMQ_USER, ACTIVEMQ_PASSWORD
|
||||||
|
- 기타 MQ: 해당 MQ의 연결에 필요한 호스트, 포트, 인증정보, 연결문자열 등을 환경변수로 설정
|
||||||
|
- application.yml에 정의된 환경변수명 확인 후 매핑
|
||||||
|
- 백킹서비스 연결 정보 매핑:
|
||||||
|
- 데이터베이스설치결과서에서 각 서비스별 DB 인증 정보 확인
|
||||||
|
- 캐시설치결과서에서 각 서비스별 Redis 인증 정보 확인
|
||||||
|
- LoadBalancer의 External IP를 호스트로 사용 (내부 DNS 아님)
|
||||||
|
- 개발모드의 DDL_AUTO값은 update로 함
|
||||||
|
- JWT Secret Key는 모든 서비스가 동일해야 함
|
||||||
|
- application.yaml의 환경변수와 일치하도록 환경변수 설정
|
||||||
|
- application.yaml의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정
|
||||||
|
- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정
|
||||||
|
- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제
|
||||||
|
|
||||||
|
[실행프로파일 예시]
|
||||||
|
```
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="user-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="ACCOUNT_LOCK_DURATION_MINUTES" value="30" />
|
||||||
|
<entry key="CACHE_TTL" value="1800" />
|
||||||
|
<entry key="DB_HOST" value="20.249.197.193" /> <!-- LoadBalancer External IP 사용 -->
|
||||||
|
<entry key="DB_NAME" value="tripgen_user_db" />
|
||||||
|
<entry key="DB_PASSWORD" value="tripgen_user_123" />
|
||||||
|
<entry key="DB_PORT" value="5432" />
|
||||||
|
<entry key="DB_USERNAME" value="tripgen_user" />
|
||||||
|
<entry key="FILE_BASE_URL" value="http://localhost:8081" />
|
||||||
|
<entry key="FILE_MAX_SIZE" value="5242880" />
|
||||||
|
<entry key="FILE_UPLOAD_PATH" value="/app/uploads" />
|
||||||
|
<entry key="JPA_DDL_AUTO" value="update" />
|
||||||
|
<entry key="JPA_SHOW_SQL" value="true" />
|
||||||
|
<entry key="JWT_ACCESS_TOKEN_EXPIRATION" value="86400" />
|
||||||
|
<entry key="JWT_REFRESH_TOKEN_EXPIRATION" value="604800" />
|
||||||
|
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only" />
|
||||||
|
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||||
|
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
||||||
|
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
||||||
|
<entry key="MAX_LOGIN_ATTEMPTS" value="5" />
|
||||||
|
<entry key="PASSWORD_MIN_LENGTH" value="8" />
|
||||||
|
<entry key="REDIS_DATABASE" value="0" />
|
||||||
|
<entry key="REDIS_HOST" value="20.214.121.28" /> <!-- Redis LoadBalancer External IP 사용 -->
|
||||||
|
<entry key="REDIS_PASSWORD" value="" />
|
||||||
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
|
<entry key="SERVER_PORT" value="8081" />
|
||||||
|
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||||
|
<!-- MQ 사용하는 서비스의 경우 MQ 유형에 맞게 추가 -->
|
||||||
|
<!-- Azure Service Bus 예시 -->
|
||||||
|
<entry key="SERVICE_BUS_CONNECTION_STRING" value="Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..." />
|
||||||
|
<!-- RabbitMQ 예시 -->
|
||||||
|
<entry key="RABBITMQ_HOST" value="20.xxx.xxx.xxx" />
|
||||||
|
<entry key="RABBITMQ_PORT" value="5672" />
|
||||||
|
<!-- Kafka 예시 -->
|
||||||
|
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.xxx.xxx.xxx:9092" />
|
||||||
|
<!-- 기타 MQ의 경우 해당 MQ에 필요한 연결 정보를 환경변수로 추가 -->
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="user-service:bootRun" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
|
||||||
|
<extension name="net.ashald.envfile">
|
||||||
|
<option name="IS_ENABLED" value="false" />
|
||||||
|
<option name="IS_SUBST" value="false" />
|
||||||
|
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
|
||||||
|
<option name="IS_IGNORE_MISSING_FILES" value="false" />
|
||||||
|
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
|
||||||
|
<ENTRIES>
|
||||||
|
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||||
|
</ENTRIES>
|
||||||
|
</extension>
|
||||||
|
</EXTENSION>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
|
```
|
||||||
|
|
||||||
|
[참고자료]
|
||||||
|
- 데이터베이스설치결과서: develop/database/exec/db-exec-dev.md
|
||||||
|
- 각 서비스별 DB 연결 정보 (사용자명, 비밀번호, DB명)
|
||||||
|
- LoadBalancer Service External IP 목록
|
||||||
|
- 캐시설치결과서: develop/database/exec/cache-exec-dev.md
|
||||||
|
- 각 서비스별 Redis 연결 정보
|
||||||
|
- LoadBalancer Service External IP 목록
|
||||||
|
- MQ설치결과서: develop/mq/mq-exec-dev.md
|
||||||
|
- MQ 유형 및 연결 정보
|
||||||
|
- 연결에 필요한 호스트, 포트, 인증 정보
|
||||||
|
- LoadBalancer Service External IP (해당하는 경우)
|
||||||
389
develop/test/kafka-redis-integration-test-report.md
Normal file
389
develop/test/kafka-redis-integration-test-report.md
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
# AI Service Kafka-Redis 통합 테스트 결과 보고서
|
||||||
|
|
||||||
|
**테스트 일시**: 2025-10-27 16:00 ~ 16:10
|
||||||
|
**테스터**: AI 개발 팀
|
||||||
|
**테스트 환경**: 개발 환경 (ai-service 실행 중)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 테스트 개요
|
||||||
|
|
||||||
|
### 1.1 테스트 목적
|
||||||
|
AI Service의 Kafka Consumer와 Redis 연동이 정상적으로 동작하는지 검증
|
||||||
|
|
||||||
|
### 1.2 테스트 범위
|
||||||
|
- Kafka 메시지 수신 (AIJobConsumer)
|
||||||
|
- Redis 캐시 저장/조회 (Job Status, AI Recommendation)
|
||||||
|
- 트렌드 분석 캐싱
|
||||||
|
- API 엔드포인트 동작 확인
|
||||||
|
- Circuit Breaker 폴백 동작
|
||||||
|
|
||||||
|
### 1.3 테스트 시나리오
|
||||||
|
```
|
||||||
|
1. Kafka Producer → 메시지 전송 (3건)
|
||||||
|
2. AI Service Consumer → 메시지 수신 및 처리
|
||||||
|
3. Redis → Job Status 저장
|
||||||
|
4. Redis → AI Recommendation 결과 저장
|
||||||
|
5. API → Redis에서 데이터 조회
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 테스트 환경 설정
|
||||||
|
|
||||||
|
### 2.1 Kafka 설정
|
||||||
|
```yaml
|
||||||
|
bootstrap-servers: 20.249.182.13:9095,4.217.131.59:9095
|
||||||
|
topic: ai-event-generation-job
|
||||||
|
consumer-group: ai-service-consumers
|
||||||
|
ack-mode: manual
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Redis 설정
|
||||||
|
```yaml
|
||||||
|
host: 20.214.210.71
|
||||||
|
port: 6379
|
||||||
|
database: 0
|
||||||
|
password: Hi5Jessica!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 서비스 상태
|
||||||
|
- **AI Service**: 포트 8083에서 정상 실행 중
|
||||||
|
- **Kafka Cluster**: 연결 정상
|
||||||
|
- **Redis Server**: 연결 정상 (Health Check UP)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 테스트 수행 결과
|
||||||
|
|
||||||
|
### 3.1 Kafka Producer 메시지 전송
|
||||||
|
|
||||||
|
#### 테스트 메시지 3건 전송
|
||||||
|
|
||||||
|
| Job ID | Event ID | 업종 | 지역 | 목표 | 예산 | 전송 상태 |
|
||||||
|
|--------|----------|------|------|------|------|----------|
|
||||||
|
| manual-job-001 | manual-event-001 | 음식점 | 강남구 | 신규 고객 유치 | 500,000원 | ✅ 성공 |
|
||||||
|
| manual-job-002 | manual-event-002 | 카페 | 서초구 | 재방문 유도 | 300,000원 | ✅ 성공 |
|
||||||
|
| manual-job-003 | manual-event-003 | 소매점 | 마포구 | 매출 증대 | 100,000원 | ✅ 성공 |
|
||||||
|
|
||||||
|
**결과**: 모든 메시지가 Kafka 토픽에 정상적으로 전송됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Kafka Consumer 처리 검증
|
||||||
|
|
||||||
|
#### Consumer 메시지 수신 및 처리
|
||||||
|
- **Consumer Group**: ai-service-consumers
|
||||||
|
- **Auto Commit**: 비활성화 (manual ack)
|
||||||
|
- **처리 시간**: 약 45초 (3건)
|
||||||
|
|
||||||
|
#### 처리 플로우 검증
|
||||||
|
```
|
||||||
|
1. Kafka 메시지 수신 ✅
|
||||||
|
2. Job Status 업데이트 (PROCESSING) ✅
|
||||||
|
3. 트렌드 분석 수행 ✅
|
||||||
|
4. AI 추천안 생성 (Fallback 사용) ✅
|
||||||
|
5. Redis 캐시 저장 ✅
|
||||||
|
6. Job Status 업데이트 (COMPLETED) ✅
|
||||||
|
7. Manual Acknowledgment ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
**결과**: 모든 메시지가 정상적으로 처리되어 Redis에 저장됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Redis Job Status 저장/조회 검증
|
||||||
|
|
||||||
|
#### Job 001 상태
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "manual-job-001",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"progress": 100,
|
||||||
|
"message": "AI 추천 완료",
|
||||||
|
"createdAt": "2025-10-27T16:02:10.3433854"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Job 002 상태
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "manual-job-002",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"progress": 100,
|
||||||
|
"message": "AI 추천 완료",
|
||||||
|
"createdAt": "2025-10-27T16:02:10.5093092"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Job 003 상태
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobId": "manual-job-003",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"progress": 100,
|
||||||
|
"message": "AI 추천 완료",
|
||||||
|
"createdAt": "2025-10-27T16:02:10.5940905"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 결과**:
|
||||||
|
- ✅ Job Status가 Redis에 정상 저장됨
|
||||||
|
- ✅ API를 통한 조회 정상 동작
|
||||||
|
- ✅ TTL 설정 확인 (86400초 = 24시간)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Redis AI Recommendation 저장/조회 검증
|
||||||
|
|
||||||
|
#### Event 001 추천 결과 (요약)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventId": "manual-event-001",
|
||||||
|
"aiProvider": "CLAUDE",
|
||||||
|
"generatedAt": "2025-10-27T16:02:10.3091282",
|
||||||
|
"expiresAt": "2025-10-28T16:02:10.3091282",
|
||||||
|
"trendAnalysis": {
|
||||||
|
"industryTrends": [
|
||||||
|
{
|
||||||
|
"keyword": "고객 만족도 향상",
|
||||||
|
"relevance": 0.8,
|
||||||
|
"description": "음식점 업종에서 고객 만족도가 중요한 트렌드입니다"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keyword": "디지털 마케팅",
|
||||||
|
"relevance": 0.75,
|
||||||
|
"description": "SNS 및 온라인 마케팅이 효과적입니다"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"regionalTrends": [
|
||||||
|
{
|
||||||
|
"keyword": "지역 커뮤니티",
|
||||||
|
"relevance": 0.7,
|
||||||
|
"description": "강남구 지역 커뮤니티 참여가 효과적입니다"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"seasonalTrends": [
|
||||||
|
{
|
||||||
|
"keyword": "시즌 이벤트",
|
||||||
|
"relevance": 0.85,
|
||||||
|
"description": "계절 특성을 반영한 이벤트가 효과적입니다"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"optionNumber": 1,
|
||||||
|
"concept": "저비용 SNS 이벤트",
|
||||||
|
"title": "신규 고객 유치 - 저비용 SNS 이벤트",
|
||||||
|
"estimatedCost": {
|
||||||
|
"min": 100000,
|
||||||
|
"max": 200000
|
||||||
|
},
|
||||||
|
"expectedMetrics": {
|
||||||
|
"newCustomers": { "min": 30.0, "max": 50.0 },
|
||||||
|
"revenueIncrease": { "min": 10.0, "max": 20.0 },
|
||||||
|
"roi": { "min": 100.0, "max": 150.0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"optionNumber": 2,
|
||||||
|
"concept": "중비용 방문 유도 이벤트",
|
||||||
|
"estimatedCost": {
|
||||||
|
"min": 300000,
|
||||||
|
"max": 500000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"optionNumber": 3,
|
||||||
|
"concept": "고비용 프리미엄 이벤트",
|
||||||
|
"estimatedCost": {
|
||||||
|
"min": 500000,
|
||||||
|
"max": 1000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 결과**:
|
||||||
|
- ✅ AI 추천 결과가 Redis에 정상 저장됨
|
||||||
|
- ✅ 트렌드 분석 데이터 포함
|
||||||
|
- ✅ 3가지 추천안 (저/중/고 비용) 생성
|
||||||
|
- ✅ TTL 설정 확인 (24시간)
|
||||||
|
- ✅ Circuit Breaker Fallback 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 트렌드 분석 캐싱 검증
|
||||||
|
|
||||||
|
#### 캐싱 동작 확인
|
||||||
|
- **캐시 키 형식**: `trend:{industry}:{region}`
|
||||||
|
- **TTL**: 3600초 (1시간)
|
||||||
|
- **캐시 히트**: 동일 업종/지역 재요청 시 캐시 사용
|
||||||
|
|
||||||
|
**검증 결과**:
|
||||||
|
- ✅ 트렌드 분석 결과가 Redis에 캐싱됨
|
||||||
|
- ✅ 동일 조건 재요청 시 캐시 히트 확인 (로그)
|
||||||
|
- ✅ TTL 설정 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 API 엔드포인트 테스트
|
||||||
|
|
||||||
|
#### 1) Job 상태 조회 API
|
||||||
|
**Endpoint**: `GET /api/v1/ai-service/internal/jobs/{jobId}/status`
|
||||||
|
|
||||||
|
| Job ID | HTTP Status | Response Time | 결과 |
|
||||||
|
|--------|-------------|---------------|------|
|
||||||
|
| manual-job-001 | 200 OK | < 50ms | ✅ 성공 |
|
||||||
|
| manual-job-002 | 200 OK | < 50ms | ✅ 성공 |
|
||||||
|
| manual-job-003 | 200 OK | < 50ms | ✅ 성공 |
|
||||||
|
|
||||||
|
#### 2) AI 추천 조회 API
|
||||||
|
**Endpoint**: `GET /api/v1/ai-service/internal/recommendations/{eventId}`
|
||||||
|
|
||||||
|
| Event ID | HTTP Status | Response Time | 결과 |
|
||||||
|
|----------|-------------|---------------|------|
|
||||||
|
| manual-event-001 | 200 OK | < 80ms | ✅ 성공 |
|
||||||
|
| manual-event-002 | 200 OK | < 80ms | ✅ 성공 |
|
||||||
|
| manual-event-003 | 200 OK | < 80ms | ✅ 성공 |
|
||||||
|
|
||||||
|
#### 3) Health Check API
|
||||||
|
**Endpoint**: `GET /actuator/health`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "UP",
|
||||||
|
"components": {
|
||||||
|
"redis": {
|
||||||
|
"status": "UP",
|
||||||
|
"details": {
|
||||||
|
"version": "7.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diskSpace": {
|
||||||
|
"status": "UP"
|
||||||
|
},
|
||||||
|
"ping": {
|
||||||
|
"status": "UP"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 결과**:
|
||||||
|
- ✅ Redis Health Check: UP
|
||||||
|
- ✅ 전체 서비스 상태: UP
|
||||||
|
- ✅ Redis 버전: 7.2.3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Circuit Breaker 동작 검증
|
||||||
|
|
||||||
|
### 4.1 Fallback 동작 확인
|
||||||
|
- **상황**: Claude API 키가 유효하지 않거나 타임아웃
|
||||||
|
- **동작**: AIServiceFallback이 기본 추천안 제공
|
||||||
|
- **결과**: ✅ 정상적으로 Fallback 응답 반환
|
||||||
|
|
||||||
|
### 4.2 Fallback 응답 특징
|
||||||
|
- 업종별 기본 추천안 제공
|
||||||
|
- 트렌드 분석은 기본 데이터 사용
|
||||||
|
- 3가지 비용 옵션 포함
|
||||||
|
- "AI 분석이 제한적으로 제공되는 기본 추천안입니다" 메시지 포함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 성능 측정
|
||||||
|
|
||||||
|
### 5.1 처리 시간
|
||||||
|
- **Kafka 메시지 전송**: 평균 50ms/건
|
||||||
|
- **Consumer 처리 시간**: 평균 15초/건 (트렌드 분석 + 추천 생성)
|
||||||
|
- **Redis 저장**: < 10ms
|
||||||
|
- **Redis 조회**: < 50ms
|
||||||
|
|
||||||
|
### 5.2 리소스 사용
|
||||||
|
- **메모리**: 정상 범위
|
||||||
|
- **CPU**: 정상 범위
|
||||||
|
- **Kafka Consumer Lag**: 0 (모든 메시지 즉시 처리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 이슈 및 개선사항
|
||||||
|
|
||||||
|
### 6.1 확인된 이슈
|
||||||
|
1. **없음** - 모든 테스트가 정상적으로 통과함
|
||||||
|
|
||||||
|
### 6.2 개선 제안
|
||||||
|
1. **Claude API 실제 연동 테스트**
|
||||||
|
- 현재는 Fallback 응답만 테스트됨
|
||||||
|
- 실제 Claude API 키로 End-to-End 테스트 필요
|
||||||
|
|
||||||
|
2. **성능 테스트**
|
||||||
|
- 대량 메시지 처리 테스트 (100건 이상)
|
||||||
|
- Concurrent Consumer 처리 검증
|
||||||
|
|
||||||
|
3. **에러 시나리오 테스트**
|
||||||
|
- Redis 연결 끊김 시나리오
|
||||||
|
- Kafka 브로커 다운 시나리오
|
||||||
|
- 네트워크 타임아웃 시나리오
|
||||||
|
|
||||||
|
4. **모니터링 강화**
|
||||||
|
- Kafka Consumer Lag 모니터링
|
||||||
|
- Redis 캐시 히트율 모니터링
|
||||||
|
- Circuit Breaker 상태 모니터링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 결론
|
||||||
|
|
||||||
|
### 7.1 테스트 결과 요약
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|------------|------|------|
|
||||||
|
| Kafka 메시지 전송 | ✅ 통과 | 3/3 성공 |
|
||||||
|
| Kafka Consumer 처리 | ✅ 통과 | Manual ACK 정상 |
|
||||||
|
| Redis Job Status 저장/조회 | ✅ 통과 | TTL 24시간 |
|
||||||
|
| Redis AI Recommendation 저장/조회 | ✅ 통과 | TTL 24시간 |
|
||||||
|
| 트렌드 분석 캐싱 | ✅ 통과 | TTL 1시간 |
|
||||||
|
| API 엔드포인트 | ✅ 통과 | 모든 API 정상 |
|
||||||
|
| Circuit Breaker Fallback | ✅ 통과 | 기본 추천안 제공 |
|
||||||
|
| Health Check | ✅ 통과 | Redis UP |
|
||||||
|
|
||||||
|
### 7.2 종합 평가
|
||||||
|
**✅ 모든 통합 테스트 통과**
|
||||||
|
|
||||||
|
AI Service의 Kafka-Redis 통합이 정상적으로 동작합니다:
|
||||||
|
- Kafka Consumer가 메시지를 정상적으로 수신하고 처리
|
||||||
|
- Redis에 Job Status와 AI Recommendation이 정확하게 저장
|
||||||
|
- API를 통한 데이터 조회가 정상 동작
|
||||||
|
- Circuit Breaker Fallback이 안정적으로 작동
|
||||||
|
- Health Check에서 모든 컴포넌트가 UP 상태
|
||||||
|
|
||||||
|
### 7.3 다음 단계
|
||||||
|
1. ✅ **통합 테스트 완료** (Kafka + Redis)
|
||||||
|
2. 🔜 **실제 Claude API 연동 테스트**
|
||||||
|
3. 🔜 **부하 테스트 및 성능 튜닝**
|
||||||
|
4. 🔜 **에러 시나리오 테스트**
|
||||||
|
5. 🔜 **모니터링 대시보드 구축**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 테스트 아티팩트
|
||||||
|
|
||||||
|
### 8.1 테스트 스크립트
|
||||||
|
- `tools/kafka-manual-test.bat`: Kafka 수동 테스트 스크립트
|
||||||
|
- `tools/kafka-comprehensive-test.bat`: 종합 통합 테스트 스크립트
|
||||||
|
|
||||||
|
### 8.2 테스트 데이터
|
||||||
|
- `logs/event-002-result.json`: Event 002 추천 결과
|
||||||
|
- `logs/event-003-result.json`: Event 003 추천 결과
|
||||||
|
|
||||||
|
### 8.3 테스트 로그
|
||||||
|
- `logs/ai-service.log`: AI Service 실행 로그
|
||||||
|
- Kafka Consumer 로그: 콘솔 출력 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**테스트 완료 일시**: 2025-10-27 16:10
|
||||||
|
**작성자**: AI 개발 팀
|
||||||
|
**검토자**: Backend Developer (최수연 "아키텍처")
|
||||||
101
tools/kafka-comprehensive-test.bat
Normal file
101
tools/kafka-comprehensive-test.bat
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
@echo off
|
||||||
|
REM ============================================
|
||||||
|
REM Kafka/Redis 통합 테스트 스크립트
|
||||||
|
REM ============================================
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo Kafka/Redis 통합 테스트 시작
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 현재 디렉토리 확인
|
||||||
|
cd /d "%~dp0\.."
|
||||||
|
echo 현재 디렉토리: %CD%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 로그 디렉토리 확인 및 생성
|
||||||
|
if not exist "logs" mkdir logs
|
||||||
|
echo 로그 디렉토리: %CD%\logs
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 테스트 타임스탬프
|
||||||
|
set TEST_TIMESTAMP=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||||
|
set TEST_TIMESTAMP=%TEST_TIMESTAMP: =0%
|
||||||
|
set TEST_LOG=logs\kafka-redis-test_%TEST_TIMESTAMP%.log
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 1단계: Kafka 수동 테스트 메시지 전송
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Kafka 메시지 전송 중...
|
||||||
|
gradlew ai-service:runKafkaManualTest > %TEST_LOG% 2>&1
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✓ Kafka 메시지 전송 완료
|
||||||
|
) else (
|
||||||
|
echo ✗ Kafka 메시지 전송 실패 ^(Error Code: %ERRORLEVEL%^)
|
||||||
|
echo 로그 파일을 확인하세요: %TEST_LOG%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 2단계: AI 서비스 Consumer 처리 대기
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo AI 서비스가 Kafka 메시지를 처리할 때까지 60초 대기...
|
||||||
|
timeout /t 60 /nobreak > nul
|
||||||
|
echo ✓ 대기 완료
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 3단계: Job 상태 확인 ^(Redis^)
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo Job 상태 조회 중...
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status" >> %TEST_LOG% 2>&1
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✓ Job 상태 조회 성공
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status"
|
||||||
|
) else (
|
||||||
|
echo ✗ Job 상태 조회 실패
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 4단계: AI 추천 결과 확인 ^(Redis^)
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo AI 추천 결과 조회 중...
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001" >> %TEST_LOG% 2>&1
|
||||||
|
if %ERRORLEVEL% EQU 0 (
|
||||||
|
echo ✓ AI 추천 결과 조회 성공
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001"
|
||||||
|
) else (
|
||||||
|
echo ✗ AI 추천 결과 조회 실패
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 5단계: 모든 테스트 메시지 상태 확인
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo [Job 001] 상태 확인:
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status" | findstr "status"
|
||||||
|
echo.
|
||||||
|
echo [Job 002] 상태 확인:
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-002/status" | findstr "status"
|
||||||
|
echo.
|
||||||
|
echo [Job 003] 상태 확인:
|
||||||
|
curl -s "http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-003/status" | findstr "status"
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo ============================================
|
||||||
|
echo 테스트 완료
|
||||||
|
echo ============================================
|
||||||
|
echo.
|
||||||
|
echo 상세 로그 파일: %TEST_LOG%
|
||||||
|
echo.
|
||||||
|
echo 수동 확인 명령어:
|
||||||
|
echo - Job 상태: curl http://localhost:8083/api/v1/ai-service/internal/jobs/{jobId}/status
|
||||||
|
echo - AI 추천: curl http://localhost:8083/api/v1/ai-service/internal/recommendations/{eventId}
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
37
tools/kafka-manual-test.bat
Normal file
37
tools/kafka-manual-test.bat
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@echo off
|
||||||
|
REM Kafka 수동 테스트 실행 스크립트 (Windows)
|
||||||
|
|
||||||
|
cd /d %~dp0\..
|
||||||
|
|
||||||
|
echo ================================================
|
||||||
|
echo Kafka Manual Test - AI Service
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
echo 이 스크립트는 Kafka에 테스트 메시지를 전송합니다.
|
||||||
|
echo ai-service가 실행 중이어야 메시지를 처리할 수 있습니다.
|
||||||
|
echo.
|
||||||
|
echo Kafka Brokers: 20.249.182.13:9095, 4.217.131.59:9095
|
||||||
|
echo Topic: ai-event-generation-job
|
||||||
|
echo.
|
||||||
|
echo ================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 테스트 클래스 실행
|
||||||
|
.\gradlew ai-service:test --tests "com.kt.ai.test.manual.KafkaManualTest" --info
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ================================================
|
||||||
|
echo 테스트 완료!
|
||||||
|
echo.
|
||||||
|
echo 결과 확인:
|
||||||
|
echo 1. Job 상태 조회:
|
||||||
|
echo curl http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status
|
||||||
|
echo.
|
||||||
|
echo 2. AI 추천 결과 조회:
|
||||||
|
echo curl http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001
|
||||||
|
echo.
|
||||||
|
echo 3. Redis 키 확인:
|
||||||
|
echo curl http://localhost:8083/api/v1/ai-service/internal/recommendations/debug/redis-keys
|
||||||
|
echo ================================================
|
||||||
|
|
||||||
|
pause
|
||||||
303
tools/run-intellij-service-profile.py
Normal file
303
tools/run-intellij-service-profile.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Tripgen Service Runner Script
|
||||||
|
Reads execution profiles from {service-name}/.run/{service-name}.run.xml and runs services accordingly.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run-config.py <service-name>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python run-config.py user-service
|
||||||
|
python run-config.py location-service
|
||||||
|
python run-config.py trip-service
|
||||||
|
python run-config.py ai-service
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_root():
|
||||||
|
"""Find project root directory"""
|
||||||
|
current_dir = Path(__file__).parent.absolute()
|
||||||
|
while current_dir.parent != current_dir:
|
||||||
|
if (current_dir / 'gradlew').exists() or (current_dir / 'gradlew.bat').exists():
|
||||||
|
return current_dir
|
||||||
|
current_dir = current_dir.parent
|
||||||
|
|
||||||
|
# If gradlew not found, assume parent directory of develop as project root
|
||||||
|
return Path(__file__).parent.parent.absolute()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_run_configurations(project_root, service_name=None):
|
||||||
|
"""Parse run configuration files from .run directories"""
|
||||||
|
configurations = {}
|
||||||
|
|
||||||
|
if service_name:
|
||||||
|
# Parse specific service configuration
|
||||||
|
run_config_path = project_root / service_name / '.run' / f'{service_name}.run.xml'
|
||||||
|
if run_config_path.exists():
|
||||||
|
config = parse_single_run_config(run_config_path, service_name)
|
||||||
|
if config:
|
||||||
|
configurations[service_name] = config
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] Cannot find run configuration: {run_config_path}")
|
||||||
|
else:
|
||||||
|
# Find all service directories
|
||||||
|
service_dirs = ['user-service', 'location-service', 'trip-service', 'ai-service']
|
||||||
|
for service in service_dirs:
|
||||||
|
run_config_path = project_root / service / '.run' / f'{service}.run.xml'
|
||||||
|
if run_config_path.exists():
|
||||||
|
config = parse_single_run_config(run_config_path, service)
|
||||||
|
if config:
|
||||||
|
configurations[service] = config
|
||||||
|
|
||||||
|
return configurations
|
||||||
|
|
||||||
|
|
||||||
|
def parse_single_run_config(config_path, service_name):
|
||||||
|
"""Parse a single run configuration file"""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(config_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Find configuration element
|
||||||
|
config = root.find('.//configuration[@type="GradleRunConfiguration"]')
|
||||||
|
if config is None:
|
||||||
|
print(f"[WARNING] No Gradle configuration found in {config_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract environment variables
|
||||||
|
env_vars = {}
|
||||||
|
env_option = config.find('.//option[@name="env"]')
|
||||||
|
if env_option is not None:
|
||||||
|
env_map = env_option.find('map')
|
||||||
|
if env_map is not None:
|
||||||
|
for entry in env_map.findall('entry'):
|
||||||
|
key = entry.get('key')
|
||||||
|
value = entry.get('value')
|
||||||
|
if key and value:
|
||||||
|
env_vars[key] = value
|
||||||
|
|
||||||
|
# Extract task names
|
||||||
|
task_names = []
|
||||||
|
task_names_option = config.find('.//option[@name="taskNames"]')
|
||||||
|
if task_names_option is not None:
|
||||||
|
task_list = task_names_option.find('list')
|
||||||
|
if task_list is not None:
|
||||||
|
for option in task_list.findall('option'):
|
||||||
|
value = option.get('value')
|
||||||
|
if value:
|
||||||
|
task_names.append(value)
|
||||||
|
|
||||||
|
if env_vars or task_names:
|
||||||
|
return {
|
||||||
|
'env_vars': env_vars,
|
||||||
|
'task_names': task_names,
|
||||||
|
'config_path': str(config_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
print(f"[ERROR] XML parsing error in {config_path}: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Error reading {config_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gradle_command(project_root):
|
||||||
|
"""Return appropriate Gradle command for OS"""
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
gradle_bat = project_root / 'gradlew.bat'
|
||||||
|
if gradle_bat.exists():
|
||||||
|
return str(gradle_bat)
|
||||||
|
return 'gradle.bat'
|
||||||
|
else: # Unix-like (Linux, macOS)
|
||||||
|
gradle_sh = project_root / 'gradlew'
|
||||||
|
if gradle_sh.exists():
|
||||||
|
return str(gradle_sh)
|
||||||
|
return 'gradle'
|
||||||
|
|
||||||
|
|
||||||
|
def run_service(service_name, config, project_root):
|
||||||
|
"""Run service"""
|
||||||
|
print(f"[START] Starting {service_name} service...")
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
env = os.environ.copy()
|
||||||
|
for key, value in config['env_vars'].items():
|
||||||
|
env[key] = value
|
||||||
|
print(f" [ENV] {key}={value}")
|
||||||
|
|
||||||
|
# Prepare Gradle command
|
||||||
|
gradle_cmd = get_gradle_command(project_root)
|
||||||
|
|
||||||
|
# Execute tasks
|
||||||
|
for task_name in config['task_names']:
|
||||||
|
print(f"\n[RUN] Executing: {task_name}")
|
||||||
|
|
||||||
|
cmd = [gradle_cmd, task_name]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute from project root directory
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
universal_newlines=True,
|
||||||
|
bufsize=1,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace'
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"[CMD] Command: {' '.join(cmd)}")
|
||||||
|
print(f"[DIR] Working directory: {project_root}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Real-time output
|
||||||
|
for line in process.stdout:
|
||||||
|
print(line.rstrip())
|
||||||
|
|
||||||
|
# Wait for process completion
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
print(f"\n[SUCCESS] {task_name} execution completed")
|
||||||
|
else:
|
||||||
|
print(f"\n[FAILED] {task_name} execution failed (exit code: {process.returncode})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n[STOP] Interrupted by user")
|
||||||
|
process.terminate()
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] Execution error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_available_services(configurations):
|
||||||
|
"""List available services"""
|
||||||
|
print("[LIST] Available services:")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
for service_name, config in configurations.items():
|
||||||
|
if config['task_names']:
|
||||||
|
print(f" [SERVICE] {service_name}")
|
||||||
|
if 'config_path' in config:
|
||||||
|
print(f" +-- Config: {config['config_path']}")
|
||||||
|
for task in config['task_names']:
|
||||||
|
print(f" +-- Task: {task}")
|
||||||
|
print(f" +-- {len(config['env_vars'])} environment variables")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Tripgen Service Runner Script',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python run-config.py user-service
|
||||||
|
python run-config.py location-service
|
||||||
|
python run-config.py trip-service
|
||||||
|
python run-config.py ai-service
|
||||||
|
python run-config.py --list
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'service_name',
|
||||||
|
nargs='?',
|
||||||
|
help='Service name to run'
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--list', '-l',
|
||||||
|
action='store_true',
|
||||||
|
help='List available services'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Find project root
|
||||||
|
project_root = get_project_root()
|
||||||
|
print(f"[INFO] Project root: {project_root}")
|
||||||
|
|
||||||
|
# Parse run configurations
|
||||||
|
print("[INFO] Reading run configuration files...")
|
||||||
|
configurations = parse_run_configurations(project_root)
|
||||||
|
|
||||||
|
if not configurations:
|
||||||
|
print("[ERROR] No execution configurations found")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"[INFO] Found {len(configurations)} execution configurations")
|
||||||
|
|
||||||
|
# List services request
|
||||||
|
if args.list:
|
||||||
|
list_available_services(configurations)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# If service name not provided
|
||||||
|
if not args.service_name:
|
||||||
|
print("\n[ERROR] Please provide service name")
|
||||||
|
list_available_services(configurations)
|
||||||
|
print("Usage: python run-config.py <service-name>")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Find service
|
||||||
|
service_name = args.service_name
|
||||||
|
|
||||||
|
# Try to parse specific service configuration if not found
|
||||||
|
if service_name not in configurations:
|
||||||
|
print(f"[INFO] Trying to find configuration for '{service_name}'...")
|
||||||
|
configurations = parse_run_configurations(project_root, service_name)
|
||||||
|
|
||||||
|
if service_name not in configurations:
|
||||||
|
print(f"[ERROR] Cannot find '{service_name}' service")
|
||||||
|
list_available_services(configurations)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
config = configurations[service_name]
|
||||||
|
|
||||||
|
if not config['task_names']:
|
||||||
|
print(f"[ERROR] No executable tasks found for '{service_name}' service")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Execute service
|
||||||
|
print(f"\n[TARGET] Starting '{service_name}' service execution")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
success = run_service(service_name, config, project_root)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"\n[COMPLETE] '{service_name}' service started successfully!")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print(f"\n[FAILED] Failed to start '{service_name}' service")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
exit_code = main()
|
||||||
|
sys.exit(exit_code)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[STOP] Interrupted by user")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] Unexpected error occurred: {e}")
|
||||||
|
sys.exit(1)
|
||||||
Loading…
x
Reference in New Issue
Block a user