Compare commits

...

27 Commits

Author SHA1 Message Date
hyeda2020
be4fcc0dc3
Merge pull request #15 from ktds-dg0501/develop
Develop
2025-10-28 13:16:23 +09:00
hyeda2020
de32a70f29
Merge branch 'main' into develop 2025-10-28 13:16:15 +09:00
kkkd-max
429f737066
Merge pull request #14 from ktds-dg0501/exec/participation
participation 실행프로파일 수정
2025-10-28 10:24:16 +09:00
Unknown
7a99dc95fe participation 실행프로파일 수정 2025-10-28 10:21:38 +09:00
Cherry Kim
d56ff7684b
Merge pull request #13 from ktds-dg0501/feature/content
Feature/content
2025-10-28 09:41:26 +09:00
cherry2250
c152faff54 Claude 폴더 원복 2025-10-28 09:40:53 +09:00
cherry2250
ee664a6134 develop 브랜치 병합 (271 파일 업데이트) 2025-10-28 09:29:26 +09:00
Hyowon Yang
50043add5d analytics 서비스 동시성 충돌 해결
[문제]
- ParticipantRegistered 이벤트 처리 시 StaleObjectStateException 발생
- 100개의 이벤트가 동시에 발행되어 EventStats 동시 업데이트 충돌
- TransactionRequiredException 발생 (트랜잭션 컨텍스트 부재)

[해결]
1. 비관적 락(Pessimistic Lock) 적용
   - EventStatsRepository에 findByEventIdWithLock 메서드 추가
   - PESSIMISTIC_WRITE 락으로 읽는 순간부터 다른 트랜잭션 차단

2. 트랜잭션 추가
   - 모든 Consumer 메서드에 @Transactional 어노테이션 추가
   - EventCreatedConsumer, ParticipantRegisteredConsumer, DistributionCompletedConsumer

3. 이벤트 발행 속도 조절
   - SampleDataLoader에서 10개마다 100ms 대기
   - 동시성 충돌 빈도 감소

[수정 파일]
- EventStatsRepository.java: 비관적 락 메서드 추가
- ParticipantRegisteredConsumer.java: @Transactional 추가, 락 메서드 사용
- DistributionCompletedConsumer.java: @Transactional 추가, 락 메서드 사용
- EventCreatedConsumer.java: @Transactional 추가
- SampleDataLoader.java: 이벤트 발행 속도 조절

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 09:16:55 +09:00
Cherry Kim
397a23063d
Merge pull request #12 from ktds-dg0501/feature/content
Feature/content
2025-10-27 17:10:48 +09:00
cherry2250
5f8bd7cf68 VM 배포를 위한 Docker 컨테이너 설정 추가
- content-service/build.gradle: bootJar 파일명 설정 추가
- deployment/container/Dockerfile-backend: 백엔드 서비스 Docker 이미지 파일
- deployment/container/docker-compose.yml: Docker Compose 설정 (환경변수 포함)
- deployment/container/build-and-run.sh: 자동화 빌드 및 배포 스크립트
- deployment/container/build-image.md: 상세 배포 가이드 문서

주요 환경변수:
- JWT_SECRET: 32자 이상 JWT 서명 키 (JWT 오류 해결)
- REDIS/KAFKA: 외부 서버 연결 정보
- REPLICATE_API_TOKEN: Stable Diffusion API 토큰
- AZURE_STORAGE_CONNECTION_STRING: Azure Blob Storage 연결

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:00:20 +09:00
SWPARK
bea547a463
Merge pull request #11 from ktds-dg0501/feature/ai
Feature/ai
2025-10-27 16:36:11 +09:00
SWPARK
c126c71e00
Merge branch 'develop' into feature/ai 2025-10-27 16:36:03 +09:00
박세원
29dddd89b7 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>
2025-10-27 16:27:14 +09:00
kkkd-max
e0fc4286c7
Merge pull request #10 from ktds-dg0501/docker/participation
Docker/participation
2025-10-27 16:17:51 +09:00
doyeon
060921e756 백엔드 컨테이너 실행 가이드 문서 추가
- deployment/container/run-container-guide-back.md 파일 생성
- VM 접속 및 ACR 로그인 방법
- 컨테이너 실행 및 관리 방법
- 문제 해결 가이드
- 헬스체크 및 모니터링 방법
- 자동화 스크립트 예시
- 서비스별 실행 예시 포함

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:17:23 +09:00
cherry2250
2da2f124a2 이미지 생성 프롬프트 개선: 음식 전문 사진 생성 및 텍스트 제외
- 음식 사진 전문성 강조 (professional food photography, appetizing food shot)
- 업종을 cuisine으로 변환하여 음식 이미지에 집중
- 스타일별 플레이팅 강조 (elegant plating, minimalist plating, trendy plating)
- negative prompt에 텍스트 관련 키워드 추가 (text, letters, words, typography, writing, numbers, characters, labels, watermark, logo, signage)
- 최종 프롬프트에 'no text overlay, text-free, clean image' 명시

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:11:31 +09:00
doyeon
b198c46d06 Analytics 서비스 및 보안 기능 업데이트
- Analytics 서비스 구현 추가 (API, 소스 코드)
- Event 서비스 소스 코드 추가
- 보안 관련 공통 컴포넌트 업데이트 (JWT, UserPrincipal, ErrorCode)
- API 컨벤션 및 명세서 업데이트
- 데이터베이스 SQL 스크립트 추가
- 백엔드 개발 문서 및 테스트 가이드 추가
- Kafka 메시지 체크 도구 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:11:00 +09:00
doyeon
003b3843cc Merge branch 'develop' into docker/participation
- 충돌 해결 완료
- settings.local.json 및 make-run-profile.md 병합

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:10:47 +09:00
hyeda2020
453f77ef01
Merge pull request #9 from ktds-dg0501/feature/user
UserPricipal 중복 필드 제거
2025-10-27 15:49:54 +09:00
wonho
375fcb390b UserPricipal 중복 필드 제거 2025-10-27 15:49:01 +09:00
hyeda2020
8323b795df
Merge pull request #8 from ktds-dg0501/feature/user
UserPricipal 충돌 부분 조치
2025-10-27 15:28:05 +09:00
hyeda2020
ce3e01008a
Merge branch 'develop' into feature/user 2025-10-27 15:27:57 +09:00
wonho
ea807cf33e UserPricipal 충돌 부분 조치 2025-10-27 15:19:35 +09:00
Hyowon Yang
394c7a0029
Merge pull request #7 from ktds-dg0501/feature/analytics
Feature/analytics
2025-10-27 15:10:51 +09:00
doyeon
e70f121db5 배포 가이드 및 명령어 추가
- 배포 관련 slash 명령어 추가 (컨테이너 이미지 빌드, 실행, K8s 배포, CI/CD)
- 백엔드/프론트엔드 각각에 대한 배포 가이드 문서 추가
- 프롬프트 파일 추가 (think, design, develop)
- deployment 디렉토리 생성
- 기존 명령어 파일 업데이트

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:03:36 +09:00
doyeon
6465719b2c SecurityConfig와 application.yml 설정 업데이트
- SecurityConfig: CORS 설정 및 보안 필터 체인 구성
- application.yml: 환경 변수 플레이스홀더 방식으로 변경

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 14:06:02 +09:00
박세원
f0699b2e2b add ai-service 2025-10-27 11:09:12 +09:00
132 changed files with 9163 additions and 168 deletions

View File

@ -0,0 +1,14 @@
---
command: "/deploy-actions-cicd-guide-back"
---
@cicd
'백엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500

View File

@ -0,0 +1,15 @@
---
command: "/deploy-actions-cicd-guide-front"
---
@cicd
'프론트엔드GitHubActions파이프라인작성가이드'에 따라 GitHub Actions를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- SYSTEM_NAME: phonebill
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500

View File

@ -0,0 +1,6 @@
---
command: "/deploy-build-image-back"
---
@cicd
'백엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.

View File

@ -0,0 +1,6 @@
---
command: "/deploy-build-image-front"
---
@cicd
'프론트엔드컨테이너이미지작성가이드'에 따라 컨테이너 이미지를 작성해 주세요.

View File

@ -0,0 +1,81 @@
---
command: "/deploy-help"
---
# 배포 작업 순서
## 1단계: 컨테이너 이미지 작성
### 백엔드
```
/deploy-build-image-back
```
- 백엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
### 프론트엔드
```
/deploy-build-image-front
```
- 프론트엔드컨테이너이미지작성가이드를 참고하여 컨테이너 이미지를 빌드합니다
## 2단계: 컨테이너 실행 가이드 작성
### 백엔드
```
/deploy-run-container-guide-back
```
- 백엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
- 실행정보(ACR명, VM정보)가 필요합니다
### 프론트엔드
```
/deploy-run-container-guide-front
```
- 프론트엔드컨테이너실행방법가이드를 참고하여 컨테이너 실행 방법을 작성합니다
- 실행정보(시스템명, ACR명, VM정보)가 필요합니다
## 3단계: Kubernetes 배포 가이드 작성
### 백엔드
```
/deploy-k8s-guide-back
```
- 백엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
- 실행정보(ACR명, k8s명, 네임스페이스, 리소스 설정)가 필요합니다
### 프론트엔드
```
/deploy-k8s-guide-front
```
- 프론트엔드배포가이드를 참고하여 쿠버네티스 배포 방법을 작성합니다
- 실행정보(시스템명, ACR명, k8s명, 네임스페이스, Gateway Host, 리소스 설정)가 필요합니다
## 4단계: CI/CD 파이프라인 구성
### Jenkins 사용 시
#### 백엔드
```
/deploy-jenkins-cicd-guide-back
```
- 백엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
#### 프론트엔드
```
/deploy-jenkins-cicd-guide-front
```
- 프론트엔드Jenkins파이프라인작성가이드를 참고하여 Jenkins CI/CD 파이프라인을 구성합니다
### GitHub Actions 사용 시
#### 백엔드
```
/deploy-actions-cicd-guide-back
```
- 백엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
#### 프론트엔드
```
/deploy-actions-cicd-guide-front
```
- 프론트엔드GitHubActions파이프라인작성가이드를 참고하여 GitHub Actions CI/CD 파이프라인을 구성합니다
## 참고사항
- 각 명령 실행 전 필요한 실행정보를 프롬프트에 포함해야 합니다
- 실행정보가 없으면 안내 메시지가 표시되며 작업이 중단됩니다
- CI/CD 도구는 Jenkins 또는 GitHub Actions 중 선택하여 사용합니다

View File

@ -0,0 +1,14 @@
---
command: "/deploy-jenkins-cicd-guide-back"
---
@cicd
'백엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500

View File

@ -0,0 +1,15 @@
---
command: "/deploy-jenkins-cicd-guide-front"
---
@cicd
'프론트엔드Jenkins파이프라인작성가이드'에 따라 Jenkins를 이용한 CI/CD 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- SYSTEM_NAME: phonebill
- ACR_NAME: acrdigitalgarage01
- RESOURCE_GROUP: rg-digitalgarage-01
- AKS_CLUSTER: aks-digitalgarage-01
- NAMESPACE: phonebill-dg0500

View File

@ -0,0 +1,16 @@
---
command: "/deploy-k8s-guide-back"
---
@cicd
'백엔드배포가이드'에 따라 백엔드 서비스 배포 방법을 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR명: acrdigitalgarage01
- k8s명: aks-digitalgarage-01
- 네임스페이스: tripgen
- 파드수: 2
- 리소스(CPU): 256m/1024m
- 리소스(메모리): 256Mi/1024Mi

View File

@ -0,0 +1,18 @@
---
command: "/deploy-k8s-guide-front"
---
@cicd
'프론트엔드배포가이드'에 따라 프론트엔드 서비스 배포 방법을 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- 시스템명: tripgen
- ACR명: acrdigitalgarage01
- k8s명: aks-digitalgarage-01
- 네임스페이스: tripgen
- 파드수: 2
- 리소스(CPU): 256m/1024m
- 리소스(메모리): 256Mi/1024Mi
- Gateway Host: http://tripgen-api.20.214.196.128.nip.io

