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
index 08e9b2e..298aebf 100644
--- a/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java
+++ b/ai-service/src/main/java/com/kt/ai/config/SecurityConfig.java
@@ -4,6 +4,7 @@ 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.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@@ -27,21 +28,19 @@ import java.util.List;
@EnableWebSecurity
public class SecurityConfig {
- /**
- * Security Filter Chain 설정
- * - 모든 요청 허용 (내부 API)
- * - CSRF 비활성화
- * - Stateless 세션
- */
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
+ // CSRF 비활성화 (REST API는 CSRF 불필요)
.csrf(AbstractHttpConfigurer::disable)
- .cors(cors -> cors.configurationSource(corsConfigurationSource()))
- .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+
+ // 세션 사용 안 함 (JWT 기반 인증)
+ .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()
);
@@ -49,19 +48,11 @@ public class SecurityConfig {
}
/**
- * CORS 설정
+ * Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
*/
@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;
+ public WebSecurityCustomizer webSecurityCustomizer() {
+ return (web) -> web.ignoring()
+ .requestMatchers("/.well-known/**");
}
}
diff --git a/develop/dev/ai-service-workflow.md b/develop/dev/ai-service-workflow.md
new file mode 100644
index 0000000..c8796a3
--- /dev/null
+++ b/develop/dev/ai-service-workflow.md
@@ -0,0 +1,390 @@
+# AI Service 전체 메서드 워크플로우
+
+## 1. AI 추천 생성 워크플로우 (Kafka 기반 비동기)
+
+```mermaid
+sequenceDiagram
+ participant ES as Event Service
+ participant Kafka as Kafka Topic
+ participant Consumer as AIJobConsumer
+ participant ARS as AIRecommendationService
+ participant JSS as JobStatusService
+ participant TAS as TrendAnalysisService
+ participant CS as CacheService
+ participant CAC as ClaudeApiClient
+ participant Claude as Claude API
+ participant Redis as Redis
+
+ %% 1. Event Service가 Kafka 메시지 발행
+ ES->>Kafka: Publish AIJobMessage
(ai-event-generation-job topic)
+
+ %% 2. Kafka Consumer가 메시지 수신
+ Kafka->>Consumer: consume(AIJobMessage)
+ Note over Consumer: @KafkaListener
groupId: ai-service-consumers
+
+ %% 3. AI 추천 생성 시작
+ Consumer->>ARS: generateRecommendations(message)
+ activate ARS
+
+ %% 4. Job 상태: PROCESSING (10%)
+ ARS->>JSS: updateJobStatus(jobId, PROCESSING, "트렌드 분석 중")
+ JSS->>CS: saveJobStatus(jobId, status)
+ CS->>Redis: SET ai:job:status:{jobId}
+
+ %% 5. 트렌드 분석
+ ARS->>ARS: analyzeTrend(message)
+ ARS->>CS: getTrend(industry, region)
+ CS->>Redis: GET ai:trend:{industry}:{region}
+
+ alt 캐시 HIT
+ Redis-->>CS: TrendAnalysis (cached)
+ CS-->>ARS: TrendAnalysis
+ else 캐시 MISS
+ ARS->>TAS: analyzeTrend(industry, region)
+ activate TAS
+
+ %% Circuit Breaker 적용
+ TAS->>TAS: circuitBreakerManager.executeWithCircuitBreaker()
+ TAS->>CAC: sendMessage(apiKey, version, request)
+ CAC->>Claude: POST /v1/messages
+ Note over Claude: Model: claude-sonnet-4-5
System: "트렌드 분석 전문가"
Prompt: 업종/지역/계절 트렌드
+ Claude-->>CAC: ClaudeResponse
+ CAC-->>TAS: ClaudeResponse
+
+ TAS->>TAS: parseResponse(responseText)
+ TAS-->>ARS: TrendAnalysis
+ deactivate TAS
+
+ %% 트렌드 캐싱
+ ARS->>CS: saveTrend(industry, region, analysis)
+ CS->>Redis: SET ai:trend:{industry}:{region} (TTL: 1시간)
+ end
+
+ %% 6. Job 상태: PROCESSING (50%)
+ ARS->>JSS: updateJobStatus(jobId, PROCESSING, "이벤트 추천안 생성 중")
+ JSS->>CS: saveJobStatus(jobId, status)
+ CS->>Redis: SET ai:job:status:{jobId}
+
+ %% 7. 이벤트 추천안 생성
+ ARS->>ARS: createRecommendations(message, trendAnalysis)
+ ARS->>ARS: circuitBreakerManager.executeWithCircuitBreaker()
+ ARS->>CAC: sendMessage(apiKey, version, request)
+ CAC->>Claude: POST /v1/messages
+ Note over Claude: Model: claude-sonnet-4-5
System: "이벤트 기획 전문가"
Prompt: 3가지 추천안 생성
+ Claude-->>CAC: ClaudeResponse
+ CAC-->>ARS: ClaudeResponse
+
+ ARS->>ARS: parseRecommendationResponse(responseText)
+
+ %% 8. Job 상태: PROCESSING (90%)
+ ARS->>JSS: updateJobStatus(jobId, PROCESSING, "결과 저장 중")
+ JSS->>CS: saveJobStatus(jobId, status)
+ CS->>Redis: SET ai:job:status:{jobId}
+
+ %% 9. 결과 저장
+ ARS->>CS: saveRecommendation(eventId, result)
+ CS->>Redis: SET ai:recommendation:{eventId} (TTL: 24시간)
+
+ %% 10. Job 상태: COMPLETED (100%)
+ ARS->>JSS: updateJobStatus(jobId, COMPLETED, "AI 추천 완료")
+ JSS->>CS: saveJobStatus(jobId, status)
+ CS->>Redis: SET ai:job:status:{jobId}
+
+ deactivate ARS
+
+ %% 11. Kafka ACK
+ Consumer->>Kafka: acknowledgment.acknowledge()
+```
+
+---
+
+## 2. Job 상태 조회 워크플로우 (동기)
+
+```mermaid
+sequenceDiagram
+ participant ES as Event Service
+ participant Controller as InternalJobController
+ participant JSS as JobStatusService
+ participant CS as CacheService
+ participant Redis as Redis
+
+ %% 1. Event Service가 Job 상태 조회
+ ES->>Controller: GET /api/v1/ai-service/internal/jobs/{jobId}/status
+
+ %% 2. Job 상태 조회
+ Controller->>JSS: getJobStatus(jobId)
+ activate JSS
+
+ JSS->>CS: getJobStatus(jobId)
+ CS->>Redis: GET ai:job:status:{jobId}
+
+ alt 상태 존재
+ Redis-->>CS: JobStatusResponse
+ CS-->>JSS: Object (JobStatusResponse)
+ JSS->>JSS: objectMapper.convertValue()
+ JSS-->>Controller: JobStatusResponse
+ Controller-->>ES: 200 OK + JobStatusResponse
+ else 상태 없음
+ Redis-->>CS: null
+ CS-->>JSS: null
+ JSS-->>Controller: JobNotFoundException
+ Controller-->>ES: 404 Not Found
+ end
+
+ deactivate JSS
+```
+
+---
+
+## 3. AI 추천 결과 조회 워크플로우 (동기)
+
+```mermaid
+sequenceDiagram
+ participant ES as Event Service
+ participant Controller as InternalRecommendationController
+ participant ARS as AIRecommendationService
+ participant CS as CacheService
+ participant Redis as Redis
+
+ %% 1. Event Service가 AI 추천 결과 조회
+ ES->>Controller: GET /api/v1/ai-service/internal/recommendations/{eventId}
+
+ %% 2. 추천 결과 조회
+ Controller->>ARS: getRecommendation(eventId)
+ activate ARS
+
+ ARS->>CS: getRecommendation(eventId)
+ CS->>Redis: GET ai:recommendation:{eventId}
+
+ alt 결과 존재
+ Redis-->>CS: AIRecommendationResult
+ CS-->>ARS: Object (AIRecommendationResult)
+ ARS->>ARS: objectMapper.convertValue()
+ ARS-->>Controller: AIRecommendationResult
+ Controller-->>ES: 200 OK + AIRecommendationResult
+ else 결과 없음
+ Redis-->>CS: null
+ CS-->>ARS: null
+ ARS-->>Controller: RecommendationNotFoundException
+ Controller-->>ES: 404 Not Found
+ end
+
+ deactivate ARS
+```
+
+---
+
+## 4. 헬스체크 워크플로우 (동기)
+
+```mermaid
+sequenceDiagram
+ participant Client as Client/Actuator
+ participant Controller as HealthController
+ participant Redis as Redis
+
+ %% 1. 헬스체크 요청
+ Client->>Controller: GET /api/v1/ai-service/health
+
+ %% 2. Redis 상태 확인
+ Controller->>Controller: checkRedis()
+
+ alt RedisTemplate 존재
+ Controller->>Redis: PING
+ alt Redis 정상
+ Redis-->>Controller: PONG
+ Controller->>Controller: redisStatus = UP
+ else Redis 오류
+ Redis-->>Controller: Exception
+ Controller->>Controller: redisStatus = DOWN
+ end
+ else RedisTemplate 없음
+ Controller->>Controller: redisStatus = UNKNOWN
+ end
+
+ %% 3. 전체 상태 판단
+ alt Redis DOWN
+ Controller->>Controller: overallStatus = DEGRADED
+ else Redis UP/UNKNOWN
+ Controller->>Controller: overallStatus = UP
+ end
+
+ %% 4. 응답
+ Controller-->>Client: 200 OK + HealthCheckResponse
+```
+
+---
+
+## 5. 주요 컴포넌트 메서드 목록
+
+### 5.1 Controller Layer
+
+#### InternalJobController
+| 메서드 | HTTP | 엔드포인트 | 설명 |
+|--------|------|-----------|------|
+| `getJobStatus(jobId)` | GET | `/api/v1/ai-service/internal/jobs/{jobId}/status` | Job 상태 조회 |
+| `createTestJob(jobId)` | GET | `/api/v1/ai-service/internal/jobs/debug/create-test-job/{jobId}` | 테스트 Job 생성 (디버그) |
+
+#### InternalRecommendationController
+| 메서드 | HTTP | 엔드포인트 | 설명 |
+|--------|------|-----------|------|
+| `getRecommendation(eventId)` | GET | `/api/v1/ai-service/internal/recommendations/{eventId}` | AI 추천 결과 조회 |
+| `debugRedisKeys()` | GET | `/api/v1/ai-service/internal/recommendations/debug/redis-keys` | Redis 모든 키 조회 |
+| `debugRedisKey(key)` | GET | `/api/v1/ai-service/internal/recommendations/debug/redis-key/{key}` | Redis 특정 키 조회 |
+| `searchAllDatabases()` | GET | `/api/v1/ai-service/internal/recommendations/debug/search-all-databases` | 전체 DB 검색 |
+| `createTestData(eventId)` | GET | `/api/v1/ai-service/internal/recommendations/debug/create-test-data/{eventId}` | 테스트 데이터 생성 |
+
+#### HealthController
+| 메서드 | HTTP | 엔드포인트 | 설명 |
+|--------|------|-----------|------|
+| `healthCheck()` | GET | `/api/v1/ai-service/health` | 서비스 헬스체크 |
+| `checkRedis()` | - | (내부) | Redis 연결 확인 |
+
+---
+
+### 5.2 Service Layer
+
+#### AIRecommendationService
+| 메서드 | 호출자 | 설명 |
+|--------|-------|------|
+| `getRecommendation(eventId)` | Controller | Redis에서 추천 결과 조회 |
+| `generateRecommendations(message)` | AIJobConsumer | AI 추천 생성 (전체 프로세스) |
+| `analyzeTrend(message)` | 내부 | 트렌드 분석 (캐시 확인 포함) |
+| `createRecommendations(message, trendAnalysis)` | 내부 | 이벤트 추천안 생성 |
+| `callClaudeApiForRecommendations(message, trendAnalysis)` | 내부 | Claude API 호출 (추천안) |
+| `buildRecommendationPrompt(message, trendAnalysis)` | 내부 | 추천안 프롬프트 생성 |
+| `parseRecommendationResponse(responseText)` | 내부 | 추천안 응답 파싱 |
+| `parseEventRecommendation(node)` | 내부 | EventRecommendation 파싱 |
+| `parseRange(node)` | 내부 | Range 객체 파싱 |
+| `extractJsonFromMarkdown(text)` | 내부 | Markdown에서 JSON 추출 |
+
+#### TrendAnalysisService
+| 메서드 | 호출자 | 설명 |
+|--------|-------|------|
+| `analyzeTrend(industry, region)` | AIRecommendationService | 트렌드 분석 수행 |
+| `callClaudeApi(industry, region)` | 내부 | Claude API 호출 (트렌드) |
+| `buildPrompt(industry, region)` | 내부 | 트렌드 분석 프롬프트 생성 |
+| `parseResponse(responseText)` | 내부 | 트렌드 응답 파싱 |
+| `extractJsonFromMarkdown(text)` | 내부 | Markdown에서 JSON 추출 |
+| `parseTrendKeywords(arrayNode)` | 내부 | TrendKeyword 리스트 파싱 |
+
+#### JobStatusService
+| 메서드 | 호출자 | 설명 |
+|--------|-------|------|
+| `getJobStatus(jobId)` | Controller | Job 상태 조회 |
+| `updateJobStatus(jobId, status, message)` | AIRecommendationService | Job 상태 업데이트 |
+| `calculateProgress(status)` | 내부 | 상태별 진행률 계산 |
+
+#### CacheService
+| 메서드 | 호출자 | 설명 |
+|--------|-------|------|
+| `set(key, value, ttlSeconds)` | 내부 | 범용 캐시 저장 |
+| `get(key)` | 내부 | 범용 캐시 조회 |
+| `delete(key)` | 외부 | 캐시 삭제 |
+| `saveJobStatus(jobId, status)` | JobStatusService | Job 상태 저장 |
+| `getJobStatus(jobId)` | JobStatusService | Job 상태 조회 |
+| `saveRecommendation(eventId, recommendation)` | AIRecommendationService | AI 추천 결과 저장 |
+| `getRecommendation(eventId)` | AIRecommendationService | AI 추천 결과 조회 |
+| `saveTrend(industry, region, trend)` | AIRecommendationService | 트렌드 분석 결과 저장 |
+| `getTrend(industry, region)` | AIRecommendationService | 트렌드 분석 결과 조회 |
+
+---
+
+### 5.3 Consumer Layer
+
+#### AIJobConsumer
+| 메서드 | 트리거 | 설명 |
+|--------|-------|------|
+| `consume(message, topic, offset, ack)` | Kafka Message | Kafka 메시지 수신 및 처리 |
+
+---
+
+### 5.4 Client Layer
+
+#### ClaudeApiClient (Feign)
+| 메서드 | 호출자 | 설명 |
+|--------|-------|------|
+| `sendMessage(apiKey, anthropicVersion, request)` | TrendAnalysisService, AIRecommendationService | Claude API 호출 |
+
+---
+
+## 6. Redis 캐시 키 구조
+
+| 키 패턴 | 설명 | TTL |
+|--------|------|-----|
+| `ai:job:status:{jobId}` | Job 상태 정보 | 24시간 (86400초) |
+| `ai:recommendation:{eventId}` | AI 추천 결과 | 24시간 (86400초) |
+| `ai:trend:{industry}:{region}` | 트렌드 분석 결과 | 1시간 (3600초) |
+
+---
+
+## 7. Claude API 호출 정보
+
+### 7.1 트렌드 분석
+- **URL**: `https://api.anthropic.com/v1/messages`
+- **Model**: `claude-sonnet-4-5-20250929`
+- **Max Tokens**: 4096
+- **Temperature**: 0.7
+- **System Prompt**: "당신은 마케팅 트렌드 분석 전문가입니다. 업종별, 지역별 트렌드를 분석하고 인사이트를 제공합니다."
+- **응답 형식**: JSON (industryTrends, regionalTrends, seasonalTrends)
+
+### 7.2 이벤트 추천안 생성
+- **URL**: `https://api.anthropic.com/v1/messages`
+- **Model**: `claude-sonnet-4-5-20250929`
+- **Max Tokens**: 4096
+- **Temperature**: 0.7
+- **System Prompt**: "당신은 소상공인을 위한 마케팅 이벤트 기획 전문가입니다. 트렌드 분석을 바탕으로 실행 가능한 이벤트 추천안을 제공합니다."
+- **응답 형식**: JSON (recommendations: 3가지 옵션)
+
+---
+
+## 8. Circuit Breaker 설정
+
+### 적용 대상
+- `claudeApi`: 모든 Claude API 호출
+
+### 설정값
+```yaml
+failure-rate-threshold: 50%
+slow-call-duration-threshold: 60초
+sliding-window-size: 10
+minimum-number-of-calls: 5
+wait-duration-in-open-state: 60초
+timeout-duration: 300초 (5분)
+```
+
+### Fallback 메서드
+- `AIServiceFallback.getDefaultTrendAnalysis()`: 기본 트렌드 분석
+- `AIServiceFallback.getDefaultRecommendations()`: 기본 추천안
+
+---
+
+## 9. 에러 처리
+
+### Exception 종류
+| Exception | HTTP Code | 발생 조건 |
+|-----------|-----------|---------|
+| `RecommendationNotFoundException` | 404 | Redis에 추천 결과 없음 |
+| `JobNotFoundException` | 404 | Redis에 Job 상태 없음 |
+| `AIServiceException` | 500 | AI 서비스 내부 오류 |
+
+### 에러 응답 예시
+```json
+{
+ "timestamp": "2025-10-30T15:30:00",
+ "status": 404,
+ "error": "Not Found",
+ "message": "추천 결과를 찾을 수 없습니다: eventId=evt-123",
+ "path": "/api/v1/ai-service/internal/recommendations/evt-123"
+}
+```
+
+---
+
+## 10. 로깅 레벨
+
+```yaml
+com.kt.ai: DEBUG
+org.springframework.kafka: INFO
+org.springframework.data.redis: INFO
+io.github.resilience4j: DEBUG
+```