AI Service Security 설정 단순화 및 워크플로우 문서 추가

- SecurityConfig CORS 설정 제거 및 단순화
- 모든 요청 허용으로 변경 (내부 API 특성 반영)
- DevTools 요청 정적 리소스 제외 처리
- AI Service 워크플로우 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
박세원 2025-10-30 16:44:23 +09:00
parent c53cbdf4f8
commit c6b33885e0
2 changed files with 403 additions and 22 deletions

View File

@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
@ -27,21 +28,19 @@ import java.util.List;
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
/**
* Security Filter Chain 설정
* - 모든 요청 허용 (내부 API)
* - CSRF 비활성화
* - Stateless 세션
*/
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
// CSRF 비활성화 (REST API는 CSRF 불필요)
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 (JWT 기반 인증)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 모든 요청 허용 (테스트용)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/internal/**").permitAll() // Internal API
.anyRequest().permitAll() .anyRequest().permitAll()
); );
@ -49,19 +48,11 @@ public class SecurityConfig {
} }
/** /**
* CORS 설정 * Chrome DevTools 요청 정적 리소스 요청을 Spring Security에서 제외
*/ */
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public WebSecurityCustomizer webSecurityCustomizer() {
CorsConfiguration configuration = new CorsConfiguration(); return (web) -> web.ignoring()
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080")); .requestMatchers("/.well-known/**");
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;
} }
} }

View File

@ -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<br/>(ai-event-generation-job topic)
%% 2. Kafka Consumer가 메시지 수신
Kafka->>Consumer: consume(AIJobMessage)
Note over Consumer: @KafkaListener<br/>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<br/>System: "트렌드 분석 전문가"<br/>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<br/>System: "이벤트 기획 전문가"<br/>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
```