View File

@ -0,0 +1,15 @@
---
command: "/deploy-run-container-guide-back"
---
@cicd
'백엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- ACR명: acrdigitalgarage01
- VM
- KEY파일: ~/home/bastion-dg0500
- USERID: azureuser
- IP: 4.230.5.6

View File

@ -0,0 +1,16 @@
---
command: "/deploy-run-container-guide-front"
---
@cicd
'프론트엔드컨테이너실행방법가이드'에 따라 컨테이너 실행 가이드를 작성해 주세요.
프롬프트에 '[실행정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[실행정보]'섹션 하위에 아래 예와 같이 필요한 정보를 제시해 주세요.
[실행정보]
- 시스템명: tripgen
- ACR명: acrdigitalgarage01
- VM
- KEY파일: ~/home/bastion-dg0500
- USERID: azureuser
- IP: 4.230.5.6

View File

@ -1,3 +1,6 @@
---
command: "/design-api"
---
@architecture @architecture
API를 설계해 주세요: API를 설계해 주세요:
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계 - '공통설계원칙'과 'API설계가이드'를 준용하여 설계

View File

@ -1,3 +1,6 @@
---
command: "/design-class"
---
@architecture @architecture
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요. '공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. 프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@ -9,4 +12,4 @@
- User: Layered - User: Layered
- Trip: Clean - Trip: Clean
- Location: Layered - Location: Layered
- AI: Layered - AI: Layered

View File

@ -1,3 +1,6 @@
---
command: "/design-data"
---
@architecture @architecture
데이터 설계를 해주세요: 데이터 설계를 해주세요:
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계 - '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계

View File

@ -1,5 +1,8 @@
---
command: "/design-fix-prototype"
---
@fix as @front @fix as @front
'[오류내용]'섹션에 제공된 오류를 해결해 주세요. '[오류내용]'섹션에 제공된 오류를 해결해 주세요.
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시 프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
{안내메시지} {안내메시지}
'[오류내용]'섹션 하위에 오류 내용을 제공 '[오류내용]'섹션 하위에 오류 내용을 제공

View File

@ -1,3 +1,6 @@
---
command: "/design-front"
---
@plan as @front @plan as @front
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요. '프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다. 프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@ -13,4 +16,4 @@
- ai service: http://localhost:8084/v3/api-docs - ai service: http://localhost:8084/v3/api-docs
[요구사항] [요구사항]
- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시 - 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기 - 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기

View File

@ -1,6 +1,9 @@
---
command: "/design-high-level"
---
@architecture @architecture
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요. 'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. 'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
{안내메시지} {안내메시지}
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요. 아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
- CLOUD: Azure - CLOUD: Azure

View File

@ -1,5 +1,8 @@
---
command: "/design-improve-prototype"
---
@improve as @front @improve as @front
'[개선내용]'섹션에 있는 내용을 개선해 주세요. '[개선내용]'섹션에 있는 내용을 개선해 주세요.
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시 프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
{안내메시지} {안내메시지}
'[개선내용]'섹션 하위에 개선할 내용을 제공 '[개선내용]'섹션 하위에 개선할 내용을 제공

View File

@ -1,2 +1,5 @@
---
command: "/design-improve-userstory"
---
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후, @analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오. @document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.

View File

@ -1,3 +1,6 @@
---
command: "/design-logical"
---
@architecture @architecture
논리 아키텍처를 설계해 주세요: 논리 아키텍처를 설계해 주세요:
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계 - '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계

View File

@ -1,3 +1,6 @@
---
command: "/design-pattern"
---
@design-pattern @design-pattern
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요: 클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성 - '클라우드아키텍처패턴선정가이드'를 준용하여 작성

View File

@ -1,6 +1,9 @@
---
command: "/design-physical"
---
@architecture @architecture
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요. '물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요. 'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
{안내메시지} {안내메시지}
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요. 아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
- CLOUD: Azure - CLOUD: Azure

View File

@ -1,3 +1,6 @@
---
command: "/design-prototype"
---
@prototype @prototype
프로토타입을 작성해 주세요: 프로토타입을 작성해 주세요:
- '프로토타입작성가이드'를 준용하여 작성 - '프로토타입작성가이드'를 준용하여 작성

View File

@ -1,3 +1,6 @@
---
command: "/design-seq-inner"
---
@architecture @architecture
내부 시퀀스 설계를 해 주세요: 내부 시퀀스 설계를 해 주세요:
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계 - '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계

View File

@ -1,3 +1,6 @@
---
command: "/design-seq-outer"
---
@architecture @architecture
외부 시퀀스 설계를 해 주세요: 외부 시퀀스 설계를 해 주세요:
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계 - '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계

View File

@ -1,2 +1,5 @@
---
command: "/design-test-prototype"
---
@test-front @test-front
프로토타입을 테스트 해 주세요. 프로토타입을 테스트 해 주세요.

View File

@ -1,3 +1,6 @@
---
command: "/design-uiux"
---
@uiux @uiux
UI/UX 설계를 해주세요: UI/UX 설계를 해주세요:
- 'UI/UX설계가이드'를 준용하여 작성 - 'UI/UX설계가이드'를 준용하여 작성

View File

@ -1,2 +1,5 @@
---
command: "/design-update-uiux"
---
@document @front @document @front
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요. 현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.

View File

@ -1,3 +1,6 @@
---
command: "/think-help"
---
기획 작업 순서 기획 작업 순서
1단계: 서비스 기획 1단계: 서비스 기획

View File

@ -1,3 +1,6 @@
---
command: "/think-planning"
---
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다. 아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
``` ```
아래 가이드를 참고하여 서비스 기획을 수행합니다. 아래 가이드를 참고하여 서비스 기획을 수행합니다.

View File

@ -1,3 +1,7 @@
---
command: "/think-userstory"
---
```
@document @document
유저스토리를 작성하세요. 유저스토리를 작성하세요.
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다. 프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
@ -16,3 +20,5 @@ Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을
2. 유저스토리 작성 2. 유저스토리 작성
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성 - '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
- 결과파일은 'design/userstory.md'에 생성 - 결과파일은 'design/userstory.md'에 생성
```

8
.gitignore vendored
View File

@ -8,6 +8,7 @@ yarn-error.log*
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
.run/
*.swp *.swp
*.swo *.swo
*~ *~
@ -31,6 +32,13 @@ logs/
logs/ logs/
*.log *.log
# Gradle
.gradle/
gradle-app.setting
!gradle-wrapper.jar
!gradle-wrapper.properties
.gradletasknamecache
# Environment # Environment
.env .env
.env.local .env.local

View File

@ -1,27 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="EventServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" folderName="Event Service">
<option name="ACTIVE_PROFILES" />
<option name="ENABLE_LAUNCH_OPTIMIZATION" value="true" />
<envs>
<env name="DB_HOST" value="20.249.177.232" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="eventdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="localhost" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="localhost:9092" />
<env name="SERVER_PORT" value="8081" />
<env name="DDL_AUTO" value="update" />
<env name="LOG_LEVEL" value="DEBUG" />
<env name="SQL_LOG_LEVEL" value="DEBUG" />
<env name="DISTRIBUTION_SERVICE_URL" value="http://localhost:8084" />
</envs>
<module name="kt-event-marketing.event-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.event.eventservice.EventServiceApplication" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -43,7 +43,7 @@
</option> </option>
<option name="taskNames"> <option name="taskNames">
<list> <list>
<option value="participation-service:bootRun" /> <option value=":participation-service:bootRun" />
</list> </list>
</option> </option>
<option name="vmOptions" /> <option name="vmOptions" />

View File

@ -1,89 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="analytics-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Settings -->
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="4.230.49.9" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="analyticdb" />
<entry key="DB_USERNAME" value="eventuser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- Redis Settings -->
<entry key="REDIS_HOST" value="20.214.210.71" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="5" />
<!-- Kafka Settings -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service" />
<!-- Sample Data Settings (MVP Only) -->
<!-- ⚠️ 실제 운영 환경에서는 false로 설정 (다른 서비스들이 이벤트 발행) -->
<entry key="SAMPLE_DATA_ENABLED" value="true" />
<!-- JPA Settings -->
<entry key="SHOW_SQL" value="true" />
<entry key="DDL_AUTO" value="update" />
<!-- Server Settings -->
<entry key="SERVER_PORT" value="8086" />
<!-- JWT Settings -->
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-analytics-service-2024" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Settings -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<!-- Logging Settings -->
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
<entry key="LOG_FILE" value="logs/analytics-service.log" />
<!-- Batch Settings -->
<entry key="BATCH_ENABLED" value="true" />
<entry key="BATCH_REFRESH_INTERVAL" value="300000" />
<entry key="BATCH_INITIAL_DELAY" value="30000" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="analytics-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@ -2,8 +2,8 @@ dependencies {
// Kafka Consumer // Kafka Consumer
implementation 'org.springframework.kafka:spring-kafka' implementation 'org.springframework.kafka:spring-kafka'
// Redis for result caching // Redis for result caching (already in root build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// OpenFeign for Claude/GPT API // OpenFeign for Claude/GPT API
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
@ -14,4 +14,20 @@ dependencies {
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind' 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'
} }

View File

@ -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);
}
}

View File

@ -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> T executeWithCircuitBreaker(
String circuitBreakerName,
Supplier<T> supplier,
Supplier<T> 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> T executeWithCircuitBreaker(String circuitBreakerName, Supplier<T> supplier) {
return executeWithCircuitBreaker(circuitBreakerName, supplier, null);
}
/**
* Circuit Breaker 상태 조회
*/
public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
return circuitBreaker.getState();
}
}

View File

@ -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<TrendAnalysis.TrendKeyword> industryTrends = List.of(
TrendAnalysis.TrendKeyword.builder()
.keyword("고객 만족도 향상")
.relevance(0.8)
.description(industry + " 업종에서 고객 만족도가 중요한 트렌드입니다")
.build(),
TrendAnalysis.TrendKeyword.builder()
.keyword("디지털 마케팅")
.relevance(0.75)
.description("SNS 및 온라인 마케팅이 효과적입니다")
.build()
);
List<TrendAnalysis.TrendKeyword> regionalTrends = List.of(
TrendAnalysis.TrendKeyword.builder()
.keyword("지역 커뮤니티")
.relevance(0.7)
.description(region + " 지역 커뮤니티 참여가 효과적입니다")
.build()
);
List<TrendAnalysis.TrendKeyword> seasonalTrends = List.of(
TrendAnalysis.TrendKeyword.builder()
.keyword("시즌 이벤트")
.relevance(0.85)
.description("계절 특성을 반영한 이벤트가 효과적입니다")
.build()
);
return TrendAnalysis.builder()
.industryTrends(industryTrends)
.regionalTrends(regionalTrends)
.seasonalTrends(seasonalTrends)
.build();
}
/**
* 기본 이벤트 추천안 반환
*/
public List<EventRecommendation> getDefaultRecommendations(String objective, String industry) {
log.info("Fallback: 기본 이벤트 추천안 반환 - objective={}, industry={}", objective, industry);
List<EventRecommendation> 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();
}
}

View File

@ -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
);
}

View File

@ -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회)
);
}
}

View File

@ -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<Message> 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;
}
}

View File

@ -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> 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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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<String, AIJobMessage> consumerFactory() {
Map<String, Object> 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<String, AIJobMessage> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
return factory;
}
}

View File

@ -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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> 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;
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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<String, Object> redisTemplate;
/**
* 서비스 헬스체크
*/
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
@GetMapping("/api/v1/ai-service/health")
public ResponseEntity<HealthCheckResponse> 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;
}
}
}

View File

@ -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<JobStatusResponse> 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<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);
}
}

View File

