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:
박세원
2025-10-27 16:27:14 +09:00
parent f0699b2e2b
commit 29dddd89b7
50 changed files with 2492 additions and 47 deletions
+8
View File
@@ -23,3 +23,11 @@ dependencies {
// 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
}
// 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.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
@@ -14,7 +15,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
* @since 1.0.0
*/
@EnableFeignClients
@SpringBootApplication
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class AiServiceApplication {
public static void main(String[] args) {
@@ -30,11 +30,10 @@ public interface ClaudeApiClient {
* @param request Claude 요청
* @return Claude 응답
*/
@PostMapping
@PostMapping(consumes = "application/json", produces = "application/json")
ClaudeResponse sendMessage(
@RequestHeader("x-api-key") String apiKey,
@RequestHeader("anthropic-version") String anthropicVersion,
@RequestHeader("content-type") String contentType,
@RequestBody ClaudeRequest request
);
}
@@ -1,15 +1,23 @@
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
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.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis 설정
* - 작업 상태 및 추천 결과 캐싱
@@ -33,6 +41,9 @@ public class RedisConfig {
@Value("${spring.data.redis.database}")
private int redisDatabase;
@Value("${spring.data.redis.timeout:3000}")
private long redisTimeout;
/**
* Redis 연결 팩토리 설정
*/
@@ -46,13 +57,46 @@ public class RedisConfig {
}
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 설정
* - Key: String
* - Value: JSON (Jackson)
* - Value: JSON (Jackson with Java 8 Date/Time support)
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
@@ -63,9 +107,12 @@ public class RedisConfig {
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value Serializer: JSON
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
// Value Serializer: JSON with Java 8 Date/Time support
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(redisObjectMapper());
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
@@ -5,8 +5,8 @@ import com.kt.ai.model.enums.CircuitBreakerState;
import com.kt.ai.model.enums.ServiceStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -23,22 +23,27 @@ import java.time.LocalDateTime;
@Slf4j
@Tag(name = "Health Check", description = "서비스 상태 확인")
@RestController
@RequiredArgsConstructor
public class HealthController {
private final RedisTemplate<String, Object> redisTemplate;
@Autowired(required = false)
private RedisTemplate<String, Object> redisTemplate;
/**
* 서비스 헬스체크
*/
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
@GetMapping("/health")
@GetMapping("/api/v1/ai-service/health")
public ResponseEntity<HealthCheckResponse> healthCheck() {
// Redis 상태 확인
ServiceStatus redisStatus = checkRedis();
// 전체 서비스 상태
ServiceStatus overallStatus = (redisStatus == ServiceStatus.UP) ? ServiceStatus.UP : ServiceStatus.DEGRADED;
// 전체 서비스 상태 (Redis가 DOWN이면 DEGRADED, UNKNOWN이면 UP으로 처리)
ServiceStatus overallStatus;
if (redisStatus == ServiceStatus.DOWN) {
overallStatus = ServiceStatus.DEGRADED;
} else {
overallStatus = ServiceStatus.UP;
}
HealthCheckResponse.Services services = HealthCheckResponse.Services.builder()
.kafka(ServiceStatus.UP) // TODO: 실제 Kafka 상태 확인
@@ -61,11 +66,25 @@ public class HealthController {
* Redis 연결 상태 확인
*/
private ServiceStatus checkRedis() {
// RedisTemplate이 주입되지 않은 경우 (로컬 환경 등)
if (redisTemplate == null) {
log.warn("RedisTemplate이 주입되지 않았습니다. Redis 상태를 UNKNOWN으로 표시합니다.");
return ServiceStatus.UNKNOWN;
}
try {
redisTemplate.getConnectionFactory().getConnection().ping();
log.debug("Redis 연결 테스트 시작...");
String pong = redisTemplate.getConnectionFactory().getConnection().ping();
log.info("✅ Redis 연결 성공! PING 응답: {}", pong);
return ServiceStatus.UP;
} 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;
}
}
@@ -1,6 +1,8 @@
package com.kt.ai.controller;
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 io.swagger.v3.oas.annotations.Operation;
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.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* Internal Job Controller
* Event Service에서 호출하는 내부 API
@@ -22,11 +27,12 @@ import org.springframework.web.bind.annotation.RestController;
@Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController
@RequestMapping("/internal/jobs")
@RequestMapping("/api/v1/ai-service/internal/jobs")
@RequiredArgsConstructor
public class InternalJobController {
private final JobStatusService jobStatusService;
private final CacheService cacheService;
/**
* 작업 상태 조회
@@ -38,4 +44,49 @@ public class InternalJobController {
JobStatusResponse response = jobStatusService.getJobStatus(jobId);
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;
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.CacheService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
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
* Event Service에서 호출하는 내부 API
@@ -22,11 +31,13 @@ import org.springframework.web.bind.annotation.RestController;
@Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController
@RequestMapping("/internal/recommendations")
@RequestMapping("/api/v1/ai-service/internal/recommendations")
@RequiredArgsConstructor
public class InternalRecommendationController {
private final AIRecommendationService aiRecommendationService;
private final CacheService cacheService;
private final RedisTemplate<String, Object> redisTemplate;
/**
* AI 추천 결과 조회
@@ -38,4 +49,216 @@ public class InternalRecommendationController {
AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId);
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.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.time.LocalDateTime;
import java.util.HashMap;
@@ -89,6 +90,29 @@ public class GlobalExceptionHandler {
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(
apiKey,
anthropicVersion,
"application/json",
request
);
@@ -93,7 +93,6 @@ public class TrendAnalysisService {
ClaudeResponse response = claudeApiClient.sendMessage(
apiKey,
anthropicVersion,
"application/json",
request
);
+11 -22
View File
@@ -5,10 +5,10 @@ spring:
# Redis Configuration
data:
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}
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}
lettuce:
pool:
@@ -33,26 +33,6 @@ spring:
listener:
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:
port: ${SERVER_PORT:8083}
@@ -119,6 +99,13 @@ logging:
pattern:
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:
name: ${LOG_FILE:logs/ai-service.log}
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
# Kafka Topics Configuration
kafka:
@@ -131,8 +118,10 @@ ai:
claude:
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
api-key: ${CLAUDE_API_KEY:}
anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
temperature: ${CLAUDE_TEMPERATURE:0.7}
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
gpt4:
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;
}
}
@@ -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