diff --git a/.gitignore b/.gitignore
index 635b6bd..74a08c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ yarn-error.log*
# IDE
.idea/
.vscode/
+.run/
*.swp
*.swo
*~
@@ -31,6 +32,13 @@ logs/
logs/
*.log
+# Gradle
+.gradle/
+gradle-app.setting
+!gradle-wrapper.jar
+!gradle-wrapper.properties
+.gradletasknamecache
+
# Environment
.env
.env.local
diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml
index a323100..8102290 100644
--- a/.run/ParticipationServiceApplication.run.xml
+++ b/.run/ParticipationServiceApplication.run.xml
@@ -43,7 +43,7 @@
diff --git a/ai-service/build.gradle b/ai-service/build.gradle
index a39127e..ffa12b5 100644
--- a/ai-service/build.gradle
+++ b/ai-service/build.gradle
@@ -2,8 +2,8 @@ dependencies {
// Kafka Consumer
implementation 'org.springframework.kafka:spring-kafka'
- // Redis for result caching
- implementation 'org.springframework.boot:spring-boot-starter-data-redis'
+ // Redis for result caching (already in root build.gradle)
+ // implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// OpenFeign for Claude/GPT API
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
@@ -14,4 +14,20 @@ dependencies {
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
+
+ // JWT (for security)
+ implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
+ runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
+ runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
+
+ // 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'
}
diff --git a/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java b/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java
new file mode 100644
index 0000000..be8b721
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/AiServiceApplication.java
@@ -0,0 +1,24 @@
+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;
+
+/**
+ * AI Service Application
+ * - Kafka를 통한 비동기 AI 추천 처리
+ * - Claude API / GPT-4 API 연동
+ * - Redis 기반 결과 캐싱
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@EnableFeignClients
+@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
+public class AiServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AiServiceApplication.class, args);
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java b/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java
new file mode 100644
index 0000000..870b4b1
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/circuitbreaker/CircuitBreakerManager.java
@@ -0,0 +1,87 @@
+package com.kt.ai.circuitbreaker;
+
+import com.kt.ai.exception.CircuitBreakerOpenException;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.function.Supplier;
+
+/**
+ * Circuit Breaker Manager
+ * - Claude API / GPT-4 API 호출 시 Circuit Breaker 적용
+ * - Fallback 처리
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CircuitBreakerManager {
+
+ private final CircuitBreakerRegistry circuitBreakerRegistry;
+
+ /**
+ * Circuit Breaker를 통한 API 호출
+ *
+ * @param circuitBreakerName Circuit Breaker 이름 (claudeApi, gpt4Api)
+ * @param supplier API 호출 로직
+ * @param fallback Fallback 로직
+ * @return API 호출 결과 또는 Fallback 결과
+ */
+ public T executeWithCircuitBreaker(
+ String circuitBreakerName,
+ Supplier supplier,
+ Supplier fallback
+ ) {
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
+
+ try {
+ // Circuit Breaker 상태 확인
+ if (circuitBreaker.getState() == CircuitBreaker.State.OPEN) {
+ log.warn("Circuit Breaker is OPEN: {}", circuitBreakerName);
+ throw new CircuitBreakerOpenException(circuitBreakerName);
+ }
+
+ // Circuit Breaker를 통한 API 호출
+ return circuitBreaker.executeSupplier(() -> {
+ log.debug("Executing with Circuit Breaker: {}", circuitBreakerName);
+ return supplier.get();
+ });
+
+ } catch (CircuitBreakerOpenException e) {
+ // Circuit Breaker가 열린 경우 Fallback 실행
+ log.warn("Circuit Breaker OPEN, executing fallback: {}", circuitBreakerName);
+ if (fallback != null) {
+ return fallback.get();
+ }
+ throw e;
+
+ } catch (Exception e) {
+ // 기타 예외 발생 시 Fallback 실행
+ log.error("API call failed, executing fallback: {}", circuitBreakerName, e);
+ if (fallback != null) {
+ return fallback.get();
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Circuit Breaker를 통한 API 호출 (Fallback 없음)
+ */
+ public T executeWithCircuitBreaker(String circuitBreakerName, Supplier supplier) {
+ return executeWithCircuitBreaker(circuitBreakerName, supplier, null);
+ }
+
+ /**
+ * Circuit Breaker 상태 조회
+ */
+ public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) {
+ CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
+ return circuitBreaker.getState();
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java b/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java
new file mode 100644
index 0000000..d7860cf
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/circuitbreaker/fallback/AIServiceFallback.java
@@ -0,0 +1,130 @@
+package com.kt.ai.circuitbreaker.fallback;
+
+import com.kt.ai.model.dto.response.EventRecommendation;
+import com.kt.ai.model.dto.response.ExpectedMetrics;
+import com.kt.ai.model.dto.response.TrendAnalysis;
+import com.kt.ai.model.enums.EventMechanicsType;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * AI Service Fallback 처리
+ * - Circuit Breaker가 열린 경우 기본 데이터 반환
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Slf4j
+@Component
+public class AIServiceFallback {
+
+ /**
+ * 기본 트렌드 분석 결과 반환
+ */
+ public TrendAnalysis getDefaultTrendAnalysis(String industry, String region) {
+ log.info("Fallback: 기본 트렌드 분석 결과 반환 - industry={}, region={}", industry, region);
+
+ List industryTrends = List.of(
+ TrendAnalysis.TrendKeyword.builder()
+ .keyword("고객 만족도 향상")
+ .relevance(0.8)
+ .description(industry + " 업종에서 고객 만족도가 중요한 트렌드입니다")
+ .build(),
+ TrendAnalysis.TrendKeyword.builder()
+ .keyword("디지털 마케팅")
+ .relevance(0.75)
+ .description("SNS 및 온라인 마케팅이 효과적입니다")
+ .build()
+ );
+
+ List regionalTrends = List.of(
+ TrendAnalysis.TrendKeyword.builder()
+ .keyword("지역 커뮤니티")
+ .relevance(0.7)
+ .description(region + " 지역 커뮤니티 참여가 효과적입니다")
+ .build()
+ );
+
+ List seasonalTrends = List.of(
+ TrendAnalysis.TrendKeyword.builder()
+ .keyword("시즌 이벤트")
+ .relevance(0.85)
+ .description("계절 특성을 반영한 이벤트가 효과적입니다")
+ .build()
+ );
+
+ return TrendAnalysis.builder()
+ .industryTrends(industryTrends)
+ .regionalTrends(regionalTrends)
+ .seasonalTrends(seasonalTrends)
+ .build();
+ }
+
+ /**
+ * 기본 이벤트 추천안 반환
+ */
+ public List getDefaultRecommendations(String objective, String industry) {
+ log.info("Fallback: 기본 이벤트 추천안 반환 - objective={}, industry={}", objective, industry);
+
+ List recommendations = new ArrayList<>();
+
+ // 옵션 1: 저비용 이벤트
+ recommendations.add(createDefaultRecommendation(1, "저비용 SNS 이벤트", objective, industry, 100000, 200000));
+
+ // 옵션 2: 중비용 이벤트
+ recommendations.add(createDefaultRecommendation(2, "중비용 방문 유도 이벤트", objective, industry, 300000, 500000));
+
+ // 옵션 3: 고비용 이벤트
+ recommendations.add(createDefaultRecommendation(3, "고비용 프리미엄 이벤트", objective, industry, 500000, 1000000));
+
+ return recommendations;
+ }
+
+ /**
+ * 기본 추천안 생성
+ */
+ private EventRecommendation createDefaultRecommendation(
+ int optionNumber,
+ String concept,
+ String objective,
+ String industry,
+ int minCost,
+ int maxCost
+ ) {
+ return EventRecommendation.builder()
+ .optionNumber(optionNumber)
+ .concept(concept)
+ .title(objective + " - " + concept)
+ .description("AI 서비스가 일시적으로 사용 불가능하여 기본 추천안을 제공합니다. " +
+ industry + " 업종에 적합한 " + concept + "입니다.")
+ .targetAudience("일반 고객")
+ .duration(EventRecommendation.Duration.builder()
+ .recommendedDays(14)
+ .recommendedPeriod("2주")
+ .build())
+ .mechanics(EventRecommendation.Mechanics.builder()
+ .type(EventMechanicsType.DISCOUNT)
+ .details("할인 쿠폰 제공 또는 경품 추첨")
+ .build())
+ .promotionChannels(List.of("Instagram", "네이버 블로그", "카카오톡 채널"))
+ .estimatedCost(EventRecommendation.EstimatedCost.builder()
+ .min(minCost)
+ .max(maxCost)
+ .breakdown(Map.of(
+ "경품비", minCost / 2,
+ "홍보비", minCost / 2
+ ))
+ .build())
+ .expectedMetrics(ExpectedMetrics.builder()
+ .newCustomers(ExpectedMetrics.Range.builder().min(30.0).max(50.0).build())
+ .revenueIncrease(ExpectedMetrics.Range.builder().min(10.0).max(20.0).build())
+ .roi(ExpectedMetrics.Range.builder().min(100.0).max(150.0).build())
+ .build())
+ .differentiator("AI 분석이 제한적으로 제공되는 기본 추천안입니다")
+ .build();
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java b/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java
new file mode 100644
index 0000000..abc2137
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/client/ClaudeApiClient.java
@@ -0,0 +1,39 @@
+package com.kt.ai.client;
+
+import com.kt.ai.client.config.FeignClientConfig;
+import com.kt.ai.client.dto.ClaudeRequest;
+import com.kt.ai.client.dto.ClaudeResponse;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+/**
+ * Claude API Feign Client
+ * API Docs: https://docs.anthropic.com/claude/reference/messages_post
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@FeignClient(
+ name = "claudeApiClient",
+ url = "${ai.claude.api-url}",
+ configuration = FeignClientConfig.class
+)
+public interface ClaudeApiClient {
+
+ /**
+ * Claude Messages API 호출
+ *
+ * @param apiKey Claude API Key
+ * @param anthropicVersion API Version (2023-06-01)
+ * @param request Claude 요청
+ * @return Claude 응답
+ */
+ @PostMapping(consumes = "application/json", produces = "application/json")
+ ClaudeResponse sendMessage(
+ @RequestHeader("x-api-key") String apiKey,
+ @RequestHeader("anthropic-version") String anthropicVersion,
+ @RequestBody ClaudeRequest request
+ );
+}
diff --git a/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java b/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java
new file mode 100644
index 0000000..f68466c
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/client/config/FeignClientConfig.java
@@ -0,0 +1,57 @@
+package com.kt.ai.client.config;
+
+import feign.Logger;
+import feign.Request;
+import feign.Retryer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Feign Client 설정
+ * - Claude API / GPT-4 API 연동 설정
+ * - Timeout, Retry 설정
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Configuration
+public class FeignClientConfig {
+
+ /**
+ * Feign Logger Level 설정
+ */
+ @Bean
+ public Logger.Level feignLoggerLevel() {
+ return Logger.Level.FULL;
+ }
+
+ /**
+ * Feign Request Options (Timeout 설정)
+ * - Connect Timeout: 10초
+ * - Read Timeout: 5분 (300초)
+ */
+ @Bean
+ public Request.Options requestOptions() {
+ return new Request.Options(
+ 10, TimeUnit.SECONDS, // connectTimeout
+ 300, TimeUnit.SECONDS, // readTimeout (5분)
+ true // followRedirects
+ );
+ }
+
+ /**
+ * Feign Retryer 설정
+ * - 최대 3회 재시도
+ * - Exponential Backoff: 1초, 5초, 10초
+ */
+ @Bean
+ public Retryer retryer() {
+ return new Retryer.Default(
+ 1000L, // period (1초)
+ 5000L, // maxPeriod (5초)
+ 3 // maxAttempts (3회)
+ );
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java
new file mode 100644
index 0000000..6dd394b
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeRequest.java
@@ -0,0 +1,67 @@
+package com.kt.ai.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Claude API 요청 DTO
+ * API Docs: https://docs.anthropic.com/claude/reference/messages_post
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ClaudeRequest {
+ /**
+ * 모델명 (예: claude-3-5-sonnet-20241022)
+ */
+ private String model;
+
+ /**
+ * 메시지 목록
+ */
+ private List messages;
+
+ /**
+ * 최대 토큰 수
+ */
+ @JsonProperty("max_tokens")
+ private Integer maxTokens;
+
+ /**
+ * Temperature (0.0 ~ 1.0)
+ */
+ private Double temperature;
+
+ /**
+ * System 프롬프트 (선택)
+ */
+ private String system;
+
+ /**
+ * 메시지
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class Message {
+ /**
+ * 역할 (user, assistant)
+ */
+ private String role;
+
+ /**
+ * 메시지 내용
+ */
+ private String content;
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java
new file mode 100644
index 0000000..d587474
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/client/dto/ClaudeResponse.java
@@ -0,0 +1,108 @@
+package com.kt.ai.client.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * Claude API 응답 DTO
+ * API Docs: https://docs.anthropic.com/claude/reference/messages_post
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ClaudeResponse {
+ /**
+ * 응답 ID
+ */
+ private String id;
+
+ /**
+ * 타입 (message)
+ */
+ private String type;
+
+ /**
+ * 역할 (assistant)
+ */
+ private String role;
+
+ /**
+ * 콘텐츠 목록
+ */
+ private List content;
+
+ /**
+ * 모델명
+ */
+ private String model;
+
+ /**
+ * 중단 이유 (end_turn, max_tokens, stop_sequence)
+ */
+ @JsonProperty("stop_reason")
+ private String stopReason;
+
+ /**
+ * 사용량
+ */
+ private Usage usage;
+
+ /**
+ * 콘텐츠
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class Content {
+ /**
+ * 타입 (text)
+ */
+ private String type;
+
+ /**
+ * 텍스트 내용
+ */
+ private String text;
+ }
+
+ /**
+ * 토큰 사용량
+ */
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class Usage {
+ /**
+ * 입력 토큰 수
+ */
+ @JsonProperty("input_tokens")
+ private Integer inputTokens;
+
+ /**
+ * 출력 토큰 수
+ */
+ @JsonProperty("output_tokens")
+ private Integer outputTokens;
+ }
+
+ /**
+ * 텍스트 내용 추출
+ */
+ public String extractText() {
+ if (content != null && !content.isEmpty()) {
+ return content.get(0).getText();
+ }
+ return null;
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java b/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java
new file mode 100644
index 0000000..c4e7b8d
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/config/CircuitBreakerConfig.java
@@ -0,0 +1,71 @@
+package com.kt.ai.config;
+
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import io.github.resilience4j.timelimiter.TimeLimiterConfig;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+
+/**
+ * Circuit Breaker 설정
+ * - Claude API / GPT-4 API 장애 대응
+ * - Timeout: 5분 (300초)
+ * - Failure Threshold: 50%
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Configuration
+public class CircuitBreakerConfig {
+
+ /**
+ * Circuit Breaker Registry 설정
+ */
+ @Bean
+ public CircuitBreakerRegistry circuitBreakerRegistry() {
+ io.github.resilience4j.circuitbreaker.CircuitBreakerConfig config =
+ io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
+ .failureRateThreshold(50)
+ .slowCallRateThreshold(50)
+ .slowCallDurationThreshold(Duration.ofSeconds(60))
+ .permittedNumberOfCallsInHalfOpenState(3)
+ .maxWaitDurationInHalfOpenState(Duration.ZERO)
+ .slidingWindowType(SlidingWindowType.COUNT_BASED)
+ .slidingWindowSize(10)
+ .minimumNumberOfCalls(5)
+ .waitDurationInOpenState(Duration.ofSeconds(60))
+ .automaticTransitionFromOpenToHalfOpenEnabled(true)
+ .build();
+
+ return CircuitBreakerRegistry.of(config);
+ }
+
+ /**
+ * Claude API Circuit Breaker
+ */
+ @Bean
+ public CircuitBreaker claudeApiCircuitBreaker(CircuitBreakerRegistry registry) {
+ return registry.circuitBreaker("claudeApi");
+ }
+
+ /**
+ * GPT-4 API Circuit Breaker
+ */
+ @Bean
+ public CircuitBreaker gpt4ApiCircuitBreaker(CircuitBreakerRegistry registry) {
+ return registry.circuitBreaker("gpt4Api");
+ }
+
+ /**
+ * Time Limiter 설정 (5분)
+ */
+ @Bean
+ public TimeLimiterConfig timeLimiterConfig() {
+ return TimeLimiterConfig.custom()
+ .timeoutDuration(Duration.ofSeconds(300))
+ .build();
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java b/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java
new file mode 100644
index 0000000..16de92f
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/config/JacksonConfig.java
@@ -0,0 +1,25 @@
+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 org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Jackson ObjectMapper 설정
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Configuration
+public class JacksonConfig {
+
+ @Bean
+ public ObjectMapper objectMapper() {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.registerModule(new JavaTimeModule());
+ mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ return mapper;
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java b/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java
new file mode 100644
index 0000000..23df4d9
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/config/KafkaConsumerConfig.java
@@ -0,0 +1,76 @@
+package com.kt.ai.config;
+
+import com.kt.ai.kafka.message.AIJobMessage;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.listener.ContainerProperties;
+import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer;
+import org.springframework.kafka.support.serializer.JsonDeserializer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Kafka Consumer 설정
+ * - Topic: ai-event-generation-job
+ * - Consumer Group: ai-service-consumers
+ * - Manual ACK 모드
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@EnableKafka
+@Configuration
+public class KafkaConsumerConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Value("${spring.kafka.consumer.group-id}")
+ private String groupId;
+
+ /**
+ * Kafka Consumer 팩토리 설정
+ */
+ @Bean
+ public ConsumerFactory consumerFactory() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
+ props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10);
+ props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000);
+
+ // Key Deserializer
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+
+ // Value Deserializer with Error Handling
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
+ props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName());
+ props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, AIJobMessage.class.getName());
+ props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
+
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ /**
+ * Kafka Listener Container Factory 설정
+ * - Manual ACK 모드
+ */
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() {
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+ factory.setConsumerFactory(consumerFactory());
+ factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
+ return factory;
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java b/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java
new file mode 100644
index 0000000..1790966
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/config/RedisConfig.java
@@ -0,0 +1,120 @@
+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 설정
+ * - 작업 상태 및 추천 결과 캐싱
+ * - TTL: 추천 24시간, Job 상태 24시간, 트렌드 1시간
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Configuration
+public class RedisConfig {
+
+ @Value("${spring.data.redis.host}")
+ private String redisHost;
+
+ @Value("${spring.data.redis.port}")
+ private int redisPort;
+
+ @Value("${spring.data.redis.password}")
+ private String redisPassword;
+
+ @Value("${spring.data.redis.database}")
+ private int redisDatabase;
+
+ @Value("${spring.data.redis.timeout:3000}")
+ private long redisTimeout;
+
+ /**
+ * Redis 연결 팩토리 설정
+ */
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
+ config.setHostName(redisHost);
+ config.setPort(redisPort);
+ if (redisPassword != null && !redisPassword.isEmpty()) {
+ config.setPassword(redisPassword);
+ }
+ config.setDatabase(redisDatabase);
+
+ // 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 with Java 8 Date/Time support)
+ */
+ @Bean
+ public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(connectionFactory);
+
+ // Key Serializer: String
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setHashKeySerializer(new StringRedisSerializer());
+
+ // Value Serializer: JSON with Java 8 Date/Time support
+ GenericJackson2JsonRedisSerializer serializer =
+ new GenericJackson2JsonRedisSerializer(redisObjectMapper());
+
+ template.setValueSerializer(serializer);
+ template.setHashValueSerializer(serializer);
+
+ template.afterPropertiesSet();
+ return template;
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java
new file mode 100644
index 0000000..08e9b2e
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java
@@ -0,0 +1,67 @@
+package com.kt.ai.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Spring Security 설정
+ * - Internal API만 제공 (Event Service에서만 호출)
+ * - JWT 인증 없음 (내부 통신)
+ * - CORS 설정
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Configuration
+@EnableWebSecurity
+public class SecurityConfig {
+
+ /**
+ * Security Filter Chain 설정
+ * - 모든 요청 허용 (내부 API)
+ * - CSRF 비활성화
+ * - Stateless 세션
+ */
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
+ .requestMatchers("/internal/**").permitAll() // Internal API
+ .anyRequest().permitAll()
+ );
+
+ return http.build();
+ }
+
+ /**
+ * CORS 설정
+ */
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080"));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
+ configuration.setAllowedHeaders(List.of("*"));
+ configuration.setAllowCredentials(true);
+ configuration.setMaxAge(3600L);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java b/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java
new file mode 100644
index 0000000..4523c0d
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/config/SwaggerConfig.java
@@ -0,0 +1,64 @@
+package com.kt.ai.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+/**
+ * Swagger/OpenAPI 설정
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Configuration
+public class SwaggerConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ Server localServer = new Server();
+ localServer.setUrl("http://localhost:8083");
+ localServer.setDescription("Local Development Server");
+
+ Server devServer = new Server();
+ devServer.setUrl("https://dev-api.kt-event-marketing.com/ai/v1");
+ devServer.setDescription("Development Server");
+
+ Server prodServer = new Server();
+ prodServer.setUrl("https://api.kt-event-marketing.com/ai/v1");
+ prodServer.setDescription("Production Server");
+
+ Contact contact = new Contact();
+ contact.setName("Digital Garage Team");
+ contact.setEmail("support@kt-event-marketing.com");
+
+ Info info = new Info()
+ .title("AI Service API")
+ .version("1.0.0")
+ .description("""
+ KT AI 기반 소상공인 이벤트 자동 생성 서비스 - AI Service
+
+ ## 서비스 개요
+ - Kafka를 통한 비동기 AI 추천 처리
+ - Claude API / GPT-4 API 연동
+ - Redis 기반 결과 캐싱 (TTL 24시간)
+
+ ## 처리 흐름
+ 1. Event Service가 Kafka Topic에 Job 메시지 발행
+ 2. AI Service가 메시지 구독 및 처리
+ 3. 트렌드 분석 수행 (Claude/GPT-4 API)
+ 4. 3가지 이벤트 추천안 생성
+ 5. 결과를 Redis에 저장 (TTL 24시간)
+ 6. Job 상태를 Redis에 업데이트
+ """)
+ .contact(contact);
+
+ return new OpenAPI()
+ .info(info)
+ .servers(List.of(localServer, devServer, prodServer));
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/controller/HealthController.java b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java
new file mode 100644
index 0000000..b54b890
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/controller/HealthController.java
@@ -0,0 +1,91 @@
+package com.kt.ai.controller;
+
+import com.kt.ai.model.dto.response.HealthCheckResponse;
+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.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;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+
+/**
+ * 헬스체크 Controller
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Slf4j
+@Tag(name = "Health Check", description = "서비스 상태 확인")
+@RestController
+public class HealthController {
+
+ @Autowired(required = false)
+ private RedisTemplate redisTemplate;
+
+ /**
+ * 서비스 헬스체크
+ */
+ @Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
+ @GetMapping("/api/v1/ai-service/health")
+ public ResponseEntity healthCheck() {
+ // Redis 상태 확인
+ ServiceStatus redisStatus = checkRedis();
+
+ // 전체 서비스 상태 (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 상태 확인
+ .redis(redisStatus)
+ .claudeApi(ServiceStatus.UP) // TODO: 실제 Claude API 상태 확인
+ .gpt4Api(ServiceStatus.UP) // TODO: 실제 GPT-4 API 상태 확인 (선택)
+ .circuitBreaker(CircuitBreakerState.CLOSED) // TODO: 실제 Circuit Breaker 상태 확인
+ .build();
+
+ HealthCheckResponse response = HealthCheckResponse.builder()
+ .status(overallStatus)
+ .timestamp(LocalDateTime.now())
+ .services(services)
+ .build();
+
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * Redis 연결 상태 확인
+ */
+ private ServiceStatus checkRedis() {
+ // RedisTemplate이 주입되지 않은 경우 (로컬 환경 등)
+ if (redisTemplate == null) {
+ log.warn("RedisTemplate이 주입되지 않았습니다. Redis 상태를 UNKNOWN으로 표시합니다.");
+ return ServiceStatus.UNKNOWN;
+ }
+
+ try {
+ 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("상세 오류 정보:");
+ log.error(" - 오류 타입: {}", e.getClass().getName());
+ log.error(" - 오류 메시지: {}", e.getMessage());
+ if (e.getCause() != null) {
+ log.error(" - 원인: {}", e.getCause().getMessage());
+ }
+ return ServiceStatus.DOWN;
+ }
+ }
+}
diff --git a/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java b/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java
new file mode 100644
index 0000000..aba5cc0
--- /dev/null
+++ b/ai-service/src/main/java/com/kt/ai/controller/InternalJobController.java
@@ -0,0 +1,92 @@
+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;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.Map;
+
+/**
+ * Internal Job Controller
+ * Event Service에서 호출하는 내부 API
+ *
+ * @author AI Service Team
+ * @since 1.0.0
+ */
+@Slf4j
+@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
+@RestController
+@RequestMapping("/api/v1/ai-service/internal/jobs")
+@RequiredArgsConstructor
+public class InternalJobController {
+
+ private final JobStatusService jobStatusService;
+ private final CacheService cacheService;
+
+ /**
+ * 작업 상태 조회
+ */
+ @Operation(summary = "작업 상태 조회", description = "Redis에 저장된 AI 추천 작업 상태 조회")
+ @GetMapping("/{jobId}/status")
+ public ResponseEntity getJobStatus(@PathVariable String jobId) {
+ log.info("Job 상태 조회 요청: jobId={}", jobId);
+ 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