@ -0,0 +1,264 @@
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
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
@RestController
@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 추천 결과 조회
*/
@Operation(summary = "AI 추천 결과 조회", description = "Redis에 캐시된 AI 추천 결과 조회")
@GetMapping("/{eventId}")
public ResponseEntity<AIRecommendationResult> getRecommendation(@PathVariable String eventId) {
log.info("AI 추천 결과 조회 요청: eventId={}", eventId);
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);
}
}

View File

@ -0,0 +1,25 @@
package com.kt.ai.exception;
/**
* AI Service 공통 예외
*
* @author AI Service Team
* @since 1.0.0
*/
public class AIServiceException extends RuntimeException {
private final String errorCode;
public AIServiceException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public AIServiceException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@ -0,0 +1,13 @@
package com.kt.ai.exception;
/**
* Circuit Breaker가 열린 상태 예외
*
* @author AI Service Team
* @since 1.0.0
*/
public class CircuitBreakerOpenException extends AIServiceException {
public CircuitBreakerOpenException(String apiName) {
super("CIRCUIT_BREAKER_OPEN", "Circuit Breaker가 열린 상태입니다: " + apiName);
}
}

View File

@ -0,0 +1,131 @@
package com.kt.ai.exception;
import com.kt.ai.model.dto.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
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;
import java.util.Map;
/**
* 전역 예외 처리 핸들러
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* Job을 찾을 없는 예외 처리
*/
@ExceptionHandler(JobNotFoundException.class)
public ResponseEntity<ErrorResponse> handleJobNotFoundException(JobNotFoundException ex) {
log.error("Job not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.code(ex.getErrorCode())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
/**
* 추천 결과를 찾을 없는 예외 처리
*/
@ExceptionHandler(RecommendationNotFoundException.class)
public ResponseEntity<ErrorResponse> handleRecommendationNotFoundException(RecommendationNotFoundException ex) {
log.error("Recommendation not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.code(ex.getErrorCode())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
/**
* Circuit Breaker가 열린 상태 예외 처리
*/
@ExceptionHandler(CircuitBreakerOpenException.class)
public ResponseEntity<ErrorResponse> handleCircuitBreakerOpenException(CircuitBreakerOpenException ex) {
log.error("Circuit breaker open: {}", ex.getMessage());
Map<String, Object> details = new HashMap<>();
details.put("message", "외부 AI API가 일시적으로 사용 불가능합니다. 잠시 후 다시 시도해주세요.");
ErrorResponse error = ErrorResponse.builder()
.code(ex.getErrorCode())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.details(details)
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error);
}
/**
* AI Service 공통 예외 처리
*/
@ExceptionHandler(AIServiceException.class)
public ResponseEntity<ErrorResponse> handleAIServiceException(AIServiceException ex) {
log.error("AI Service error: {}", ex.getMessage(), ex);
ErrorResponse error = ErrorResponse.builder()
.code(ex.getErrorCode())
.message(ex.getMessage())
.timestamp(LocalDateTime.now())
.build();
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);
}
/**
* 일반 예외 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
log.error("Unexpected error: {}", ex.getMessage(), ex);
ErrorResponse error = ErrorResponse.builder()
.code("INTERNAL_ERROR")
.message("서버 내부 오류가 발생했습니다")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}

View File

@ -0,0 +1,13 @@
package com.kt.ai.exception;
/**
* Job을 찾을 없는 예외
*
* @author AI Service Team
* @since 1.0.0
*/
public class JobNotFoundException extends AIServiceException {
public JobNotFoundException(String jobId) {
super("JOB_NOT_FOUND", "작업을 찾을 수 없습니다: " + jobId);
}
}

View File

@ -0,0 +1,13 @@
package com.kt.ai.exception;
/**
* 추천 결과를 찾을 없는 예외
*
* @author AI Service Team
* @since 1.0.0
*/
public class RecommendationNotFoundException extends AIServiceException {
public RecommendationNotFoundException(String eventId) {
super("RECOMMENDATION_NOT_FOUND", "추천 결과를 찾을 수 없습니다: " + eventId);
}
}

View File

@ -0,0 +1,60 @@
package com.kt.ai.kafka.consumer;
import com.kt.ai.kafka.message.AIJobMessage;
import com.kt.ai.service.AIRecommendationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
/**
* AI Job Kafka Consumer
* - Topic: ai-event-generation-job
* - Consumer Group: ai-service-consumers
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AIJobConsumer {
private final AIRecommendationService aiRecommendationService;
/**
* Kafka 메시지 수신 처리
*/
@KafkaListener(
topics = "${kafka.topics.ai-job}",
groupId = "${spring.kafka.consumer.group-id}",
containerFactory = "kafkaListenerContainerFactory"
)
public void consume(
@Payload AIJobMessage message,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
@Header(KafkaHeaders.OFFSET) Long offset,
Acknowledgment acknowledgment
) {
try {
log.info("Kafka 메시지 수신: topic={}, offset={}, jobId={}, eventId={}",
topic, offset, message.getJobId(), message.getEventId());
// AI 추천 생성
aiRecommendationService.generateRecommendations(message);
// Manual ACK
acknowledgment.acknowledge();
log.info("Kafka 메시지 처리 완료: jobId={}", message.getJobId());
} catch (Exception e) {
log.error("Kafka 메시지 처리 실패: jobId={}", message.getJobId(), e);
// DLQ로 이동하거나 재시도 로직 추가 가능
acknowledgment.acknowledge(); // 실패한 메시지도 ACK (DLQ로 이동)
}
}
}

View File

@ -0,0 +1,71 @@
package com.kt.ai.kafka.message;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* AI 이벤트 생성 요청 메시지 (Kafka)
* Topic: ai-event-generation-job
* Consumer Group: ai-service-consumers
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AIJobMessage {
/**
* Job 고유 ID
*/
private String jobId;
/**
* 이벤트 ID (Event Service에서 생성)
*/
private String eventId;
/**
* 이벤트 목적
* - "신규 고객 유치"
* - "재방문 유도"
* - "매출 증대"
* - "브랜드 인지도 향상"
*/
private String objective;
/**
* 업종
*/
private String industry;
/**
* 지역 (//)
*/
private String region;
/**
* 매장명 (선택)
*/
private String storeName;
/**
* 목표 고객층 (선택)
*/
private String targetAudience;
/**
* 예산 () (선택)
*/
private Integer budget;
/**
* 요청 시각
*/
private LocalDateTime requestedAt;
}

View File

@ -0,0 +1,54 @@
package com.kt.ai.model.dto.response;
import com.kt.ai.model.enums.AIProvider;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* AI 이벤트 추천 결과 DTO
* Redis Key: ai:recommendation:{eventId}
* TTL: 86400초 (24시간)
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AIRecommendationResult {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 트렌드 분석 결과
*/
private TrendAnalysis trendAnalysis;
/**
* 추천 이벤트 기획안 (3개)
*/
private List<EventRecommendation> recommendations;
/**
* 생성 시각
*/
private LocalDateTime generatedAt;
/**
* 캐시 만료 시각 (생성 시각 + 24시간)
*/
private LocalDateTime expiresAt;
/**
* 사용된 AI 제공자
*/
private AIProvider aiProvider;
}

View File

@ -0,0 +1,41 @@
package com.kt.ai.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 에러 응답 DTO
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
/**
* 에러 코드
*/
private String code;
/**
* 에러 메시지
*/
private String message;
/**
* 에러 발생 시각
*/
private LocalDateTime timestamp;
/**
* 추가 에러 상세
*/
private Map<String, Object> details;
}

View File

@ -0,0 +1,139 @@
package com.kt.ai.model.dto.response;
import com.kt.ai.model.enums.EventMechanicsType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 이벤트 추천안 DTO
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventRecommendation {
/**
* 옵션 번호 (1-3)
*/
private Integer optionNumber;
/**
* 이벤트 컨셉
*/
private String concept;
/**
* 이벤트 제목
*/
private String title;
/**
* 이벤트 설명
*/
private String description;
/**
* 목표 고객층
*/
private String targetAudience;
/**
* 이벤트 기간
*/
private Duration duration;
/**
* 이벤트 메커니즘
*/
private Mechanics mechanics;
/**
* 추천 홍보 채널 (최대 5개)
*/
private List<String> promotionChannels;
/**
* 예상 비용
*/
private EstimatedCost estimatedCost;
/**
* 예상 성과 지표
*/
private ExpectedMetrics expectedMetrics;
/**
* 다른 옵션과의 차별점
*/
private String differentiator;
/**
* 이벤트 기간
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Duration {
/**
* 권장 진행 일수
*/
private Integer recommendedDays;
/**
* 권장 진행 시기
*/
private String recommendedPeriod;
}
/**
* 이벤트 메커니즘
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Mechanics {
/**
* 이벤트 유형
*/
private EventMechanicsType type;
/**
* 상세 메커니즘
*/
private String details;
}
/**
* 예상 비용
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class EstimatedCost {
/**
* 최소 비용 ()
*/
private Integer min;
/**
* 최대 비용 ()
*/
private Integer max;
/**
* 비용 구성
*/
private Map<String, Integer> breakdown;
}
}

View File

@ -0,0 +1,74 @@
package com.kt.ai.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 예상 성과 지표 DTO
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExpectedMetrics {
/**
* 신규 고객
*/
private Range newCustomers;
/**
* 재방문 고객 (선택)
*/
private Range repeatVisits;
/**
* 매출 증가율 (%)
*/
private Range revenueIncrease;
/**
* ROI - 투자 대비 수익률 (%)
*/
private Range roi;
/**
* SNS 참여도 (선택)
*/
private SocialEngagement socialEngagement;
/**
* 범위 (최소-최대)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Range {
private Double min;
private Double max;
}
/**
* SNS 참여도
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SocialEngagement {
/**
* 예상 게시물
*/
private Integer estimatedPosts;
/**
* 예상 도달
*/
private Integer estimatedReach;
}
}

View File

@ -0,0 +1,72 @@
package com.kt.ai.model.dto.response;
import com.kt.ai.model.enums.CircuitBreakerState;
import com.kt.ai.model.enums.ServiceStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 서비스 헬스체크 응답 DTO
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HealthCheckResponse {
/**
* 전체 서비스 상태
*/
private ServiceStatus status;
/**
* 체크 시각
*/
private LocalDateTime timestamp;
/**
* 개별 서비스 상태
*/
private Services services;
/**
* 개별 서비스 상태 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Services {
/**
* Kafka 연결 상태
*/
private ServiceStatus kafka;
/**
* Redis 연결 상태
*/
private ServiceStatus redis;
/**
* Claude API 상태
*/
private ServiceStatus claudeApi;
/**
* GPT-4 API 상태 (선택)
*/
private ServiceStatus gpt4Api;
/**
* Circuit Breaker 상태
*/
private CircuitBreakerState circuitBreaker;
}
}

View File

@ -0,0 +1,83 @@
package com.kt.ai.model.dto.response;
import com.kt.ai.model.enums.JobStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 작업 상태 응답 DTO
* Redis Key: ai:job:status:{jobId}
* TTL: 86400초 (24시간)
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobStatusResponse {
/**
* Job ID
*/
private String jobId;
/**
* 작업 상태
*/
private JobStatus status;
/**
* 진행률 (0-100)
*/
private Integer progress;
/**
* 상태 메시지
*/
private String message;
/**
* 이벤트 ID
*/
private String eventId;
/**
* 작업 생성 시각
*/
private LocalDateTime createdAt;
/**
* 작업 시작 시각
*/
private LocalDateTime startedAt;
/**
* 작업 완료 시각 (완료 )
*/
private LocalDateTime completedAt;
/**
* 작업 실패 시각 (실패 )
*/
private LocalDateTime failedAt;
/**
* 에러 메시지 (실패 )
*/
private String errorMessage;
/**
* 재시도 횟수
*/
private Integer retryCount;
/**
* 처리 시간 (밀리초)
*/
private Long processingTimeMs;
}

View File

@ -0,0 +1,59 @@
package com.kt.ai.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 트렌드 분석 결과 DTO
*
* @author AI Service Team
* @since 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TrendAnalysis {
/**
* 업종 트렌드 키워드 (최대 5개)
*/
private List<TrendKeyword> industryTrends;
/**
* 지역 트렌드 키워드 (최대 5개)
*/
private List<TrendKeyword> regionalTrends;
/**
* 시즌 트렌드 키워드 (최대 5개)
*/
private List<TrendKeyword> seasonalTrends;
/**
* 트렌드 키워드 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TrendKeyword {
/**
* 트렌드 키워드
*/
private String keyword;
/**
* 연관도 (0-1)
*/
private Double relevance;
/**
* 트렌드 설명
*/
private String description;
}
}

View File

@ -0,0 +1,19 @@
package com.kt.ai.model.enums;
/**
* AI 제공자 타입
*
* @author AI Service Team
* @since 1.0.0
*/
public enum AIProvider {
/**
* Claude API (Anthropic)
*/
CLAUDE,
/**
* GPT-4 API (OpenAI)
*/
GPT4
}

View File

@ -0,0 +1,24 @@
package com.kt.ai.model.enums;
/**
* Circuit Breaker 상태
*
* @author AI Service Team
* @since 1.0.0
*/
public enum CircuitBreakerState {
/**
* 닫힘 - 정상 동작
*/
CLOSED,
/**
* 열림 - 장애 발생, 요청 차단
*/
OPEN,
/**
* 반열림 - 복구 시도
*/
HALF_OPEN
}

View File

@ -0,0 +1,39 @@
package com.kt.ai.model.enums;
/**
* 이벤트 메커니즘 타입
*
* @author AI Service Team
* @since 1.0.0
*/
public enum EventMechanicsType {
/**
* 할인형 이벤트
*/
DISCOUNT,
/**
* 경품 증정형 이벤트
*/
GIFT,
/**
* 스탬프 적립형 이벤트
*/
STAMP,
/**
* 체험형 이벤트
*/
EXPERIENCE,
/**
* 추첨형 이벤트
*/
LOTTERY,
/**
* 묶음 구매형 이벤트
*/
COMBO
}

View File

@ -0,0 +1,29 @@
package com.kt.ai.model.enums;
/**
* AI 추천 작업 상태
*
* @author AI Service Team
* @since 1.0.0
*/
public enum JobStatus {
/**
* 대기 - Kafka 메시지 수신 처리 대기
*/
PENDING,
/**
* 처리 - AI API 호출 분석 진행
*/
PROCESSING,
/**
* 완료 - AI 추천 결과 생성 완료
*/
COMPLETED,
/**
* 실패 - AI API 호출 실패 또는 타임아웃
*/
FAILED
}

View File

@ -0,0 +1,29 @@
package com.kt.ai.model.enums;
/**
* 서비스 상태
*
* @author AI Service Team
* @since 1.0.0
*/
public enum ServiceStatus {
/**
* 정상 동작
*/
UP,
/**
* 서비스 중단
*/
DOWN,
/**
* 성능 저하
*/
DEGRADED,
/**
* 상태 없음 (설정되지 않음)
*/
UNKNOWN
}

View File

@ -0,0 +1,418 @@
package com.kt.ai.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.ai.circuitbreaker.CircuitBreakerManager;
import com.kt.ai.circuitbreaker.fallback.AIServiceFallback;
import com.kt.ai.client.ClaudeApiClient;
import com.kt.ai.client.dto.ClaudeRequest;
import com.kt.ai.client.dto.ClaudeResponse;
import com.kt.ai.exception.RecommendationNotFoundException;
import com.kt.ai.kafka.message.AIJobMessage;
import com.kt.ai.model.dto.response.AIRecommendationResult;
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.AIProvider;
import com.kt.ai.model.enums.EventMechanicsType;
import com.kt.ai.model.enums.JobStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* AI 추천 서비스
* - 트렌드 분석 이벤트 추천 총괄
* - Claude API 연동
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AIRecommendationService {
private final CacheService cacheService;
private final JobStatusService jobStatusService;
private final TrendAnalysisService trendAnalysisService;
private final ClaudeApiClient claudeApiClient;
private final CircuitBreakerManager circuitBreakerManager;
private final AIServiceFallback fallback;
private final ObjectMapper objectMapper;
@Value("${ai.provider:CLAUDE}")
private String aiProvider;
@Value("${ai.claude.api-key}")
private String apiKey;
@Value("${ai.claude.anthropic-version}")
private String anthropicVersion;
@Value("${ai.claude.model}")
private String model;
@Value("${ai.claude.max-tokens}")
private Integer maxTokens;
@Value("${ai.claude.temperature}")
private Double temperature;
/**
* AI 추천 결과 조회
*/
public AIRecommendationResult getRecommendation(String eventId) {
Object cached = cacheService.getRecommendation(eventId);
if (cached == null) {
throw new RecommendationNotFoundException(eventId);
}
return objectMapper.convertValue(cached, AIRecommendationResult.class);
}
/**
* AI 추천 생성 (Kafka Consumer에서 호출)
*/
public void generateRecommendations(AIJobMessage message) {
try {
log.info("AI 추천 생성 시작: jobId={}, eventId={}", message.getJobId(), message.getEventId());
// Job 상태 업데이트: PROCESSING
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "트렌드 분석 중 (10%)");
// 1. 트렌드 분석
TrendAnalysis trendAnalysis = analyzeTrend(message);
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "이벤트 추천안 생성 중 (50%)");
// 2. 이벤트 추천안 생성
List<EventRecommendation> recommendations = createRecommendations(message, trendAnalysis);
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.PROCESSING, "결과 저장 중 (90%)");
// 3. 결과 생성 저장
AIRecommendationResult result = AIRecommendationResult.builder()
.eventId(message.getEventId())
.trendAnalysis(trendAnalysis)
.recommendations(recommendations)
.generatedAt(LocalDateTime.now())
.expiresAt(LocalDateTime.now().plusDays(1))
.aiProvider(AIProvider.valueOf(aiProvider))
.build();
// 결과 캐싱
cacheService.saveRecommendation(message.getEventId(), result);
// Job 상태 업데이트: COMPLETED
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.COMPLETED, "AI 추천 완료");
log.info("AI 추천 생성 완료: jobId={}, eventId={}", message.getJobId(), message.getEventId());
} catch (Exception e) {
log.error("AI 추천 생성 실패: jobId={}", message.getJobId(), e);
jobStatusService.updateJobStatus(message.getJobId(), JobStatus.FAILED, "AI 추천 실패: " + e.getMessage());
}
}
/**
* 트렌드 분석
*/
private TrendAnalysis analyzeTrend(AIJobMessage message) {
String industry = message.getIndustry();
String region = message.getRegion();
// 캐시 확인
Object cached = cacheService.getTrend(industry, region);
if (cached != null) {
log.info("트렌드 분석 캐시 히트 - industry={}, region={}", industry, region);
return objectMapper.convertValue(cached, TrendAnalysis.class);
}
// TrendAnalysisService를 통한 실제 분석
log.info("트렌드 분석 시작 - industry={}, region={}", industry, region);
TrendAnalysis analysis = trendAnalysisService.analyzeTrend(industry, region);
// 캐시 저장
cacheService.saveTrend(industry, region, analysis);
return analysis;
}
/**
* 이벤트 추천안 생성
*/
private List<EventRecommendation> createRecommendations(AIJobMessage message, TrendAnalysis trendAnalysis) {
log.info("이벤트 추천안 생성 시작 - eventId={}", message.getEventId());
return circuitBreakerManager.executeWithCircuitBreaker(
"claudeApi",
() -> callClaudeApiForRecommendations(message, trendAnalysis),
() -> fallback.getDefaultRecommendations(message.getObjective(), message.getIndustry())
);
}
/**
* Claude API를 통한 추천안 생성
*/
private List<EventRecommendation> callClaudeApiForRecommendations(AIJobMessage message, TrendAnalysis trendAnalysis) {
// 프롬프트 생성
String prompt = buildRecommendationPrompt(message, trendAnalysis);
// Claude API 요청 생성
ClaudeRequest request = ClaudeRequest.builder()
.model(model)
.messages(List.of(
ClaudeRequest.Message.builder()
.role("user")
.content(prompt)
.build()
))
.maxTokens(maxTokens)
.temperature(temperature)
.system("당신은 소상공인을 위한 마케팅 이벤트 기획 전문가입니다. 트렌드 분석을 바탕으로 실행 가능한 이벤트 추천안을 제공합니다.")
.build();
// API 호출
log.debug("Claude API 호출 (추천안 생성) - model={}", model);
ClaudeResponse response = claudeApiClient.sendMessage(
apiKey,
anthropicVersion,
request
);
// 응답 파싱
String responseText = response.extractText();
log.debug("Claude API 응답 수신 (추천안) - length={}", responseText.length());
return parseRecommendationResponse(responseText);
}
/**
* 추천안 프롬프트 생성
*/
private String buildRecommendationPrompt(AIJobMessage message, TrendAnalysis trendAnalysis) {
StringBuilder trendSummary = new StringBuilder();
trendSummary.append("**업종 트렌드:**\n");
trendAnalysis.getIndustryTrends().forEach(trend ->
trendSummary.append(String.format("- %s (연관도: %.2f): %s\n",
trend.getKeyword(), trend.getRelevance(), trend.getDescription()))
);
trendSummary.append("\n**지역 트렌드:**\n");
trendAnalysis.getRegionalTrends().forEach(trend ->
trendSummary.append(String.format("- %s (연관도: %.2f): %s\n",
trend.getKeyword(), trend.getRelevance(), trend.getDescription()))
);
trendSummary.append("\n**계절 트렌드:**\n");
trendAnalysis.getSeasonalTrends().forEach(trend ->
trendSummary.append(String.format("- %s (연관도: %.2f): %s\n",
trend.getKeyword(), trend.getRelevance(), trend.getDescription()))
);
return String.format("""
# 이벤트 추천안 생성 요청
## 고객 정보
- 매장명: %s
- 업종: %s
- 지역: %s
- 목표: %s
- 타겟 고객: %s
- 예산: %,d원
## 트렌드 분석 결과
%s
## 요구사항
트렌드 분석을 바탕으로 **3가지 이벤트 추천안** 생성해주세요:
1. **저비용 옵션** (100,000 ~ 200,000원): SNS/온라인 중심
2. **중비용 옵션** (300,000 ~ 500,000원): /오프라인 결합
3. **고비용 옵션** (500,000 ~ 1,000,000원): 프리미엄 경험 제공
## 응답 형식
응답은 반드시 다음 JSON 형식으로 작성해주세요:
```json
{
"recommendations": [
{
"optionNumber": 1,
"concept": "이벤트 컨셉 (10자 이내)",
"title": "이벤트 제목 (20자 이내)",
"description": "이벤트 상세 설명 (3-5문장)",
"targetAudience": "타겟 고객층",
"duration": {
"recommendedDays": 14,
"recommendedPeriod": "2주"
},
"mechanics": {
"type": "DISCOUNT",
"details": "이벤트 참여 방법 및 혜택 상세"
},
"promotionChannels": ["채널1", "채널2", "채널3"],
"estimatedCost": {
"min": 100000,
"max": 200000,
"breakdown": {
"경품비": 50000,
"홍보비": 50000
}
},
"expectedMetrics": {
"newCustomers": { "min": 30.0, "max": 50.0 },
"revenueIncrease": { "min": 10.0, "max": 20.0 },
"roi": { "min": 100.0, "max": 150.0 }
},
"differentiator": "차별화 포인트 (2-3문장)"
}
]
}
```
## mechanics.type
- DISCOUNT: 할인
- GIFT: 경품/사은품
- STAMP: 스탬프 적립
- EXPERIENCE: 체험형 이벤트
- LOTTERY: 추첨 이벤트
- COMBO: 결합 혜택
## 주의사항
- 옵션은 예산 범위 내에서 실행 가능해야
- 트렌드 분석 결과를 반영한 구체적인 기획
- 타겟 고객과 지역 특성을 고려
- expectedMetrics는 백분율(%% 표기)
- promotionChannels는 실제 활용 가능한 채널로 제시
""",
message.getStoreName(),
message.getIndustry(),
message.getRegion(),
message.getObjective(),
message.getTargetAudience(),
message.getBudget(),
trendSummary.toString()
);
}
/**
* 추천안 응답 파싱
*/
private List<EventRecommendation> parseRecommendationResponse(String responseText) {
try {
// JSON 부분만 추출
String jsonText = extractJsonFromMarkdown(responseText);
// JSON 파싱
JsonNode rootNode = objectMapper.readTree(jsonText);
JsonNode recommendationsNode = rootNode.get("recommendations");
List<EventRecommendation> recommendations = new ArrayList<>();
if (recommendationsNode != null && recommendationsNode.isArray()) {
recommendationsNode.forEach(node -> {
recommendations.add(parseEventRecommendation(node));
});
}
return recommendations;
} catch (JsonProcessingException e) {
log.error("추천안 응답 파싱 실패", e);
throw new RuntimeException("이벤트 추천안 응답 파싱 중 오류 발생", e);
}
}
/**
* EventRecommendation 파싱
*/
private EventRecommendation parseEventRecommendation(JsonNode node) {
// Mechanics Type 파싱
String mechanicsTypeStr = node.get("mechanics").get("type").asText();
EventMechanicsType mechanicsType = EventMechanicsType.valueOf(mechanicsTypeStr);
// Promotion Channels 파싱
List<String> promotionChannels = new ArrayList<>();
JsonNode channelsNode = node.get("promotionChannels");
if (channelsNode != null && channelsNode.isArray()) {
channelsNode.forEach(channel -> promotionChannels.add(channel.asText()));
}
// Breakdown 파싱
Map<String, Integer> breakdown = new HashMap<>();
JsonNode breakdownNode = node.get("estimatedCost").get("breakdown");
if (breakdownNode != null && breakdownNode.isObject()) {
breakdownNode.fields().forEachRemaining(entry ->
breakdown.put(entry.getKey(), entry.getValue().asInt())
);
}
return EventRecommendation.builder()
.optionNumber(node.get("optionNumber").asInt())
.concept(node.get("concept").asText())
.title(node.get("title").asText())
.description(node.get("description").asText())
.targetAudience(node.get("targetAudience").asText())
.duration(EventRecommendation.Duration.builder()
.recommendedDays(node.get("duration").get("recommendedDays").asInt())
.recommendedPeriod(node.get("duration").get("recommendedPeriod").asText())
.build())
.mechanics(EventRecommendation.Mechanics.builder()
.type(mechanicsType)
.details(node.get("mechanics").get("details").asText())
.build())
.promotionChannels(promotionChannels)
.estimatedCost(EventRecommendation.EstimatedCost.builder()
.min(node.get("estimatedCost").get("min").asInt())
.max(node.get("estimatedCost").get("max").asInt())
.breakdown(breakdown)
.build())
.expectedMetrics(ExpectedMetrics.builder()
.newCustomers(parseRange(node.get("expectedMetrics").get("newCustomers")))
.revenueIncrease(parseRange(node.get("expectedMetrics").get("revenueIncrease")))
.roi(parseRange(node.get("expectedMetrics").get("roi")))
.build())
.differentiator(node.get("differentiator").asText())
.build();
}
/**
* Range 파싱
*/
private ExpectedMetrics.Range parseRange(JsonNode node) {
return ExpectedMetrics.Range.builder()
.min(node.get("min").asDouble())
.max(node.get("max").asDouble())
.build();
}
/**
* Markdown에서 JSON 추출
*/
private String extractJsonFromMarkdown(String text) {
// ```json ... ``` 형태에서 JSON만 추출
if (text.contains("```json")) {
int start = text.indexOf("```json") + 7;
int end = text.indexOf("```", start);
return text.substring(start, end).trim();
}
// ```{ ... }``` 형태에서 JSON만 추출
if (text.contains("```")) {
int start = text.indexOf("```") + 3;
int end = text.indexOf("```", start);
return text.substring(start, end).trim();
}
// 순수 JSON인 경우
return text.trim();
}
}

View File

@ -0,0 +1,134 @@
package com.kt.ai.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* Redis 캐시 서비스
* - Job 상태 관리
* - AI 추천 결과 캐싱
* - 트렌드 분석 결과 캐싱
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CacheService {
private final RedisTemplate<String, Object> redisTemplate;
@Value("${cache.ttl.recommendation:86400}")
private long recommendationTtl;
@Value("${cache.ttl.job-status:86400}")
private long jobStatusTtl;
@Value("${cache.ttl.trend:3600}")
private long trendTtl;
/**
* 캐시 저장
*
* @param key Redis Key
* @param value 저장할
* @param ttlSeconds TTL ()
*/
public void set(String key, Object value, long ttlSeconds) {
try {
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
log.debug("캐시 저장 성공: key={}, ttl={}초", key, ttlSeconds);
} catch (Exception e) {
log.error("캐시 저장 실패: key={}", key, e);
}
}
/**
* 캐시 조회
*
* @param key Redis Key
* @return 캐시된 (없으면 null)
*/
public Object get(String key) {
try {
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
log.debug("캐시 조회 성공: key={}", key);
} else {
log.debug("캐시 미스: key={}", key);
}
return value;
} catch (Exception e) {
log.error("캐시 조회 실패: key={}", key, e);
return null;
}
}
/**
* 캐시 삭제
*
* @param key Redis Key
*/
public void delete(String key) {
try {
redisTemplate.delete(key);
log.debug("캐시 삭제 성공: key={}", key);
} catch (Exception e) {
log.error("캐시 삭제 실패: key={}", key, e);
}
}
/**
* Job 상태 저장
*/
public void saveJobStatus(String jobId, Object status) {
String key = "ai:job:status:" + jobId;
set(key, status, jobStatusTtl);
}
/**
* Job 상태 조회
*/
public Object getJobStatus(String jobId) {
String key = "ai:job:status:" + jobId;
return get(key);
}
/**
* AI 추천 결과 저장
*/
public void saveRecommendation(String eventId, Object recommendation) {
String key = "ai:recommendation:" + eventId;
set(key, recommendation, recommendationTtl);
}
/**
* AI 추천 결과 조회
*/
public Object getRecommendation(String eventId) {
String key = "ai:recommendation:" + eventId;
return get(key);
}
/**
* 트렌드 분석 결과 저장
*/
public void saveTrend(String industry, String region, Object trend) {
String key = "ai:trend:" + industry + ":" + region;
set(key, trend, trendTtl);
}
/**
* 트렌드 분석 결과 조회
*/
public Object getTrend(String industry, String region) {
String key = "ai:trend:" + industry + ":" + region;
return get(key);
}
}

View File

@ -0,0 +1,63 @@
package com.kt.ai.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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* Job 상태 관리 서비스
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JobStatusService {
private final CacheService cacheService;
private final ObjectMapper objectMapper;
/**
* Job 상태 조회
*/
public JobStatusResponse getJobStatus(String jobId) {
Object cached = cacheService.getJobStatus(jobId);
if (cached == null) {
throw new JobNotFoundException(jobId);
}
return objectMapper.convertValue(cached, JobStatusResponse.class);
}
/**
* Job 상태 업데이트
*/
public void updateJobStatus(String jobId, JobStatus status, String message) {
JobStatusResponse response = JobStatusResponse.builder()
.jobId(jobId)
.status(status)
.progress(calculateProgress(status))
.message(message)
.createdAt(LocalDateTime.now())
.build();
cacheService.saveJobStatus(jobId, response);
log.info("Job 상태 업데이트: jobId={}, status={}", jobId, status);
}
private int calculateProgress(JobStatus status) {
return switch (status) {
case PENDING -> 0;
case PROCESSING -> 50;
case COMPLETED -> 100;
case FAILED -> 0;
};
}
}

View File

@ -0,0 +1,222 @@
package com.kt.ai.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.ai.circuitbreaker.CircuitBreakerManager;
import com.kt.ai.circuitbreaker.fallback.AIServiceFallback;
import com.kt.ai.client.ClaudeApiClient;
import com.kt.ai.client.dto.ClaudeRequest;
import com.kt.ai.client.dto.ClaudeResponse;
import com.kt.ai.model.dto.response.TrendAnalysis;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 트렌드 분석 서비스
* - Claude AI를 통한 업종/지역/계절 트렌드 분석
* - Circuit Breaker 적용
*
* @author AI Service Team
* @since 1.0.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TrendAnalysisService {
private final ClaudeApiClient claudeApiClient;
private final CircuitBreakerManager circuitBreakerManager;
private final AIServiceFallback fallback;
private final ObjectMapper objectMapper;
@Value("${ai.claude.api-key}")
private String apiKey;
@Value("${ai.claude.anthropic-version}")
private String anthropicVersion;
@Value("${ai.claude.model}")
private String model;
@Value("${ai.claude.max-tokens}")
private Integer maxTokens;
@Value("${ai.claude.temperature}")
private Double temperature;
/**
* 트렌드 분석 수행
*
* @param industry 업종
* @param region 지역
* @return 트렌드 분석 결과
*/
public TrendAnalysis analyzeTrend(String industry, String region) {
log.info("트렌드 분석 시작 - industry={}, region={}", industry, region);
return circuitBreakerManager.executeWithCircuitBreaker(
"claudeApi",
() -> callClaudeApi(industry, region),
() -> fallback.getDefaultTrendAnalysis(industry, region)
);
}
/**
* Claude API 호출
*/
private TrendAnalysis callClaudeApi(String industry, String region) {
// 프롬프트 생성
String prompt = buildPrompt(industry, region);
// Claude API 요청 생성
ClaudeRequest request = ClaudeRequest.builder()
.model(model)
.messages(List.of(
ClaudeRequest.Message.builder()
.role("user")
.content(prompt)
.build()
))
.maxTokens(maxTokens)
.temperature(temperature)
.system("당신은 마케팅 트렌드 분석 전문가입니다. 업종별, 지역별 트렌드를 분석하고 인사이트를 제공합니다.")
.build();
// API 호출
log.debug("Claude API 호출 - model={}", model);
ClaudeResponse response = claudeApiClient.sendMessage(
apiKey,
anthropicVersion,
request
);
// 응답 파싱
String responseText = response.extractText();
log.debug("Claude API 응답 수신 - length={}", responseText.length());
return parseResponse(responseText);
}
/**
* 프롬프트 생성
*/
private String buildPrompt(String industry, String region) {
return String.format("""
# 트렌드 분석 요청
다음 조건에 맞는 마케팅 트렌드를 분석해주세요:
- 업종: %s
- 지역: %s
## 분석 요구사항
1. **업종 트렌드**: 해당 업종에서 현재 주목받는 마케팅 트렌드 3개
2. **지역 트렌드**: 해당 지역의 특성과 소비자 성향을 반영한 트렌드 2개
3. **계절 트렌드**: 현재 계절(또는 다가오는 시즌) 적합한 트렌드 2개
## 응답 형식
응답은 반드시 다음 JSON 형식으로 작성해주세요:
```json
{
"industryTrends": [
{
"keyword": "트렌드 키워드",
"relevance": 0.9,
"description": "트렌드에 대한 상세 설명 (2-3문장)"
}
],
"regionalTrends": [
{
"keyword": "트렌드 키워드",
"relevance": 0.85,
"description": "트렌드에 대한 상세 설명 (2-3문장)"
}
],
"seasonalTrends": [
{
"keyword": "트렌드 키워드",
"relevance": 0.8,
"description": "트렌드에 대한 상세 설명 (2-3문장)"
}
]
}
```
## 주의사항
- relevance 값은 0.0 ~ 1.0 사이의 소수점
- description은 구체적이고 실행 가능한 인사이트 포함
- 한국 시장과 문화를 고려한 분석
""", industry, region);
}
/**
* Claude 응답 파싱
*/
private TrendAnalysis parseResponse(String responseText) {
try {
// JSON 부분만 추출 (```json ... ``` 형태로 있음)
String jsonText = extractJsonFromMarkdown(responseText);
// JSON 파싱
JsonNode rootNode = objectMapper.readTree(jsonText);
// TrendAnalysis 객체 생성
return TrendAnalysis.builder()
.industryTrends(parseTrendKeywords(rootNode.get("industryTrends")))
.regionalTrends(parseTrendKeywords(rootNode.get("regionalTrends")))
.seasonalTrends(parseTrendKeywords(rootNode.get("seasonalTrends")))
.build();
} catch (JsonProcessingException e) {
log.error("응답 파싱 실패", e);
throw new RuntimeException("트렌드 분석 응답 파싱 중 오류 발생", e);
}
}
/**
* Markdown에서 JSON 추출
*/
private String extractJsonFromMarkdown(String text) {
// ```json ... ``` 형태에서 JSON만 추출
if (text.contains("```json")) {
int start = text.indexOf("```json") + 7;
int end = text.indexOf("```", start);
return text.substring(start, end).trim();
}
// ```{ ... }``` 형태에서 JSON만 추출
if (text.contains("```")) {
int start = text.indexOf("```") + 3;
int end = text.indexOf("```", start);
return text.substring(start, end).trim();
}
// 순수 JSON인 경우
return text.trim();
}
/**
* TrendKeyword 리스트 파싱
*/
private List<TrendAnalysis.TrendKeyword> parseTrendKeywords(JsonNode arrayNode) {
List<TrendAnalysis.TrendKeyword> keywords = new ArrayList<>();
if (arrayNode != null && arrayNode.isArray()) {
arrayNode.forEach(node -> {
keywords.add(TrendAnalysis.TrendKeyword.builder()
.keyword(node.get("keyword").asText())
.relevance(node.get("relevance").asDouble())
.description(node.get("description").asText())
.build());
});
}
return keywords;
}
}

View File

@ -0,0 +1,174 @@
spring:
application:
name: ai-service
# Redis Configuration
data:
redis:
host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0} # AI Service uses database 3
timeout: ${REDIS_TIMEOUT:3000}
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
max-wait: -1ms
# Kafka Consumer Configuration
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
consumer:
group-id: ai-service-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: "*"
max.poll.records: ${KAFKA_MAX_POLL_RECORDS:10}
session.timeout.ms: ${KAFKA_SESSION_TIMEOUT:30000}
listener:
ack-mode: manual
# Server Configuration
server:
port: ${SERVER_PORT:8083}
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
# JWT Configuration
jwt:
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:8080}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Actuator Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
health:
redis:
enabled: true
kafka:
enabled: true
# OpenAPI Documentation Configuration
springdoc:
api-docs:
path: /v3/api-docs
enabled: true
swagger-ui:
path: /swagger-ui.html
enabled: true
operations-sorter: method
tags-sorter: alpha
display-request-duration: true
doc-expansion: none
show-actuator: false
default-consumes-media-type: application/json
default-produces-media-type: application/json
# Logging Configuration
logging:
level:
root: INFO
com.kt.ai: DEBUG
org.springframework.kafka: INFO
org.springframework.data.redis: INFO
io.github.resilience4j: DEBUG
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:
topics:
ai-job: ${KAFKA_TOPIC_AI_JOB:ai-event-generation-job}
ai-job-dlq: ${KAFKA_TOPIC_AI_JOB_DLQ:ai-event-generation-job-dlq}
# AI External API Configuration
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}
api-key: ${GPT4_API_KEY:}
model: ${GPT4_MODEL:gpt-4-turbo-preview}
max-tokens: ${GPT4_MAX_TOKENS:4096}
timeout: ${GPT4_TIMEOUT:300000} # 5 minutes
provider: ${AI_PROVIDER:CLAUDE} # CLAUDE or GPT4
# Circuit Breaker Configuration
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 50
slow-call-rate-threshold: 50
slow-call-duration-threshold: 60s
permitted-number-of-calls-in-half-open-state: 3
max-wait-duration-in-half-open-state: 0
sliding-window-type: COUNT_BASED
sliding-window-size: 10
minimum-number-of-calls: 5
wait-duration-in-open-state: 60s
automatic-transition-from-open-to-half-open-enabled: true
instances:
claudeApi:
base-config: default
failure-rate-threshold: 50
wait-duration-in-open-state: 60s
gpt4Api:
base-config: default
failure-rate-threshold: 50
wait-duration-in-open-state: 60s
timelimiter:
configs:
default:
timeout-duration: 300s # 5 minutes
instances:
claudeApi:
timeout-duration: 300s
gpt4Api:
timeout-duration: 300s
# Redis Cache TTL Configuration (seconds)
cache:
ttl:
recommendation: ${CACHE_TTL_RECOMMENDATION:86400} # 24 hours
job-status: ${CACHE_TTL_JOB_STATUS:86400} # 24 hours
trend: ${CACHE_TTL_TREND:3600} # 1 hour
fallback: ${CACHE_TTL_FALLBACK:604800} # 7 days

View File

@ -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);
});
}
}

View File

@ -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 종료");
}
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,69 @@
spring:
application:
name: ai-service-test
# Redis Configuration (테스트용)
data:
redis:
host: ${REDIS_HOST:20.214.210.71}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Hi5Jessica!}
database: ${REDIS_DATABASE:3}
timeout: 3000
# Kafka Configuration (테스트용)
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer:
group-id: ai-service-test-consumers
auto-offset-reset: earliest
enable-auto-commit: false
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*"
listener:
ack-mode: manual
# Server Configuration
server:
port: 0 # 랜덤 포트 사용
# JWT Configuration (테스트용)
jwt:
secret: test-jwt-secret-key-for-testing-only
access-token-validity: 1800
refresh-token-validity: 86400
# Kafka Topics
kafka:
topics:
ai-job: ai-event-generation-job
ai-job-dlq: ai-event-generation-job-dlq
# AI API Configuration (테스트용 - Mock 사용)
ai:
provider: CLAUDE
claude:
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
api-key: ${CLAUDE_API_KEY:test-key}
anthropic-version: 2023-06-01
model: claude-3-5-sonnet-20241022
max-tokens: 4096
temperature: 0.7
timeout: 300000
# Cache TTL
cache:
ttl:
recommendation: 86400
job-status: 86400
trend: 3600
fallback: 604800
# Logging
logging:
level:
root: INFO
com.kt.ai: DEBUG
org.springframework.kafka: DEBUG

View File

@ -286,6 +286,11 @@ public class SampleDataLoader implements ApplicationRunner {
publishEvent(PARTICIPANT_REGISTERED_TOPIC, event); publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
totalPublished++; totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기
if ((j + 1) % 10 == 0) {
Thread.sleep(100);
}
} }
} }

View File

@ -11,6 +11,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -37,7 +38,10 @@ public class DistributionCompletedConsumer {
/** /**
* DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열) * DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열)
*
* @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요
*/ */
@Transactional
@KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}") @KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}")
public void handleDistributionCompleted(String message) { public void handleDistributionCompleted(String message) {
try { try {
@ -128,8 +132,8 @@ public class DistributionCompletedConsumer {
.mapToInt(ChannelStats::getImpressions) .mapToInt(ChannelStats::getImpressions)
.sum(); .sum();
// EventStats 업데이트 // EventStats 업데이트 - 비관적 적용
eventStatsRepository.findByEventId(eventId) eventStatsRepository.findByEventIdWithLock(eventId)
.ifPresentOrElse( .ifPresentOrElse(
eventStats -> { eventStats -> {
eventStats.setTotalViews(totalViews); eventStats.setTotalViews(totalViews);

View File

@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -34,7 +35,10 @@ public class EventCreatedConsumer {
/** /**
* EventCreated 이벤트 처리 (MVP용 샘플 토픽) * EventCreated 이벤트 처리 (MVP용 샘플 토픽)
*
* @Transactional 필수: DB 저장 작업을 위해 트랜잭션 컨텍스트 필요
*/ */
@Transactional
@KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}") @KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}")
public void handleEventCreated(String message) { public void handleEventCreated(String message) {
try { try {

View File

@ -10,6 +10,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -34,7 +35,10 @@ public class ParticipantRegisteredConsumer {
/** /**
* ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽) * ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽)
*
* @Transactional 필수: 비관적 사용을 위해 트랜잭션 컨텍스트 필요
*/ */
@Transactional
@KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}") @KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}")
public void handleParticipantRegistered(String message) { public void handleParticipantRegistered(String message) {
try { try {
@ -51,8 +55,8 @@ public class ParticipantRegisteredConsumer {
return; return;
} }
// 2. 이벤트 통계 업데이트 (참여자 +1) // 2. 이벤트 통계 업데이트 (참여자 +1) - 비관적 적용
eventStatsRepository.findByEventId(eventId) eventStatsRepository.findByEventIdWithLock(eventId)
.ifPresentOrElse( .ifPresentOrElse(
eventStats -> { eventStats -> {
eventStats.incrementParticipants(); eventStats.incrementParticipants();

View File

@ -1,7 +1,11 @@
package com.kt.event.analytics.repository; package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.EventStats; import com.kt.event.analytics.entity.EventStats;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
@ -20,6 +24,20 @@ public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
*/ */
Optional<EventStats> findByEventId(String eventId); Optional<EventStats> findByEventId(String eventId);
/**
* 이벤트 ID로 통계 조회 (비관적 적용)
*
* 동시성 충돌 방지를 위해 PESSIMISTIC_WRITE 사용
* - 읽는 순간부터 락을 걸어 다른 트랜잭션 차단
* - ParticipantRegistered 이벤트 처리 사용
*
* @param eventId 이벤트 ID
* @return 이벤트 통계
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM EventStats e WHERE e.eventId = :eventId")
Optional<EventStats> findByEventIdWithLock(@Param("eventId") String eventId);
/** /**
* 매장 ID와 이벤트 ID로 통계 조회 * 매장 ID와 이벤트 ID로 통계 조회
* *

View File

@ -0,0 +1,82 @@
# 백엔드 컨테이너이미지 작성가이드
[요청사항]
- 백엔드 각 서비스를의 컨테이너 이미지 생성
- 실제 빌드 수행 및 검증까지 완료
- '[결과파일]'에 수행한 명령어를 포함하여 컨테이너 이미지 작성 과정 생성
[작업순서]
- 서비스명 확인
서비스명은 settings.gradle에서 확인
예시) include 'common'하위의 4개가 서비스명임.
```
rootProject.name = 'tripgen'
include 'common'
include 'user-service'
include 'location-service'
include 'ai-service'
include 'trip-service'
```
- 실행Jar 파일 설정
실행Jar 파일명을 서비스명과 일치하도록 build.gradle에 설정 합니다.
```
bootJar {
archiveFileName = '{서비스명}.jar'
}
```
- Dockerfile 생성
아래 내용으로 deployment/container/Dockerfile-backend 생성
```
# Build stage
FROM openjdk:23-oraclelinux8 AS builder
ARG BUILD_LIB_DIR
ARG ARTIFACTORY_FILE
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
# Run stage
FROM openjdk:23-slim
ENV USERNAME=k8s
ENV ARTIFACTORY_HOME=/home/${USERNAME}
ENV JAVA_OPTS=""
# Add a non-root user
RUN adduser --system --group ${USERNAME} && \
mkdir -p ${ARTIFACTORY_HOME} && \
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
WORKDIR ${ARTIFACTORY_HOME}
COPY --from=builder app.jar app.jar
RUN chown ${USERNAME}:${USERNAME} app.jar
USER ${USERNAME}
ENTRYPOINT [ "sh", "-c" ]
CMD ["java ${JAVA_OPTS} -jar app.jar"]
```
- 컨테이너 이미지 생성
아래 명령으로 각 서비스 빌드. shell 파일을 생성하지 말고 command로 수행.
서브에이젼트를 생성하여 병렬로 수행.
```
DOCKER_FILE=deployment/container/Dockerfile-backend
service={서비스명}
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="${서비스명}/build/libs" \
--build-arg ARTIFACTORY_FILE="${서비스명}.jar" \
-f ${DOCKER_FILE} \
-t ${서비스명}:latest .
```
- 생성된 이미지 확인
아래 명령으로 모든 서비스의 이미지가 빌드되었는지 확인
```
docker images | grep {서비스명}
```
[결과파일]
deployment/container/build-image.md

220
claude/design-prompt.md Normal file
View File

@ -0,0 +1,220 @@
# 설계 프롬프트
아래 순서대로 설계합니다.
## UI/UX 설계
command: "/design-uiux"
prompt:
```
@uiux
UI/UX 설계를 해주세요:
- 'UI/UX설계가이드'를 준용하여 작성
```
---
# 프로토타입 작성
command: "/design-prototype"
prompt:
**1.작성**
```
@prototype
프로토타입을 작성해 주세요:
- '프로토타입작성가이드'를 준용하여 작성
```
---
**2.검증**
command: "/design-test-prototype"
prompt:
```
@test-front
프로토타입을 테스트 해 주세요.
```
---
**3.오류수정**
command: "/design-fix-prototype"
prompt:
```
@fix as @front
'[오류내용]'섹션에 제공된 오류를 해결해 주세요.
프롬프트에 '[오류내용]'섹션이 없으면 수행 중단하고 안내 메시지 표시
{안내메시지}
'[오류내용]'섹션 하위에 오류 내용을 제공
```
---
**4.개선**
command: "/design-improve-prototype"
prompt:
```
@improve as @front
'[개선내용]'섹션에 있는 내용을 개선해 주세요.
프롬프트에 '[개선내용]'항목이 없으면 수행을 중단하고 안내 메시지 표시
{안내메시지}
'[개선내용]'섹션 하위에 개선할 내용을 제공
```
---
**5.유저스토리 품질 높이기**
command: "/design-improve-userstory"
prompt:
```
@analyze as @front 프로토타입을 웹브라우저에서 분석한 후,
@document as @scribe 수정된 프로토타입에 따라 유저스토리를 업데이트 해주십시오.
```
---
**6.설계서 다시 업데이트**
command: "/design-update-uiux"
prompt:
```
@document @front
현재 프로토타입과 유저스토리를 기준으로 UI/UX설계서와 스타일가이드를 수정해 주세요.
```
---
## 클라우드 아키텍처 패턴 선정
command: "/design-pattern"
prompt:
```
@design-pattern
클라우드 아키텍처 패턴 적용 방안을 작성해 주세요:
- '클라우드아키텍처패턴선정가이드'를 준용하여 작성
```
---
## 논리아키텍처 설계
command: "/design-logical"
prompt:
```
@architecture
논리 아키텍처를 설계해 주세요:
- '공통설계원칙'과 '논리아키텍처 설계 가이드'를 준용하여 설계
```
---
## 외부 시퀀스 설계
command: "/design-seq-outer"
prompt:
```
@architecture
외부 시퀀스 설계를 해 주세요:
- '공통설계원칙'과 '외부시퀀스설계가이드'를 준용하여 설계
```
---
## 내부 시퀀스 설계
command: "/design-seq-inner"
prompt:
```
@architecture
내부 시퀀스 설계를 해 주세요:
- '공통설계원칙'과 '내부시퀀스설계 가이드'를 준용하여 설계
```
---
## API 설계
command: "/design-api"
prompt:
```
@architecture
API를 설계해 주세요:
- '공통설계원칙'과 'API설계가이드'를 준용하여 설계
```
---
## 클래스 설계
command: "/design-class"
prompt:
```
@architecture
'공통설계원칙'과 '클래스설계가이드'를 준용하여 클래스를 설계해 주세요.
프롬프트에 '[클래스설계 정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
{안내메시지}
'[클래스설계 정보]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
[클래스설계 정보]
- 패키지 그룹: com.unicorn.tripgen
- 설계 아키텍처 패턴
- User: Layered
- Trip: Clean
- Location: Layered
- AI: Layered
```
---
## 데이터 설계
command: "/design-data"
prompt:
```
@architecture
데이터 설계를 해주세요:
- '공통설계원칙'과 '데이터설계가이드'를 준용하여 설계
```
---
## High Level 아키텍처 정의서 작성
command: "/design-high-level"
prompt:
```
@architecture
'HighLevel아키텍처정의가이드'를 준용하여 High Level 아키텍처 정의서를 작성해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
{안내메시지}
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
- CLOUD: Azure
```
---
## 물리 아키텍처 설계
command: "/design-physical"
prompt:
```
@architecture
'물리아키텍처설계가이드'를 준용하여 물리아키텍처를 설계해 주세요.
'CLOUD' 정보가 없으면 수행을 중단하고 안내메시지를 표시하세요.
{안내메시지}
아래 예와 같이 CLOUD 제공자를 Azure, AWS, Google과 같이 제공하세요.
- CLOUD: Azure
```
## 프론트엔드 설계
command: "/design-front"
prompt:
```
@plan as @front
'프론트엔드설계가이드'를 준용하여 **프론트엔드설계서**를 작성해 주세요.
프롬프트에 '[백엔드시스템]'항목이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
{안내메시지}
'[백엔드시스템]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
[백엔드시스템]
- 시스템: tripgen
- 마이크로서비스: user-service, location-service, trip-service, ai-service
- API문서
- user service: http://localhost:8081/v3/api-docs
- location service: http://localhost:8082/v3/api-docs
- trip service: http://localhost:8083/v3/api-docs
- ai service: http://localhost:8084/v3/api-docs
[요구사항]
- 각 화면에 Back 아이콘 버튼과 화면 타이틀 표시
- 하단 네비게이션 바 아이콘화: 홈, 새여행, 주변장소검색, 여행보기
```

View File

@ -1,4 +1,6 @@
# 백엔드 개발 가이드 % Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 백엔드 개발 가이드
[요청사항] [요청사항]
- <개발원칙>을 준용하여 개발 - <개발원칙>을 준용하여 개발
@ -601,7 +603,7 @@ public class UserPrincipal {
* 일반 사용자 권한 여부 확인 * 일반 사용자 권한 여부 확인
*/ */
public boolean isUser() { public boolean isUser() {
return "USER".equals(authority) || authority == null; return "USER".equals(authority) || 100 22883 100 22883 0 0 76277 0 --:--:-- --:--:-- --:--:-- 76788authority == null;
} }
} }
``` ```
@ -660,3 +662,4 @@ public class SwaggerConfig {
} }
} }
``` ```

180
claude/develop-prompt.md Normal file
View File

@ -0,0 +1,180 @@
# 개발 프롬프트
## 데이터베이스 설치계획서 작성 요청
command: "/develop-db-guide"
prompt:
```
@backing-service
"데이터베이스설치계획서가이드"에 따라 데이터베이스 설치계획서를 작성해 주십시오.
```
---
## 데이터베이스 설치 수행 요청
command: "/develop-db-install"
prompt:
```
@backing-service
[요구사항]
'데이터베이스설치가이드'에 따라 설치해 주세요.
'[설치정보]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시하세요.
{안내메시지}
'[설치정보]'섹션 하위에 아래 예와 같이 설치에 필요한 정보를 추가해 주세요.
- 설치대상환경: 개발환경
- AKS Resource Group: rg-digitalgarage-01
- AKS Name: aks-digitalgarage-01
- Namespace: tripgen-dev
```
---
## 데이터베이스 설치 제거 요청 (필요시)
command: "/develop-db-remove"
prompt:
```
@backing-service
[요구사항]
- "데이터베이스설치결과서"를 보고 관련된 모든 리소스를 삭제
- "캐시설치결과서"를 보고 관련된 모든 리소스를 삭제
- 현재 OS에 맞게 수행
- 서브 에이젼트를 병렬로 수행하여 삭제
- 결과파일은 생성할 필요 없고 화면에만 결과 표시
[참고자료]
- 데이터베이스설치결과서
- 캐시설치결과서
```
---
## Message Queue 설치 계획서 작성 요청
command: "/develop-mq-guide"
prompt:
```
@backing-service
"MQ설치게획서가이드"에 따라 Message Queue 설치계획서를 작성해 주세요.
```
---
## Message Queue 설치 수행 요청(필요시)
command: "/develop-mq-install"
prompt:
```
@backing-service
[요구사항]
'MQ설치가이드'에 따라 설치해 주세요.
'[설치정보]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시하세요.
{안내메시지}
'[설치정보]'섹션 하위에 아래 예와 같이 설치에 필요한 정보를 추가해 주세요.
- 설치대상환경: 개발환경
- Resource Group: rg-digitalgarage-01
- Namespace: tripgen-dev
```
---
## Message Queue 설치 제거 요청
command: "/develop-mq-remove"
prompt:
```
@backing-service
[요구사항]
- "MQ설치결과서"를 보고 관련된 모든 리소스를 삭제
- 현재 OS에 맞게 수행
- 서브 에이젼트를 병렬로 수행하여 삭제
- 결과파일은 생성할 필요 없고 화면에만 결과 표시
[참고자료]
- MQ설치결과서
```
---
## 백엔드 개발 요청
command: "/develop-dev-backend"
prompt:
```
@dev-backend
"백엔드개발가이드"에 따라 개발해 주세요.
프롬프트에 '[개발정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
[개발정보]
- 개발 아키텍처패턴
- auth: Layered
- bill-inquiry: Clean
- product-change: Layered
- kos-mock: Layered
```
---
## 백엔드 오류 해결 요청
command: "/develop-fix-backend"
prompt:
```
@fix as @back
개발된 각 서비스와 common 모듈을 컴파일하고 에러를 해결해 주세요.
- common 모듈 우선 수행
- 각 서비스별로 서브 에이젠트를 병렬로 수행
- 컴파일이 모두 성공할때까지 계속 수행
```
---
## 서비스 실행파일 작성 요청
command: "/develop-make-run-profile"
prompt:
```
@test-backend
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
{안내메시지}
[작성정보]
- API Key
- Claude: sk-ant-ap...
- OpenAI: sk-proj-An4Q...
- Open Weather Map: 1aa5b...
- Kakao API Key: 5cdc24....
```
---
## 백엔드 테스트 요청
command: "/develop-test-backend"
prompt:
```
@test-backend
'백엔드테스트가이드'에 따라 테스트를 해 주세요.
프롬프트에 '[테스트정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
테스트 대상 서비스를 지정안하면 모든 서비스를 테스트 합니다.
{안내메시지}
'[테스트정보]'섹션 하위에 아래 예와 같이 테스트에 필요한 정보를 제시해 주세요.
테스트 대상 서비스를 콤마로 구분하여 입력할 수 있으며 전체를 테스트 할 때는 '전체'라고 입력하세요.
- 서비스: user-service
- API Key
- Claude: sk-ant-ap...
- OpenAI: sk-proj-An4Q...
- Open Weather Map: 1aa5b...
- Kakao API Key: 5cdc24....
```
---
## 프론트엔드 개발 요청
command: "/develop-dev-front"
prompt:
```
@dev-front
"프론트엔드개발가이드"에 따라 개발해 주세요.
프롬프트에 '[개발정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
{안내메시지}
'[개발정보]'섹션 하위에 아래 예와 같이 개발에 필요한 정보를 제시해 주세요.
[개발정보]
- 개발프레임워크: Typescript + React 18
- UI프레임워크: MUI v5
- 상태관리: Redux Toolkit
- 라우팅: React Router v6
- API통신: Axios
- 스타일링: MUI + styled-components
- 빌드도구: Vite
```

View File

@ -1,7 +1,4 @@
% Total % Received % Xferd Average Speed Time Time Time Current # 서비스실행프로파일작성가이드
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
[요청사항] [요청사항]
- <수행원칙>을 준용하여 수행 - <수행원칙>을 준용하여 수행
@ -151,8 +148,7 @@
<option name="IS_ENABLED" value="false" /> <option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" /> <option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" /> <option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false <option name="IS_IGNORE_MISSING_FILES" value="false" />
100 9115 100 9115 0 0 28105 0 --:--:-- --:--:-- --:--:-- 28219" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" /> <option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES> <ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" /> <ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
@ -177,4 +173,3 @@
- MQ 유형 및 연결 정보 - MQ 유형 및 연결 정보
- 연결에 필요한 호스트, 포트, 인증 정보 - 연결에 필요한 호스트, 포트, 인증 정보
- LoadBalancer Service External IP (해당하는 경우) - LoadBalancer Service External IP (해당하는 경우)

41
claude/think-prompt.md Normal file
View File

@ -0,0 +1,41 @@
# 서비스 기획 프롬프트
## 서비스 기획
command: "/think-planning"
prompt:
아래 내용을 터미널에 표시만 하고 수행을 하지는 않습니다.
```
아래 가이드를 참고하여 서비스 기획을 수행합니다.
https://github.com/cna-bootcamp/aiguide/blob/main/AI%ED%99%9C%EC%9A%A9%20%EC%84%9C%EB%B9%84%EC%8A%A4%20%EA%B8%B0%ED%9A%8D%20%EA%B0%80%EC%9D%B4%EB%93%9C.md
```
---
## 유저스토리 작성
command: "/think-userstory"
prompt:
```
@document
유저스토리를 작성하세요.
프롬프트에 '[요구사항]'섹션이 없으면 수행을 중단하고 안내 메시지를 표시합니다.
{안내메시지}
'[요구사항]' 섹션에 아래 예와 같은 정보를 제공해 주십시오.
[요구사항]
Case 1) 이벤트스토밍을 피그마로 수행한 경우는 피그마 채널ID를 제공
예) 피그마 채널ID 'abcde'에 접속하여 분석
Case 2) 다른 방법으로 이벤트스토밍을 한 경우는 요구사항을 정리한 파일 경로를 제공
예) 요구사항문서 'design/requirement.md'를 읽어 분석
프롬프트에 '[요구사항]'섹션이 있으면 아래와 같이 수행합니다.
1. 요구사항 분석
- 피그마 채널ID가 제공된 경우 figma MCP를 이용하여 해당 채널에 접속하여 분석
- 요구사항문서 경로가 제공된 경우 해당 문서를 읽어 요구사항을 분석
2. 유저스토리 작성
- '유저스토리작성방법'과 '유저스토리예제'를 참고하여 유저스토리를 작성
- 결과파일은 'design/userstory.md'에 생성
```

View File

@ -32,4 +32,7 @@ dependencies {
// Jackson for JSON // Jackson for JSON
api 'com.fasterxml.jackson.core:jackson-databind' api 'com.fasterxml.jackson.core:jackson-databind'
api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' api 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// Swagger/OpenAPI
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
} }

View File

@ -56,13 +56,14 @@ public class JwtTokenProvider {
* @param roles 역할 목록 * @param roles 역할 목록
* @return Access Token * @return Access Token
*/ */
public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) {
public String createAccessToken(Long userId, Long storeId, String email, String name, List<String> roles) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs); Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId.toString())
.claim("storeId", storeId.toString()) .claim("storeId", storeId != null ? storeId.toString() : null)
.claim("email", email) .claim("email", email)
.claim("name", name) .claim("name", name)
.claim("roles", roles) .claim("roles", roles)
@ -112,8 +113,9 @@ public class JwtTokenProvider {
public UserPrincipal getUserPrincipalFromToken(String token) { public UserPrincipal getUserPrincipalFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
UUID userId = UUID.fromString(claims.getSubject()); Long userId = Long.parseLong(claims.getSubject());
UUID storeId = UUID.fromString(claims.get("storeId", String.class)); String storeIdStr = claims.get("storeId", String.class);
Long storeId = storeIdStr != null ? Long.parseLong(storeIdStr) : null;
String email = claims.get("email", String.class); String email = claims.get("email", String.class);
String name = claims.get("name", String.class); String name = claims.get("name", String.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@ -24,12 +24,12 @@ public class UserPrincipal implements UserDetails {
/** /**
* 사용자 ID * 사용자 ID
*/ */
private final UUID userId; private final Long userId;
/** /**
* 매장 ID * 매장 ID
*/ */
private final UUID storeId; private final Long storeId;
/** /**
* 사용자 이메일 * 사용자 이메일

View File

@ -21,3 +21,8 @@ dependencies {
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.core:jackson-databind'
} }
// JAR
bootJar {
archiveFileName = 'content-service.jar'
}

View File

@ -23,6 +23,22 @@ public class ContentCommand {
private Long eventDraftId; private Long eventDraftId;
private String eventTitle; private String eventTitle;
private String eventDescription; private String eventDescription;
/**
* 업종 (: "고깃집", "카페", "베이커리")
*/
private String industry;
/**
* 지역 (: "강남", "홍대", "서울")
*/
private String location;
/**
* 트렌드 키워드 (최대 3개 권장, : ["할인", "신메뉴", "이벤트"])
*/
private List<String> trends;
private List<ImageStyle> styles; private List<ImageStyle> styles;
private List<Platform> platforms; private List<Platform> platforms;
} }

View File

@ -0,0 +1,288 @@
package com.kt.event.content.biz.service;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
import com.kt.event.content.biz.usecase.out.CDNUploader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.JobWriter;
import com.kt.event.content.infra.gateway.client.HuggingFaceApiClient;
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Hugging Face Inference API 이미지 생성 서비스
*
* Hugging Face Inference API를 사용하여 Stable Diffusion으로 이미지 생성 (무료)
*/
@Slf4j
@Service
@Profile({"prod", "dev"}) // production dev 환경에서 활성화 (local은 Mock 사용)
public class HuggingFaceImageGenerator implements GenerateImagesUseCase {
private final HuggingFaceApiClient huggingFaceClient;
private final CDNUploader cdnUploader;
private final JobWriter jobWriter;
private final ContentWriter contentWriter;
private final CircuitBreaker circuitBreaker;
public HuggingFaceImageGenerator(
HuggingFaceApiClient huggingFaceClient,
CDNUploader cdnUploader,
JobWriter jobWriter,
ContentWriter contentWriter,
@Qualifier("huggingfaceCircuitBreaker") CircuitBreaker circuitBreaker) {
this.huggingFaceClient = huggingFaceClient;
this.cdnUploader = cdnUploader;
this.jobWriter = jobWriter;
this.contentWriter = contentWriter;
this.circuitBreaker = circuitBreaker;
}
@Override
public JobInfo execute(ContentCommand.GenerateImages command) {
log.info("Hugging Face 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
// Job 생성
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
Job job = Job.builder()
.id(jobId)
.eventDraftId(command.getEventDraftId())
.jobType("image-generation")
.status(Job.Status.PENDING)
.progress(0)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// Job 저장
RedisJobData jobData = RedisJobData.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus().name())
.progress(job.getProgress())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
jobWriter.saveJob(jobData, 3600); // TTL 1시간
log.info("Job 생성 완료: jobId={}", jobId);
// 비동기로 이미지 생성
processImageGeneration(jobId, command);
return JobInfo.from(job);
}
@Async
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
try {
log.info("Hugging Face 이미지 생성 시작: jobId={}", jobId);
// Content 생성 또는 조회
Content content = Content.builder()
.eventDraftId(command.getEventDraftId())
.eventTitle(command.getEventDraftId() + " 이벤트")
.eventDescription("AI 생성 이벤트 이미지")
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
Content savedContent = contentWriter.save(content);
log.info("Content 생성 완료: contentId={}", savedContent.getId());
// 스타일 x 플랫폼 조합으로 이미지 생성
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> images = new ArrayList<>();
int totalCount = styles.size() * platforms.size();
int currentCount = 0;
for (ImageStyle style : styles) {
for (Platform platform : platforms) {
currentCount++;
// 진행률 업데이트
int progress = (currentCount * 100) / totalCount;
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress);
// Hugging Face로 이미지 생성
String prompt = buildPrompt(command, style, platform);
String imageUrl = generateImage(prompt, platform);
// GeneratedImage 저장
GeneratedImage image = GeneratedImage.builder()
.eventDraftId(command.getEventDraftId())
.style(style)
.platform(platform)
.cdnUrl(imageUrl)
.prompt(prompt)
.selected(currentCount == 1) // 번째 이미지를 선택
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
if (currentCount == 1) {
image.select();
}
GeneratedImage savedImage = contentWriter.saveImage(image);
images.add(savedImage);
log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}",
savedImage.getId(), style, platform, imageUrl);
}
}
// Job 완료
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
jobWriter.updateJobResult(jobId, resultMessage);
log.info("Hugging Face Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패: jobId={}", jobId, e);
jobWriter.updateJobError(jobId, e.getMessage());
}
}
/**
* Hugging Face로 이미지 생성
*
* @param prompt 이미지 생성 프롬프트
* @param platform 플랫폼 (이미지 크기 결정)
* @return 생성된 이미지 URL
*/
private String generateImage(String prompt, Platform platform) {
try {
// 플랫폼별 이미지 크기 설정
int width = platform.getWidth();
int height = platform.getHeight();
// Hugging Face API 요청
HuggingFaceRequest request = HuggingFaceRequest.builder()
.inputs(prompt)
.parameters(HuggingFaceRequest.Parameters.builder()
.negative_prompt("blurry, bad quality, distorted, ugly, low resolution")
.width(width)
.height(height)
.guidance_scale(7.5)
.num_inference_steps(50)
.build())
.build();
log.info("Hugging Face API 호출: prompt={}, size={}x{}", prompt, width, height);
// 이미지 생성 (동기 방식)
byte[] imageData = generateImageWithCircuitBreaker(request);
log.info("Hugging Face 이미지 생성 완료: size={} bytes", imageData.length);
// Azure Blob Storage에 업로드
String fileName = String.format("event-%s-%s-%s.png",
platform.name().toLowerCase(),
UUID.randomUUID().toString().substring(0, 8),
System.currentTimeMillis());
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl);
return azureCdnUrl;
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패: prompt={}", prompt, e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
/**
* 이미지 생성 프롬프트 구성
*/
private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) {
StringBuilder prompt = new StringBuilder();
// 업종 정보 추가
if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) {
prompt.append(command.getIndustry()).append(" ");
}
// 기본 프롬프트
prompt.append("event promotion image");
// 지역 정보 추가
if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) {
prompt.append(" in ").append(command.getLocation());
}
// 트렌드 키워드 추가 (최대 3개)
if (command.getTrends() != null && !command.getTrends().isEmpty()) {
prompt.append(", featuring ");
int count = Math.min(3, command.getTrends().size());
for (int i = 0; i < count; i++) {
if (i > 0) prompt.append(", ");
prompt.append(command.getTrends().get(i));
}
}
prompt.append(", ");
// 스타일별 프롬프트
switch (style) {
case FANCY:
prompt.append("elegant, luxurious, premium design, vibrant colors, ");
break;
case SIMPLE:
prompt.append("minimalist, clean design, simple layout, modern, ");
break;
case TRENDY:
prompt.append("trendy, contemporary, stylish, modern design, ");
break;
}
// 플랫폼별 특성 추가
prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, ");
prompt.append("high quality, detailed, 4k resolution");
return prompt.toString();
}
/**
* Circuit Breaker로 보호된 Hugging Face 이미지 생성
*
* @param request Hugging Face 요청
* @return 생성된 이미지 바이트 데이터
*/
private byte[] generateImageWithCircuitBreaker(HuggingFaceRequest request) {
try {
return circuitBreaker.executeSupplier(() -> huggingFaceClient.generateImage(request));
} catch (CallNotPermittedException e) {
log.error("Hugging Face Circuit Breaker가 OPEN 상태입니다. 이미지 생성 차단");
throw new RuntimeException("Hugging Face API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패", e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
}

Some files were not shown because too many files have changed in this diff Show More