diff --git a/README.md b/README.md
index 7553299..10ba487 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,12 @@
| 분류 | 기술 |
|------|------|
| Language | Kotlin 1.9.25, JDK 21 |
-| Framework | Spring Boot 3.4, Spring Batch, Spring Security |
+| Framework | Spring Boot 3.4, Spring Batch |
| ORM / Query | JPA, QueryDSL |
| Database | MySQL |
| Cache | Caffeine Cache |
| Infra | AWS EC2, Docker |
-| External API | YouTube Data v3, Gemini 2.5 Flash, Google Places API, Discord Webhook |
+| External API | YouTube Data v3, Gemini 2.5 Flash, Google Places API, Discord Webhook, webshare proxy |
---
@@ -25,12 +25,17 @@ linktrip-bootstrap ← Spring Boot 진입점, DI 조립
├── linktrip-input-batch ← Spring Batch Job & Scheduler
│
├── linktrip-application ← 핵심 비즈니스 로직 (Port & Service)
-│ ├── domain/ ← 도메인 서비스
+│ ├── domain/ ← 도메인 모델 + 도메인 서비스
+│ │ ├── video/ ← 영상 분석 도메인
+│ │ ├── youtube/ ← 영상 수집 도메인
+│ │ ├── trip/ ← 여행 계획 도메인
+│ │ ├── quota/ ← 외부 API 호출량 가드 + 카운터 + 비용 알림
+│ │ └── notification/ ← 알림 이벤트
│ ├── port/input/ ← UseCase 인터페이스
│ └── port/output/ ← Output Port 인터페이스
│
-├── linktrip-output-http ← 외부 API 어댑터 (YouTube, Gemini, Places, Discord)
-├── linktrip-output-cache ← Caffeine 캐시 데코레이터 어댑터
+├── linktrip-output-http ← 외부 API 어댑터 (YouTube, Gemini, Places, Discord, webshare)
+├── linktrip-output-cache ← Caffeine 캐시 데코레이터 어댑터 + 인메모리 큐
├── linktrip-output-persistence ← MySQL JPA + QueryDSL 어댑터
└── linktrip-common ← 공통 예외, 이벤트, 설정
```
@@ -67,10 +72,11 @@ linktrip-bootstrap ← Spring Boot 진입점, DI 조립
▲ ▲ ▲
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ output-cache │ │ output- │ │ output-http │
- │ (Caffeine) │ │ persistence │ │ (YouTube, │
- │ │ │ (MySQL/JPA) │ │ Gemini, │
- │ @Primary │ │ @Qualifier │ │ Places, │
- └──────┬───────┘ └──────────────┘ │ Discord) │
+ │ (Caffeine + │ │ persistence │ │ (YouTube, │
+ │ in-memory │ │ (MySQL/JPA) │ │ Gemini, │
+ │ queue) │ │ @Qualifier │ │ Places, │
+ │ @Primary │ │ │ │ Discord, │
+ └──────┬───────┘ └──────────────┘ │ webshare) │
│ ↑ └──────────────┘
│ delegate │
└───────────────┘
@@ -109,92 +115,451 @@ Caffeine Cache (TTL 7시간, 최대 100건)
## 영상 분석 파이프라인
+전체 흐름은 **요청 처리 (트랜잭션)** → **이벤트 발행/수신 (큐 적재)** → **백그라운드 소비 (실제 분석)** 3 단계로 분리되어 있습니다.
+
```
+[ ① 요청 처리 — 트랜잭션 안 ]
+
POST /video/analyze { youtubeUrl }
│
▼
-┌────────────────────────────────────────────────┐
-│ VideoAnalyzeService │
-│ │
-│ URL 정규화 → 중복 확인 → Task 생성 (PENDING) │
-│ → 요청 대기열 등록 (memberId) │
-│ → Event 발행 → 202 Accepted 즉시 반환 │
-└─────────────────────┬──────────────────────────┘
- │ @Async + @TransactionalEventListener
- ▼
-┌────────────────────────────────────────────────┐
-│ VideoAnalyzeEventListener │
-│ │
-│ Gemini 2.5 Flash 영상 분석 │
-│ │ │
-│ 유효한 여행 영상? │
-│ ├── No → INVALID │
-│ ▼ Yes │
-│ 일정 저장 (EAT / ATTRACTION / SHOPPING / etc.) │
-│ │ │
-│ 대기열 조회 → 각 요청자별 TripPlan 생성 │
-│ │ │
-│ Google Places API 좌표 매핑 (병렬) │
-│ │ │
-│ 완료 알림 발송 │
-└────────────────────────────────────────────────┘
- │
- ▼
+┌────────────────────────────────────────────────────────────┐
+│ VideoAnalyzeService │
+│ │
+│ URL 정규화 → 중복 확인 │
+│ ├─ COMPLETED 영상이면 → schedule 즉시 로드 + 인라인 200 │
+│ ├─ INVALID 영상이면 → 200 + status=INVALID │
+│ └─ 신규 / PENDING / FAILED │
+│ → Task 생성/전이 (PENDING) │
+│ → 요청 대기열 등록 (memberId) │
+│ → Events.raise(VideoAnalyzeEvent) ← Spring 이벤트 발행 │
+│ → 202 Accepted │
+└────────────────────────┬───────────────────────────────────┘
+ │ AFTER_COMMIT (트랜잭션 커밋 후에만)
+ ▼
+[ ② 이벤트 수신 — @TransactionalEventListener ]
+
+┌────────────────────────────────────────────────────────────┐
+│ VideoAnalyzeEventListener (@Async) │
+│ │
+│ videoAnalysisQueuePort.enqueue(taskId, url, source) │
+│ → PriorityBlockingQueue 에 적재 │
+└────────────────────────┬───────────────────────────────────┘
+ │ 큐에 enqueue 됨
+ ▼
+[ ③ 큐 소비 — 별도 데몬 스레드 ]
+
+┌────────────────────────────────────────────────────────────┐
+│ VideoAnalysisQueueConsumer (단일 스레드) │
+│ │
+│ loop guard: 외부 API 일일 한도 초과 시 dequeue 차단 │
+│ │ │
+│ USER 우선순위 dequeue (BATCH 는 USER 비었을 때만) │
+│ │ │
+│ 1단계: 자막 추출 (webshare 프록시 라운드로빈) │
+│ ├── 한국 IP 우선 → JP → TW → ... 10개 sticky │
+│ ├── 429/403 시 다음 프록시로 즉시 swap │
+│ └── 모호 실패 → sentinel 영상으로 IP 차단 vs 자막 없음 │
+│ 구분 │
+│ │ │
+│ 2단계: Gemini 2.5 Flash 자막 분석 │
+│ │ │
+│ 결과 저장 (EAT / ATTRACTION / SHOPPING / TRANSPORTATION) │
+│ │ │
+│ 대기열 조회 → 각 요청자별 TripPlan 생성 │
+│ │ │
+│ Google Places 좌표 매핑 (코루틴 병렬) │
+│ │ │
+│ 완료 알림 발송 │
+└────────────────────────────────────────────────────────────┘
+ │
+ ▼
GET /video/{id}/schedule → 일정표 + 장소 좌표 반환
+```
+### 이벤트 기반 비동기 흐름 — 왜 이렇게 분리?
+
+```
+VideoAnalyzeService VideoAnalyzeEventListener VideoAnalysisQueueConsumer
+───────────────────── ───────────────────────── ───────────────────────────
+@Transactional @Async + @TransactionalEvent 데몬 스레드 (부팅 시 시작)
+ │ Listener(AFTER_COMMIT) │
+ │ │ │
+ │ Events.raise(event) ────────────────► │ │
+ │ │ │
+ │ ... 트랜잭션 커밋 ... │ │
+ │ │ (커밋 후에야 호출됨) │
+ │ 202 응답 반환 ◄───────── │ │
+ │ │
+ │ queuePort.enqueue() ───────► │
+ │ │
+ │ dequeue → 분석
+ │ Gemini, Places, 알림
```
+**3 단계로 분리한 이유**:
+
+1. **`@TransactionalEventListener(AFTER_COMMIT)` — DB 커밋 후에만 큐 적재**
+ - `Events.raise()` 호출 시점이 아니라 **트랜잭션 커밋 직후** 이벤트가 전달됨
+ - 만약 `analyzeVideo` 트랜잭션이 롤백되면 이벤트도 발행되지 않음
+ - → DB 에 task 가 없는데 큐에만 좀비 이벤트가 남는 상황 방지 (consistency)
+
+2. **`@Async("VideoAnalyzeExecutor")` — 사용자 응답을 빠르게**
+ - 큐 적재 자체는 별도 스레드 풀에서
+ - HTTP 응답은 `Events.raise()` 만 마치고 곧장 202 반환 (수 ms)
+ - 사용자는 큐 적재를 기다리지 않음
+
+3. **Consumer 는 별도 데몬 스레드**
+ - HTTP 요청 흐름과 완전히 분리
+ - `PriorityBlockingQueue.poll(1초)` 로 큐를 폴링하며 dequeue
+ - quota 가드가 dequeue 자체를 차단할 수 있어 비용 폭주 시점에 깨끗이 멈춤
+
+**메시지 브로커 (Kafka/RabbitMQ) 대신 in-memory 큐를 쓴 이유**:
+- 단일 인스턴스 운영 → 분산 큐 불필요
+- in-memory `PriorityBlockingQueue` 가 USER/BATCH 우선순위 + FIFO 정렬을 단일 자료구조로 처리
+- 부팅 시 PENDING 인 task 를 DB 에서 다시 로드해 큐에 재적재 (`ApplicationInitializer`) → 인스턴스 재시작에도 누락 없음
+- 5분마다 `VideoAnalysisRetryJob` 이 stale PENDING 을 재 enqueue → 큐 자체가 사고로 비더라도 self-healing
+
+### POST 응답 형태
+
+`POST /video/analyze` 는 status 에 따라 응답이 달라집니다 (단일 shape `VideoAnalyzeResponse`).
+
+| status | HTTP | 응답 데이터 | 설명 |
+|---|---|---|---|
+| `COMPLETED` | 200 | 결과 인라인 (요약 / 일정 / 타임라인) | 추가 폴링 불필요 |
+| `INVALID` | 200 | id / url / status 만 | 자막 없음 등 영구 종료 |
+| `PENDING` / `PROCESSING` | 202 | id / url / status 만 | 클라이언트 폴링 |
+
### 동시 요청 처리
+같은 영상을 여러 사용자가 동시에 / 시차로 요청해도 **분석은 정확히 1회만 수행**하고, 각 요청자에게 **개별 TripPlan 을 생성**합니다.
+영상의 현재 상태에 따라 처리 분기가 달라지며, 분석 완료 시점에 race condition 이 발생해도 Lazy 보완으로 자가 복구합니다.
+
+#### 시나리오 1 — 신규 영상 (User A 가 처음 요청)
+
+`video_analysis_task` 도 없고 분석도 진행 중이 아닌 영상. **Task 생성 + 대기열 등록 + 이벤트 발행** 모두 수행.
+
+```mermaid
+sequenceDiagram
+ participant UA as User A
+ participant Svc as VideoAnalyzeService
+ participant DB as DB
+ participant Q as 분석 큐
+
+ UA->>Svc: POST /analyze (영상 X)
+ activate Svc
+ Note over Svc: @Transactional
+ Svc->>DB: SELECT task by url
+ DB-->>Svc: not found
+ Svc->>DB: INSERT task (PENDING)
+ Svc->>DB: INSERT trip_plan_request (User A)
+ Svc->>Q: VideoAnalyzeEvent 발행
(AFTER_COMMIT 에 enqueue)
+ Svc-->>UA: 202 Accepted
+ deactivate Svc
```
-같은 영상을 여러 사용자가 요청하는 경우:
-분석은 1회만 수행하고, 각 요청자의 여행 계획은 개별 생성
-User A: POST /analyze (영상 X) → 새 분석 시작, 대기열 등록, Event 발행
-User B: POST /analyze (영상 X) → PENDING 확인 → 대기열 등록 (Event 발행 안 함)
-...분석 완료...
-EventListener → 대기열 조회 → [User A, User B] → TripPlan 각각 생성
+#### 시나리오 2 — 분석 진행 중 영상 (User B 가 뒤따라 요청)
+
+`task` 가 이미 `PENDING` / `PROCESSING`. **대기열만 등록**하고 **이벤트는 발행 안 함** → 중복 분석 차단.
+
+```mermaid
+sequenceDiagram
+ participant UB as User B
+ participant Svc as VideoAnalyzeService
+ participant DB as DB
+ participant Q as 분석 큐
+
+ UB->>Svc: POST /analyze (영상 X)
+ activate Svc
+ Svc->>DB: SELECT task by url
+ DB-->>Svc: PENDING (이미 큐에 있음)
+ Svc->>DB: INSERT trip_plan_request (User B)
+ Note over Svc: 이벤트 발행 ❌
큐에 중복 enqueue 안 함
+ Svc-->>UB: 202 Accepted
+ deactivate Svc
+
+ Note over Q,DB: ... 분석 완료 후 ...
+ Q->>DB: 대기열 조회 → [User A, User B]
+ Q->>DB: 각자에게 TripPlan 생성
```
-```
-분석 완료 후 요청하는 경우:
+#### 시나리오 3 — 이미 완료된 영상 (User C 가 나중에 요청)
-User C: POST /analyze (영상 X) → COMPLETED 확인 → TripPlan 즉시 생성 (대기열 불필요)
-```
+`task.status = COMPLETED` 이고 분석 결과가 DB 에 있는 상태. **분석 큐 안 거치고 즉시 TripPlan 생성 + 결과 인라인 200**.
+```mermaid
+sequenceDiagram
+ participant UC as User C
+ participant Svc as VideoAnalyzeService
+ participant DB as DB
+
+ UC->>Svc: POST /analyze (영상 X)
+ activate Svc
+ Svc->>DB: SELECT task by url
+ DB-->>Svc: COMPLETED
+ Svc->>DB: INSERT TripPlan (User C)
+ Svc->>DB: SELECT 분석 결과 (요약 / 일정 / 타임라인)
+ Svc-->>UC: 200 OK + 결과 인라인
+ deactivate Svc
```
-경쟁 조건 보완:
-분석 완료 커밋과 대기열 조회 사이에 등록된 요청이 누락될 수 있음
-→ GET /schedule 조회 시 TripPlan이 없으면 즉시 생성 (Lazy 보완)
-→ EXISTS 쿼리는 (member_id, video_analysis_task_id) 커버링 인덱스로 처리
+#### Race condition + Lazy 보완
+
+**문제**: 분석 완료 커밋과 Consumer 의 대기열 조회 사이의 좁은 창에서 들어온 요청은 `trip_plan_request` 에 들어가긴 하지만 Consumer 가 이미 대기열을 조회한 뒤라 **누락**될 수 있음.
+
+```mermaid
+sequenceDiagram
+ participant UD as User D
+ participant Svc as VideoAnalyzeService
+ participant DB as DB
+ participant Q as Consumer
+ Note over Q: 분석 진행 중...
+ Q->>DB: UPDATE task SET status = COMPLETED
+ Q->>DB: COMMIT
+
+ rect rgb(255, 240, 240)
+ Note over UD,DB: race window — Consumer 가 대기열 조회하기 직전
+ UD->>Svc: POST /analyze (영상 X)
+ Svc->>DB: SELECT task → COMPLETED 보임
+ Note over Svc: 시나리오 3 으로 분기되어
TripPlan 즉시 생성됨 ✓
+ Svc-->>UD: 200 OK + 결과
+ end
+
+ Q->>DB: 대기열 조회 → User D 빠질 수 있음
+ Note over Q: 다른 분기에서 누락 시?
아래 Lazy 보완으로 복구
```
+**보완 — GET `/schedule` Lazy 생성**: 어떤 경로로 누락되더라도, 사용자가 일정표를 조회하는 시점에 TripPlan 이 없으면 즉시 생성합니다. 두 번째 요청부터는 정상.
+
+```mermaid
+sequenceDiagram
+ participant U as User
+ participant Ctl as ScheduleController
+ participant DB as DB
+
+ U->>Ctl: GET /video/{id}/schedule
+ activate Ctl
+ Ctl->>DB: SELECT TripPlan WHERE member_id = ?
+ DB-->>Ctl: not found
+ Note over Ctl: Lazy 보완 발동
+ Ctl->>DB: INSERT TripPlan (User)
+ Ctl->>DB: SELECT 분석 결과 + 좌표
+ Ctl-->>U: 일정표 + 장소 좌표
+ deactivate Ctl
+```
+
+**왜 EXISTS 쿼리가 빠른가**: `trip_plan` 의 `(member_id, video_analysis_task_id)` 가 **유니크 인덱스 + 커버링 인덱스** 라, "이 멤버의 plan 이 있는지" 체크가 인덱스 만으로 끝나 테이블 접근 0.
+
### 상태 전이
```
PENDING ──→ COMPLETED (분석 성공)
- ├──→ INVALID (여행 영상 아님)
- └──→ FAILED (AI 분석 오류) ──→ 재요청 시 PENDING 복원
+ ├──→ INVALID (자막 없음 / 여행 영상 아님)
+ ├──→ FAILED (AI 분석 오류) ──→ 재요청 시 PENDING 복원
+ └──→ PENDING (IP 차단 / 일시 오류) ──→ 5분 후 재시도 배치가 픽업
+```
+
+---
+
+## 자막 추출 — 프록시 라운드로빈 + 실패 분류
+
+YouTube 자막 API 는 IP 차단 / 지역 차단 / 영상 자체의 자막 부재 등 실패 원인이 다양한데, 라이브러리 (`youtube-transcript-api`) 가 모두 단일 예외 타입으로 던져 원인 구분이 안 됩니다.
+이 시스템은 **(1) 프록시 라운드로빈으로 IP 차단을 자동 우회**하고, **(2) sentinel ping 으로 모호한 실패 원인을 분류**해 재시도 가치가 있는 실패 (`PENDING`) 와 영구 종료 (`INVALID`) 를 구별합니다.
+
+### 프록시 라운드로빈 (webshare)
+
+```mermaid
+sequenceDiagram
+ participant Caller as 요청
+ participant Proxy as ProxyYoutubeClient
+ participant YT as YouTube
+
+ Note over Proxy: current = KR (sticky)
+ Caller->>Proxy: videoId
+ Proxy->>YT: GET (한국 IP)
+ YT-->>Proxy: HTTP 200 (자막 ok)
+ Proxy-->>Caller: 자막
+ Note over Proxy: 다음 요청도 같은 IP 유지
(sticky session)
+ Caller->>Proxy: videoId
+ Proxy->>YT: GET (한국 IP)
+ YT-->>Proxy: HTTP 429 / 403 (차단)
+ Note over Proxy: in-flight swap
→ JP 로 즉시 교체
+ Proxy->>YT: GET (일본 IP)
+ YT-->>Proxy: HTTP 200
+ Proxy-->>Caller: 자막
+```
+
+**프록시 풀 우선순위** (한국 영상이 주력 콘텐츠라 한국 IP 우선):
+
+```
+KR (2개) → JP (1개) → TW (2개) → MY (1개) → 나머지 (총 10개 sticky)
+```
+
+- **Sticky session**: 같은 IP 를 계속 사용. 매번 새 IP 로 교체하면 라운드로빈 효율보다 초기화 비용이 큼
+- **429 / 403 시 즉시 swap**: 차단 응답을 받자마자 in-flight 요청을 다음 IP 로 교체. 한 번 차단된 IP 는 같은 요청 처리에서 다시 안 씀
+- **`IOException` catch + 60초 request timeout**: 프록시 측 hang 시 무한 대기 방지
+
+### 모호 실패 분류 (sentinel ping)
+
+라이브러리가 던지는 단일 예외 (`TranscriptRetrievalException`) 만으로는 다음을 구별할 수 없습니다:
+
+| 실패 원인 | 재시도 가치 | 적합한 status |
+|---|---|---|
+| 영상 자체에 자막 없음 / 영상 비공개 | ❌ 재시도 무의미 | `INVALID` (영구 종료) |
+| 일시적 IP 차단 / 전 프록시 일시 차단 | ✅ 시간 지나면 회복 | `PENDING` (5분 후 재시도) |
+
+→ **자막이 항상 존재하는 sentinel 영상** (`TVM6Nswlfbg`) 으로 ping 을 날려 분류:
+
+```mermaid
+flowchart TD
+ A[자막 추출 시도] --> B{HTTP 응답?}
+ B -->|200| C[자막 추출 성공 ✓]
+ B -->|429 / 403| D[다음 프록시 swap
위 라운드로빈으로 회귀]
+ B -->|TranscriptRetrievalException
원인 모호| E[sentinel 영상 ping]
+ E --> F{sentinel 응답?}
+ F -->|HTTP 200 정상
우리 프록시는 살아있음| G["영상 고유 문제
(자막 없음 / 비공개)
status = INVALID
재시도 안 함"]
+ F -->|sentinel 도 실패| H["전 프록시 IP 동시 차단 확정
status = PENDING
5분 후 VideoAnalysisRetryJob 이 재 enqueue"]
+
+ style C fill:#d4edda,stroke:#155724
+ style G fill:#f8d7da,stroke:#721c24
+ style H fill:#fff3cd,stroke:#856404
+```
+
+### 이중 안전망 효과
+
+```
+1차 — 프록시 라운드로빈
+ 단일 IP 차단을 자동 우회. 사용자에게 실패가 안 보임.
+
+2차 — sentinel ping
+ 라운드로빈으로도 회복 안 되는 상황에서
+ "영상 문제 vs 전 프록시 차단" 구분 → 재시도 정책 결정.
+```
+
+→ IP 차단으로 인한 재시도 가능 실패와 자막 부재로 인한 영구 실패가 섞이지 않게 됨. 결과: `PENDING` row 가 정확히 "회복 가능" 한 건만 남음.
+
+---
+
+## 큐 우선순위
+
+큐 한 개 안에서 사용자 요청과 시스템 배치를 분리해 사용자 응답을 보장합니다.
+
+### Source — 호출 출처
+
+| Source | priority | 발생 |
+|---|---|---|
+| `USER` | 0 (높음) | `POST /video/analyze` 등 사용자 직접 요청 |
+| `BATCH` | 10 (낮음) | YouTube 정기 수집 / stranded 백필 등 시스템 트리거 |
+
+`task.source` 컬럼에 audit 으로 영구 저장. 재시도 시 원래 priority 보존.
+
+### 정렬 보장
+
+`PriorityBlockingQueue` + sequence tiebreaker 로 다음을 보장:
+
+1. **priority 순**: USER 가 항상 BATCH 보다 먼저 dequeue
+2. **동일 priority 내 FIFO**: 단조 증가 sequence 로 들어온 순서 보존
+
+```
+들어온 순서: USER-A, BATCH-X, USER-B, USER-C, BATCH-Y
+ │ │ │ │ │
+ seq=1 seq=2 seq=3 seq=4 seq=5
+
+dequeue 순서:
+ USER-A (priority 0, seq 1) ←─┐
+ USER-B (priority 0, seq 3) │ USER 그룹 — FIFO
+ USER-C (priority 0, seq 4) ←─┘
+ BATCH-X (priority 10, seq 2) ←─┐
+ BATCH-Y (priority 10, seq 5) ←─┘ BATCH 그룹 — FIFO
```
---
+## 외부 API 비용 보호
+
+API 호출 비용 폭주를 사전 차단하기 위한 가드 + 카운터 + 알림 시스템.
-### 인기 여행 영상
+### 흐름
```
-YouTubeCollectJob
+어댑터 메서드 (예: VideoAnalyzeAdapter.analyzeFromTranscript)
+ │
+ ├─ ApiQuotaGuardService.isExceeded(GEMINI)
+ │ ├─ true → throw LinktripException (호출 안 함)
+ │ └─ false → 다음
│
- ├── 24개 키워드 중 5개 랜덤 선택
+ ├─ 외부 API 호출 (Gemini / YouTube / Places)
+ │
+ └─ ApiCallCounterService.recordSuccess(GEMINI)
+ ├─ DB UPSERT +1 (atomic)
+ └─ 누적 비용 = Σ (API 별 호출 수 × 단가)
+ └─ 마지막 알림 임계값 + 1000원 넘었는지 체크
+ └─ 넘었다면 Discord 알림 + 임계값 갱신
+```
+
+### 이중 안전망
+
+```
+1차: VideoAnalysisQueueConsumer 의 loop guard
+ ├─ dequeue 직전 isAnyApiExceeded() 체크
+ └─ 초과 시 60초 sleep, 큐 자체를 멈춤 (가장 빠른 방어)
+
+2차: 각 어댑터의 호출 직전 가드
+ └─ race / 다중 호출 경로 보호 (예: PlaceEnrich 코루틴 병렬)
+
+3차: 사후 카운팅
+ └─ 호출 직후 DB 적재 + 비용 임계값 알림
+```
+
+### 설정 (application.yml)
+
+```yaml
+api:
+ daily-quota: # 호출 한도 — 초과 시 dequeue 차단
+ gemini: 500
+ youtube-data: 9000
+ google-places: 3000
+ cost-per-call-krw: # 호출당 단가 (KRW) — 누적 비용 계산용
+ gemini: 50
+ youtube-data: 0
+ google-places: 7
+```
+
+### 자정 reset
+
+`call_date` 컬럼이 DATE 타입이라, 자정 지나면 새 row 가 자동 생성되어 가드/임계값이 초기화됩니다.
+
+---
+
+## 인기 영상 / 크리에이터 수집
+
+### YouTube 정기 수집
+
+```
+YouTubeCollectScheduler (매시간 cron)
+ │
+ ├── 키워드 풀에서 region 별 batch 만큼 순차 선택
├── YouTube Search API → 키워드당 최대 10개 영상
├── DB 중복 체크 (videoId)
├── YouTube Videos API → 조회수, 좋아요, 영상 길이
├── 메타데이터 태깅 (region, country, city, theme)
- └── DB 저장 + @CacheEvict
+ ├── DB 저장 + @CacheEvict
+ └── 신규 영상 → BATCH 우선순위로 분석 큐 enqueue
```
+### 미처리 영상 자동 분석 (백필)
+
+```
+VideoAnalysisBackfillScheduler (10분 cron, 환경별 활성화)
+ │
+ └─ youtube_video LEFT JOIN video_analysis_task
+ └─ task 가 없는 (= 한 번도 분석 안 된) 영상 5건
+ └─ BATCH 우선순위로 분석 큐 enqueue
+```
+
+`@ConditionalOnProperty(batch.video-analysis-backfill.enabled=true)` — 환경별 on/off.
+
### 인기 크리에이터
```
@@ -215,6 +580,16 @@ PlaceEnrichRetryJob
└── Google Places API 재검색 → 성공 시 좌표 저장 / 실패 시 count++
```
+### PENDING 분석 재시도
+
+```
+VideoAnalysisRetryJobScheduler (5분 cron)
+ │
+ └─ status=PENDING + createdAt < now-5분
+ └─ task.source 그대로 보존하여 재 enqueue
+ (BATCH 로 만들어진 task 가 USER 로 promote 되지 않음)
+```
+
---
## API
@@ -222,7 +597,7 @@ PlaceEnrichRetryJob
| Method | Path | 설명 |
|--------|------|------|
| POST | `/api/auth/login` | 디바이스 시리얼 기반 로그인 (신규 시 자동 가입) |
-| POST | `/api/video/analyze` | 영상 분석 요청 (비동기, 202) |
+| POST | `/api/video/analyze` | 영상 분석 요청. COMPLETED면 결과 인라인(200) / 그 외 status 반환(202) |
| GET | `/api/video/{id}/schedule` | 분석 결과 일정표 + 장소 좌표 |
| GET | `/api/video/discover/category` | 인기 여행 영상 (국가/지역 필터) |
| GET | `/api/video/discover/theme` | 테마별 여행 영상 (커서 페이징, 40건) |
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt
new file mode 100644
index 0000000..bedcbcc
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/notification/CostAlertEvent.kt
@@ -0,0 +1,13 @@
+package com.linktrip.application.domain.notification
+
+import com.linktrip.application.domain.quota.ApiCostBreakdown
+import java.time.LocalDate
+
+/**
+ * 외부 API 누적 비용이 임계값(1000원 단위)을 새로 넘었을 때 발송되는 알림 이벤트.
+ */
+data class CostAlertEvent(
+ val date: LocalDate,
+ val thresholdKrw: Long,
+ val breakdown: ApiCostBreakdown,
+)
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt
new file mode 100644
index 0000000..a2ff8cf
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCount.kt
@@ -0,0 +1,31 @@
+package com.linktrip.application.domain.quota
+
+import com.linktrip.application.domain.common.IdGenerator
+import java.time.LocalDate
+import java.time.LocalDateTime
+
+/**
+ * 특정 [apiType] 의 [callDate] 일자 호출 누적 카운트.
+ * UPSERT 의 UPDATE 경로에서는 SQL 측 `call_count + 1` 로 누적되며 [callCount] 값은 사용되지 않는다.
+ */
+data class ApiCallCount(
+ val id: String,
+ val apiType: ApiType,
+ val callDate: LocalDate,
+ val callCount: Long,
+ val createdAt: LocalDateTime = LocalDateTime.now(),
+ val updatedAt: LocalDateTime = LocalDateTime.now(),
+) {
+ companion object {
+ fun create(
+ apiType: ApiType,
+ callDate: LocalDate,
+ ): ApiCallCount =
+ ApiCallCount(
+ id = IdGenerator.generate(),
+ apiType = apiType,
+ callDate = callDate,
+ callCount = 1L,
+ )
+ }
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt
new file mode 100644
index 0000000..7dc045f
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCallCounterService.kt
@@ -0,0 +1,78 @@
+package com.linktrip.application.domain.quota
+
+import com.linktrip.application.domain.notification.CostAlertEvent
+import com.linktrip.application.port.output.notification.NotificationPort
+import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort
+import com.linktrip.application.port.output.quota.ApiCostPolicyPort
+import com.linktrip.application.port.output.quota.ApiDailyCostAlertPersistencePort
+import mu.KotlinLogging
+import org.springframework.stereotype.Service
+import java.time.LocalDate
+
+private val logger = KotlinLogging.logger {}
+
+/**
+ * 외부 API 호출 성공 시의 부수 처리 (카운트 적재 + 누적 비용 임계값 알림) 를 담당한다.
+ *
+ * 서비스 레이어에서 호출 직후 한 줄로 호출:
+ * ```
+ * val r = port.callApi(...)
+ * apiCallCounterService.recordSuccess(ApiType.X)
+ * ```
+ *
+ * 실패 처리 정책:
+ * - **카운트 실패는 그대로 throw**. 카운트가 실제 호출 수와 어긋나면 가드가 한도 초과를 감지 못 해 비용 폭주 위험.
+ * 본 PR 의 핵심 가치 (비용 보호) 를 지키려면 fail-loud 가 맞다.
+ * - **알림 실패는 로그만**. 알림이 늦어져도 가드는 정상 작동하므로 비용 보호에는 영향 없음.
+ */
+@Service
+class ApiCallCounterService(
+ private val countPort: ApiCallCountPersistencePort,
+ private val costPolicyPort: ApiCostPolicyPort,
+ private val alertPort: ApiDailyCostAlertPersistencePort,
+ private val notificationPort: NotificationPort,
+) {
+ fun recordSuccess(apiType: ApiType) {
+ countPort.increment(ApiCallCount.create(apiType, LocalDate.now()))
+
+ try {
+ notifyIfCostThresholdCrossed()
+ } catch (e: Exception) {
+ logger.warn(e) { "비용 알림 체크 실패: $apiType" }
+ }
+ }
+
+ private fun notifyIfCostThresholdCrossed() {
+ val today = LocalDate.now()
+ val breakdown = computeCostBreakdown(today)
+ val lastSent = alertPort.findLastSentThresholdKrw(today) ?: 0L
+
+ if (breakdown.totalKrw < lastSent + THRESHOLD_KRW) return
+
+ val newThreshold = (breakdown.totalKrw / THRESHOLD_KRW) * THRESHOLD_KRW
+ notificationPort.sendCostAlert(
+ CostAlertEvent(
+ date = today,
+ thresholdKrw = newThreshold,
+ breakdown = breakdown,
+ ),
+ )
+ alertPort.upsert(ApiDailyCostAlert.create(today, newThreshold))
+ logger.info { "비용 임계값 알림 발송: total=${breakdown.totalKrw}원, threshold=${newThreshold}원" }
+ }
+
+ private fun computeCostBreakdown(date: LocalDate): ApiCostBreakdown {
+ val countByApi = countPort.findAllByDate(date).associateBy { it.apiType }
+ val items =
+ ApiType.entries.map { apiType ->
+ val count = countByApi[apiType]?.callCount ?: 0L
+ ApiCostItem(apiType, count * costPolicyPort.perCallKrw(apiType))
+ }
+ return ApiCostBreakdown(items)
+ }
+
+ companion object {
+ /** 알림 임계값 단위 (KRW). 1000원마다 한 번 발송. */
+ private const val THRESHOLD_KRW = 1000L
+ }
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt
new file mode 100644
index 0000000..42b6b08
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiCostBreakdown.kt
@@ -0,0 +1,19 @@
+package com.linktrip.application.domain.quota
+
+/**
+ * 특정 일자의 API 별 누적 비용 (KRW) 분해.
+ * 합계 / 0원 제외 필터 등 표현/판정 로직을 도메인에 포함.
+ */
+data class ApiCostBreakdown(
+ val items: List,
+) {
+ val totalKrw: Long = items.sumOf { it.costKrw }
+
+ /** 비용 0원인 API 는 제외 (Discord 메시지에 노출할 항목만). */
+ fun nonZero(): List = items.filter { it.costKrw > 0L }
+}
+
+data class ApiCostItem(
+ val apiType: ApiType,
+ val costKrw: Long,
+)
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt
new file mode 100644
index 0000000..bf01b11
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiDailyCostAlert.kt
@@ -0,0 +1,29 @@
+package com.linktrip.application.domain.quota
+
+import com.linktrip.application.domain.common.IdGenerator
+import java.time.LocalDate
+import java.time.LocalDateTime
+
+/**
+ * 일자별로 마지막으로 발송한 비용 알림 임계값 (KRW).
+ * 같은 임계값 구간에 대해 알림이 두 번 나가지 않게 추적하는 용도.
+ */
+data class ApiDailyCostAlert(
+ val id: String,
+ val alertDate: LocalDate,
+ val lastSentThresholdKrw: Long,
+ val createdAt: LocalDateTime = LocalDateTime.now(),
+ val updatedAt: LocalDateTime = LocalDateTime.now(),
+) {
+ companion object {
+ fun create(
+ alertDate: LocalDate,
+ lastSentThresholdKrw: Long,
+ ): ApiDailyCostAlert =
+ ApiDailyCostAlert(
+ id = IdGenerator.generate(),
+ alertDate = alertDate,
+ lastSentThresholdKrw = lastSentThresholdKrw,
+ )
+ }
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt
new file mode 100644
index 0000000..0b58691
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardService.kt
@@ -0,0 +1,36 @@
+package com.linktrip.application.domain.quota
+
+import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort
+import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort
+import com.linktrip.common.exception.ExceptionCode
+import com.linktrip.common.exception.LinktripException
+import mu.KotlinLogging
+import org.springframework.stereotype.Service
+import java.time.LocalDate
+
+private val logger = KotlinLogging.logger {}
+
+/**
+ * 외부 API 일일 호출 한도 가드.
+ * 서비스 레이어에서 호출 직전 [isExceeded] 로 체크, 큐 컨슈머는 [isAnyApiExceeded] 로 dequeue 차단.
+ * 자정 지나면 [LocalDate.now] 가 바뀌어 자동 해제.
+ */
+@Service
+class ApiQuotaGuardService(
+ private val countPort: ApiCallCountPersistencePort,
+ private val policyPort: ApiQuotaPolicyPort,
+) {
+ fun isAnyApiExceeded(): Boolean = ApiType.entries.any { isExceeded(it) }
+
+ fun isExceeded(apiType: ApiType): Boolean {
+ val limit =
+ policyPort.dailyLimit(apiType)
+ ?: throw LinktripException(ExceptionCode.INTERNAL_QUOTA_POLICY_NOT_CONFIGURED)
+ val current = countPort.findByApiTypeAndDate(apiType, LocalDate.now())?.callCount ?: 0L
+ val exceeded = current >= limit
+ if (exceeded) {
+ logger.warn { "$apiType 일일 한도 초과: 현재=$current / 한도=$limit" }
+ }
+ return exceeded
+ }
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt
new file mode 100644
index 0000000..4f639c4
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/quota/ApiType.kt
@@ -0,0 +1,16 @@
+package com.linktrip.application.domain.quota
+
+/**
+ * 비용 통제가 필요한 외부 API 종류.
+ * 새 외부 API 도입 시 enum 값 + properties 의 daily-quota / cost-per-call 항목 추가.
+ */
+enum class ApiType {
+ /** Google Vertex AI Gemini — 토큰 기반 종량제 */
+ GEMINI,
+
+ /** YouTube Data API v3 — 일일 quota */
+ YOUTUBE_DATA,
+
+ /** Google Places Text Search — 호출당 과금 */
+ GOOGLE_PLACES,
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt
index dd015af..c0cf69d 100644
--- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/trip/TripPlanService.kt
@@ -13,8 +13,10 @@ import com.linktrip.application.port.output.persistence.TripPlanPersistencePort
import com.linktrip.application.port.output.persistence.TripPlanRequestPersistencePort
import com.linktrip.common.exception.ExceptionCode
import com.linktrip.common.exception.LinktripException
+import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
+import java.time.LocalDate
import java.time.LocalDateTime
@Service
@@ -24,6 +26,8 @@ class TripPlanService(
private val itineraryItemPort: TravelItineraryItemPersistencePort,
private val requestPort: TripPlanRequestPersistencePort,
private val hashtagPort: HashtagPersistencePort,
+ @Value("\${member.daily-video-analyze-limit:10}")
+ private val dailyVideoAnalyzeLimit: Long,
) : TripPlanUseCase {
@Transactional
override fun registerRequest(
@@ -31,9 +35,21 @@ class TripPlanService(
videoAnalysisTaskId: String,
) {
if (requestPort.existsByMemberIdAndVideoAnalysisTaskId(memberId, videoAnalysisTaskId)) return
+ ensureDailyVideoAnalyzeLimitNotExceeded(memberId)
requestPort.save(TripPlanRequest.create(memberId, videoAnalysisTaskId))
}
+ private fun ensureDailyVideoAnalyzeLimitNotExceeded(memberId: String) {
+ val today = LocalDate.now()
+ val count = requestPort.countByMemberIdAndDate(memberId, today)
+ if (count >= dailyVideoAnalyzeLimit) {
+ throw LinktripException(
+ ExceptionCode.TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY,
+ "오늘 영상 분석 요청 한도($count/$dailyVideoAnalyzeLimit) 초과",
+ )
+ }
+ }
+
@Transactional
override fun createFromAnalysisIfAbsent(
memberId: String,
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt
index e960762..67ab601 100644
--- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumer.kt
@@ -1,5 +1,6 @@
package com.linktrip.application.domain.video
+import com.linktrip.application.domain.quota.ApiQuotaGuardService
import com.linktrip.application.domain.trip.TripPlanService
import com.linktrip.application.port.output.external.VideoAnalysisNotificationPort
import com.linktrip.application.port.output.external.VideoAnalyzePort
@@ -26,6 +27,7 @@ class VideoAnalysisQueueConsumer(
private val tripPlanRequestPort: TripPlanRequestPersistencePort,
private val tripPlanService: TripPlanService,
private val rateLimitBucketStore: RateLimitBucketStore,
+ private val apiQuotaGuardService: ApiQuotaGuardService,
) {
fun startConsuming() {
Thread({ consumeLoop() }, "VideoAnalysisQueueConsumer").apply {
@@ -38,6 +40,11 @@ class VideoAnalysisQueueConsumer(
private fun consumeLoop() {
while (!Thread.currentThread().isInterrupted) {
try {
+ if (apiQuotaGuardService.isAnyApiExceeded()) {
+ logger.warn { "외부 API 일일 한도 초과 — ${QUOTA_BACKOFF_MS / 1000}초 대기" }
+ Thread.sleep(QUOTA_BACKOFF_MS)
+ continue
+ }
val event = videoAnalysisQueuePort.dequeue() ?: continue
processAnalysis(event)
} catch (_: InterruptedException) {
@@ -209,5 +216,8 @@ class VideoAnalysisQueueConsumer(
companion object {
private const val RATE_LIMIT_KEY = "gemini-api"
+
+ /** API 한도 초과 시 다음 가드 체크까지 대기 시간. 자정 후 재개 지연 / 로그 노이즈 절충. */
+ private const val QUOTA_BACKOFF_MS = 60_000L
}
}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt
index c50163c..afd31d7 100644
--- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/notification/NotificationPort.kt
@@ -1,5 +1,6 @@
package com.linktrip.application.port.output.notification
+import com.linktrip.application.domain.notification.CostAlertEvent
import com.linktrip.application.domain.notification.ExceptionAlertEvent
/**
@@ -8,4 +9,6 @@ import com.linktrip.application.domain.notification.ExceptionAlertEvent
*/
interface NotificationPort {
fun sendExceptionAlert(event: ExceptionAlertEvent)
+
+ fun sendCostAlert(event: CostAlertEvent)
}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt
index 78b255f..d66fc61 100644
--- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/TripPlanRequestPersistencePort.kt
@@ -1,6 +1,7 @@
package com.linktrip.application.port.output.persistence
import com.linktrip.application.domain.trip.TripPlanRequest
+import java.time.LocalDate
interface TripPlanRequestPersistencePort {
fun save(request: TripPlanRequest): TripPlanRequest
@@ -15,4 +16,9 @@ interface TripPlanRequestPersistencePort {
fun findMemberIdsByVideoAnalysisTaskId(videoAnalysisTaskId: String): List
fun saveAll(requests: List)
+
+ fun countByMemberIdAndDate(
+ memberId: String,
+ date: LocalDate,
+ ): Long
}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt
new file mode 100644
index 0000000..7a616f0
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCallCountPersistencePort.kt
@@ -0,0 +1,20 @@
+package com.linktrip.application.port.output.quota
+
+import com.linktrip.application.domain.quota.ApiCallCount
+import com.linktrip.application.domain.quota.ApiType
+import java.time.LocalDate
+
+/**
+ * 일별 API 호출 카운트 영속화 포트.
+ * 구현체는 (api_type, call_date) 단일 row 를 유지하며 호출 횟수를 atomic 하게 +1 한다.
+ */
+interface ApiCallCountPersistencePort {
+ fun increment(apiCallCount: ApiCallCount)
+
+ fun findByApiTypeAndDate(
+ apiType: ApiType,
+ date: LocalDate,
+ ): ApiCallCount?
+
+ fun findAllByDate(date: LocalDate): List
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt
new file mode 100644
index 0000000..1fde49c
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiCostPolicyPort.kt
@@ -0,0 +1,11 @@
+package com.linktrip.application.port.output.quota
+
+import com.linktrip.application.domain.quota.ApiType
+
+/**
+ * API 별 호출 1회당 추정 비용 (KRW). yml 등 외부 소스에서 제공.
+ * 0 = 비용 추적 비활성 (구현체는 항상 non-null Long 을 반환).
+ */
+interface ApiCostPolicyPort {
+ fun perCallKrw(apiType: ApiType): Long
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt
new file mode 100644
index 0000000..8d42dc7
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiDailyCostAlertPersistencePort.kt
@@ -0,0 +1,14 @@
+package com.linktrip.application.port.output.quota
+
+import com.linktrip.application.domain.quota.ApiDailyCostAlert
+import java.time.LocalDate
+
+/**
+ * 일자별로 마지막으로 알림 발송한 누적 비용 임계값을 추적한다.
+ * 같은 임계값(예: 5000원) 에 대해 알림이 한 번만 발송되도록 함.
+ */
+interface ApiDailyCostAlertPersistencePort {
+ fun findLastSentThresholdKrw(date: LocalDate): Long?
+
+ fun upsert(alert: ApiDailyCostAlert)
+}
diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt
new file mode 100644
index 0000000..08af801
--- /dev/null
+++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/quota/ApiQuotaPolicyPort.kt
@@ -0,0 +1,11 @@
+package com.linktrip.application.port.output.quota
+
+import com.linktrip.application.domain.quota.ApiType
+
+/**
+ * API 별 일일 호출 한도 정책 (yml 등 외부 소스에서 제공).
+ * null = 한도 미설정 = 가드 비활성.
+ */
+interface ApiQuotaPolicyPort {
+ fun dailyLimit(apiType: ApiType): Long?
+}
diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt
new file mode 100644
index 0000000..f113a5f
--- /dev/null
+++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiCallCounterServiceTest.kt
@@ -0,0 +1,176 @@
+package com.linktrip.application.domain.quota
+
+import com.linktrip.application.domain.notification.CostAlertEvent
+import com.linktrip.application.port.output.notification.NotificationPort
+import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort
+import com.linktrip.application.port.output.quota.ApiCostPolicyPort
+import com.linktrip.application.port.output.quota.ApiDailyCostAlertPersistencePort
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.InjectMocks
+import org.mockito.Mock
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import java.time.LocalDate
+
+@ExtendWith(MockitoExtension::class)
+class ApiCallCounterServiceTest {
+ @Mock
+ lateinit var countPort: ApiCallCountPersistencePort
+
+ @Mock
+ lateinit var costPolicyPort: ApiCostPolicyPort
+
+ @Mock
+ lateinit var alertPort: ApiDailyCostAlertPersistencePort
+
+ @Mock
+ lateinit var notificationPort: NotificationPort
+
+ @InjectMocks
+ lateinit var service: ApiCallCounterService
+
+ @Test
+ fun `recordSuccess 는 ApiType 과 오늘 날짜로 카운트를 increment 한다`() {
+ stubBreakdown(geminiCount = 0L)
+
+ service.recordSuccess(ApiType.GEMINI)
+
+ val captor = argumentCaptor()
+ verify(countPort).increment(captor.capture())
+ assertEquals(ApiType.GEMINI, captor.firstValue.apiType)
+ assertEquals(LocalDate.now(), captor.firstValue.callDate)
+ assertEquals(1L, captor.firstValue.callCount)
+ }
+
+ @Test
+ fun `누적 비용이 임계값 1000원 미달이면_Discord 알림과 임계값 갱신을 모두 수행하지 않는다`() {
+ // 30 * 50 = 1500 → 1500 < (lastSent=0) + 1000 = false → 알림 발송 케이스
+ // 일부러 미달 케이스: 10 * 50 = 500 < 1000 → 발송 X
+ stubBreakdown(geminiCount = 10L)
+ whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null)
+
+ service.recordSuccess(ApiType.GEMINI)
+
+ verify(notificationPort, never()).sendCostAlert(any())
+ verify(alertPort, never()).upsert(any())
+ }
+
+ @Test
+ fun `누적 비용이 첫 1000원 임계값을 넘으면_thresholdKrw 1000 으로 알림을 발송하고 lastSent 를 갱신한다`() {
+ // 30 * 50 = 1500 → newThreshold = (1500 / 1000) * 1000 = 1000
+ stubBreakdown(geminiCount = 30L)
+ whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null)
+
+ service.recordSuccess(ApiType.GEMINI)
+
+ val eventCaptor = argumentCaptor()
+ verify(notificationPort).sendCostAlert(eventCaptor.capture())
+ assertEquals(1000L, eventCaptor.firstValue.thresholdKrw)
+ assertEquals(1500L, eventCaptor.firstValue.breakdown.totalKrw)
+
+ val alertCaptor = argumentCaptor()
+ verify(alertPort).upsert(alertCaptor.capture())
+ assertEquals(1000L, alertCaptor.firstValue.lastSentThresholdKrw)
+ }
+
+ @Test
+ fun `누적 비용이 0원에서 5500원으로 한 번에 점프하면_5000원 임계값으로 단 1회만 발송한다 (중간 임계값 압축)`() {
+ // 110 * 50 = 5500 → newThreshold = 5000. 1000/2000/3000/4000 알림은 의도적으로 압축됨.
+ stubBreakdown(geminiCount = 110L)
+ whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null)
+
+ service.recordSuccess(ApiType.GEMINI)
+
+ val captor = argumentCaptor()
+ verify(notificationPort).sendCostAlert(captor.capture())
+ assertEquals(5000L, captor.firstValue.thresholdKrw)
+ // 알림은 단 1회
+ verify(notificationPort, org.mockito.Mockito.times(1)).sendCostAlert(any())
+ }
+
+ @Test
+ fun `같은 임계값 구간 내 재호출은_중복 알림을 발송하지 않는다 (lastSent=1000 + 누적=1200 → return)`() {
+ // 1200 < (lastSent=1000) + 1000 = 2000 → 발송 X
+ stubBreakdown(geminiCount = 24L) // 24 * 50 = 1200
+ whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(1000L)
+
+ service.recordSuccess(ApiType.GEMINI)
+
+ verify(notificationPort, never()).sendCostAlert(any())
+ }
+
+ @Test
+ fun `Discord 알림 발송이 실패해도_recordSuccess 는 외부로 예외를 전파하지 않는다 (알림은 best-effort)`() {
+ // increment 는 이미 성공한 상태인데, 알림 실패로 호출자가 깨지면 외부 API 결과까지 손실되어 손해가 큼.
+ // 메모상 알림은 log only 정책.
+ stubBreakdown(geminiCount = 30L)
+ whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null)
+ whenever(notificationPort.sendCostAlert(any())).thenThrow(RuntimeException("Discord 다운"))
+
+ // throw 하지 않고 정상 종료
+ service.recordSuccess(ApiType.GEMINI)
+
+ // increment 는 정상 호출되어야 함 (카운트 자체는 적재됨)
+ verify(countPort).increment(any())
+ }
+
+ @Test
+ fun `카운트 increment 자체가 실패하면_예외를 그대로 전파한다 (fail-loud - 가드 신뢰성)`() {
+ // counter 의 drift 는 가드 신뢰성을 깨뜨려 비용 폭주로 이어지므로 fail-loud.
+ whenever(countPort.increment(any())).thenThrow(RuntimeException("DB 다운"))
+
+ assertThrows {
+ service.recordSuccess(ApiType.GEMINI)
+ }
+ }
+
+ @Test
+ fun `비용 breakdown 은 findAllByDate 로 1회만 조회되어야 한다 (N+1 회피)`() {
+ // ApiType 별로 findByApiTypeAndDate 를 N 번 부르지 않고, 일자 기준 1쿼리로 모두 가져온다.
+ stubBreakdown(geminiCount = 30L)
+ whenever(alertPort.findLastSentThresholdKrw(LocalDate.now())).thenReturn(null)
+
+ service.recordSuccess(ApiType.GEMINI)
+
+ verify(countPort).findAllByDate(LocalDate.now())
+ verify(countPort, never()).findByApiTypeAndDate(any(), any())
+ }
+
+ /**
+ * 단가는 GEMINI=50, YOUTUBE_DATA=0, GOOGLE_PLACES=7 (yml 기본값과 동일) 로 고정.
+ * 각 카운트는 인자로 받아 totalKrw 를 의도적으로 조절.
+ */
+ private fun stubBreakdown(
+ geminiCount: Long = 0L,
+ youtubeDataCount: Long = 0L,
+ googlePlacesCount: Long = 0L,
+ ) {
+ whenever(countPort.findAllByDate(LocalDate.now())).thenReturn(
+ listOf(
+ ApiCallCount(id = "g", apiType = ApiType.GEMINI, callDate = LocalDate.now(), callCount = geminiCount),
+ ApiCallCount(
+ id = "y",
+ apiType = ApiType.YOUTUBE_DATA,
+ callDate = LocalDate.now(),
+ callCount = youtubeDataCount,
+ ),
+ ApiCallCount(
+ id = "p",
+ apiType = ApiType.GOOGLE_PLACES,
+ callDate = LocalDate.now(),
+ callCount = googlePlacesCount,
+ ),
+ ),
+ )
+ whenever(costPolicyPort.perCallKrw(ApiType.GEMINI)).thenReturn(50L)
+ whenever(costPolicyPort.perCallKrw(ApiType.YOUTUBE_DATA)).thenReturn(0L)
+ whenever(costPolicyPort.perCallKrw(ApiType.GOOGLE_PLACES)).thenReturn(7L)
+ }
+}
diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt
new file mode 100644
index 0000000..3738f35
--- /dev/null
+++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/quota/ApiQuotaGuardServiceTest.kt
@@ -0,0 +1,114 @@
+package com.linktrip.application.domain.quota
+
+import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort
+import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort
+import com.linktrip.common.exception.ExceptionCode
+import com.linktrip.common.exception.LinktripException
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.InjectMocks
+import org.mockito.Mock
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.whenever
+import java.time.LocalDate
+
+@ExtendWith(MockitoExtension::class)
+class ApiQuotaGuardServiceTest {
+ @Mock
+ lateinit var countPort: ApiCallCountPersistencePort
+
+ @Mock
+ lateinit var policyPort: ApiQuotaPolicyPort
+
+ @InjectMocks
+ lateinit var service: ApiQuotaGuardService
+
+ @Test
+ fun `정책 한도가 미설정인 ApiType 으로 isExceeded 호출 시_INTERNAL_QUOTA_POLICY_NOT_CONFIGURED 가 발생한다 (fail-closed)`() {
+ // 운영 yml 누락이나 환경변수 오타 등으로 한도가 비면, 가드가 조용히 무력화되어 비용 폭주 사고로 이어진다.
+ // counter 의 fail-loud 정책과 일관되게 본 가드도 throw 로 멈춰야 한다.
+ whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(null)
+
+ val exception =
+ assertThrows {
+ service.isExceeded(ApiType.GEMINI)
+ }
+ assertEquals(ExceptionCode.INTERNAL_QUOTA_POLICY_NOT_CONFIGURED, exception.exceptionCode)
+ }
+
+ @Test
+ fun `현재 카운트가 한도 미만이면_isExceeded 가 false 를 반환한다`() {
+ whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L)
+ whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now()))
+ .thenReturn(apiCallCount(ApiType.GEMINI, callCount = 100L))
+
+ assertFalse(service.isExceeded(ApiType.GEMINI))
+ }
+
+ @Test
+ fun `현재 카운트가 한도와 정확히 같으면_isExceeded 가 true 를 반환한다 (경계값 - inclusive 차단)`() {
+ // 한도 도달 = 더 이상 호출 금지. 한도 정확히 도달한 시점부터 차단되어야 함.
+ whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L)
+ whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now()))
+ .thenReturn(apiCallCount(ApiType.GEMINI, callCount = 500L))
+
+ assertTrue(service.isExceeded(ApiType.GEMINI))
+ }
+
+ @Test
+ fun `현재 카운트가 한도를 초과하면_isExceeded 가 true 를 반환한다`() {
+ whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L)
+ whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now()))
+ .thenReturn(apiCallCount(ApiType.GEMINI, callCount = 999L))
+
+ assertTrue(service.isExceeded(ApiType.GEMINI))
+ }
+
+ @Test
+ fun `해당 일자의 카운트 row 가 아직 없으면_0 으로 간주하여 isExceeded 가 false 를 반환한다`() {
+ // 자정 직후 그날 첫 호출 시점 — row 가 없어도 정상 통과되어야 (가드가 잘못 막으면 안 됨).
+ whenever(policyPort.dailyLimit(ApiType.GEMINI)).thenReturn(500L)
+ whenever(countPort.findByApiTypeAndDate(ApiType.GEMINI, LocalDate.now())).thenReturn(null)
+
+ assertFalse(service.isExceeded(ApiType.GEMINI))
+ }
+
+ @Test
+ fun `모든 ApiType 의 카운트가 한도 미만이면_isAnyApiExceeded 가 false 를 반환한다`() {
+ ApiType.entries.forEach { apiType ->
+ whenever(policyPort.dailyLimit(apiType)).thenReturn(1000L)
+ whenever(countPort.findByApiTypeAndDate(apiType, LocalDate.now()))
+ .thenReturn(apiCallCount(apiType, callCount = 100L))
+ }
+
+ assertFalse(service.isAnyApiExceeded())
+ }
+
+ @Test
+ fun `하나의 ApiType 만 한도를 초과해도_isAnyApiExceeded 가 true 를 반환한다 (큐 컨슈머는 어느 하나라도 막히면 dequeue 멈춤)`() {
+ // GEMINI / YOUTUBE_DATA 정상, GOOGLE_PLACES 만 초과. 마지막 ApiType 까지 순회되어야 검출되는 것을 보장.
+ ApiType.entries.forEach { apiType ->
+ whenever(policyPort.dailyLimit(apiType)).thenReturn(500L)
+ val count = if (apiType == ApiType.GOOGLE_PLACES) 1000L else 100L
+ whenever(countPort.findByApiTypeAndDate(apiType, LocalDate.now()))
+ .thenReturn(apiCallCount(apiType, callCount = count))
+ }
+
+ assertTrue(service.isAnyApiExceeded())
+ }
+
+ private fun apiCallCount(
+ apiType: ApiType,
+ callCount: Long,
+ ): ApiCallCount =
+ ApiCallCount(
+ id = "id-${apiType.name}",
+ apiType = apiType,
+ callDate = LocalDate.now(),
+ callCount = callCount,
+ )
+}
diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt
index 81edfc8..2b11f51 100644
--- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt
+++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/trip/TripPlanServiceTest.kt
@@ -17,15 +17,16 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
-import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
@@ -47,8 +48,21 @@ class TripPlanServiceTest {
@Mock
lateinit var hashtagPort: HashtagPersistencePort
- @InjectMocks
- lateinit var service: TripPlanService
+ private lateinit var service: TripPlanService
+
+ @BeforeEach
+ fun setUp() {
+ // @InjectMocks 는 Long 같은 단순 타입을 주입 못 하므로 명시 생성.
+ service =
+ TripPlanService(
+ planPort = planPort,
+ planItemPort = planItemPort,
+ itineraryItemPort = itineraryItemPort,
+ requestPort = requestPort,
+ hashtagPort = hashtagPort,
+ dailyVideoAnalyzeLimit = 10L,
+ )
+ }
@Nested
inner class RegisterRequest {
@@ -72,6 +86,32 @@ class TripPlanServiceTest {
assertEquals("m1", captor.firstValue.memberId)
assertEquals("t1", captor.firstValue.videoAnalysisTaskId)
}
+
+ @Test
+ fun `오늘 분석 요청이 한도 10건에 도달한 멤버가 새 영상을 요청하면_TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY 예외가 발생하고 저장하지 않는다`() {
+ // 어뷰징 방지 가드: 한도 도달 시 외부 API 호출도, DB 저장도 일어나지 않아야 한다.
+ whenever(requestPort.existsByMemberIdAndVideoAnalysisTaskId("m1", "t1")).thenReturn(false)
+ whenever(requestPort.countByMemberIdAndDate(eq("m1"), any())).thenReturn(10L)
+
+ val exception =
+ assertThrows {
+ service.registerRequest("m1", "t1")
+ }
+ assertEquals(ExceptionCode.TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY, exception.exceptionCode)
+ verify(requestPort, never()).save(any())
+ }
+
+ @Test
+ fun `이미 등록된 요청은 한도 카운트 조회 자체를 거치지 않는다 (dedup 우선 - 같은 영상 재요청은 카운트 영향 X)`() {
+ // 같은 영상에 대해 같은 멤버가 두 번 요청해도 trip_plan_request 의 unique 로 인해 1 row.
+ // 한도 체크는 새 row 가 생기는 경로에서만 의미가 있으므로, 이미 등록된 요청은 한도 조회를 스킵해야 한다.
+ whenever(requestPort.existsByMemberIdAndVideoAnalysisTaskId("m1", "t1")).thenReturn(true)
+
+ service.registerRequest("m1", "t1")
+
+ verify(requestPort, never()).countByMemberIdAndDate(any(), any())
+ verify(requestPort, never()).save(any())
+ }
}
@Nested
diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt
new file mode 100644
index 0000000..e57b228
--- /dev/null
+++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeServiceTest.kt
@@ -0,0 +1,120 @@
+package com.linktrip.application.domain.video
+
+import com.linktrip.application.domain.youtube.SearchKeywordLoader
+import com.linktrip.application.domain.youtube.YouTubeSearchResult
+import com.linktrip.application.port.input.VideoAnalyzeUseCase
+import com.linktrip.application.port.output.external.YouTubePort
+import org.junit.jupiter.api.Assertions.assertEquals
+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.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@ExtendWith(MockitoExtension::class)
+class KeywordAnalyzeServiceTest {
+ @Mock
+ lateinit var youTubePort: YouTubePort
+
+ @Mock
+ lateinit var videoAnalyzeUseCase: VideoAnalyzeUseCase
+
+ @InjectMocks
+ lateinit var service: KeywordAnalyzeService
+
+ @Test
+ fun `존재하지 않는 region 으로 호출하면_외부 API 호출 없이 빈 결과를 반환한다 (불필요한 비용 회피)`() {
+ // 가드 의도: 필터 결과 0 이면 외부 API 가 한 번도 호출되지 않아야 함.
+ val result = service.analyzeByKeywords(region = "존재하지-않는-region-xyz", country = null, maxResults = 5)
+
+ assertEquals(0, result.keywordCount)
+ assertEquals(0, result.tasks.size)
+ verify(youTubePort, never()).searchVideos(any(), any())
+ verify(videoAnalyzeUseCase, never()).analyzeVideo(any(), any())
+ }
+
+ @Test
+ fun `region 과 country 모두 null 이면_전체 키워드 풀에 대해 검색을 시도한다`() {
+ val totalKeywords = SearchKeywordLoader.getAll().size
+ whenever(youTubePort.searchVideos(any(), any())).thenReturn(emptyList())
+
+ val result = service.analyzeByKeywords(region = null, country = null, maxResults = 5)
+
+ assertEquals(totalKeywords, result.keywordCount)
+ verify(youTubePort, times(totalKeywords)).searchVideos(any(), any())
+ }
+
+ @Test
+ fun `YouTube 검색이 일부 키워드에서 예외를 던져도_나머지 키워드는 계속 처리된다 (best-effort)`() {
+ // 키워드 단위 실패 시 전체 잡이 멈추면 발견된 신규 영상이 모두 누락. 키워드 단위로 격리해야 함.
+ var callIndex = 0
+ whenever(youTubePort.searchVideos(any(), any())).thenAnswer {
+ callIndex++
+ if (callIndex == 1) {
+ throw RuntimeException("YouTube API 일시 오류")
+ }
+ emptyList()
+ }
+ val totalKeywords = SearchKeywordLoader.getAll().size
+
+ // 외부로 예외가 전파되지 않고 정상 종료
+ val result = service.analyzeByKeywords(region = null, country = null, maxResults = 5)
+
+ // 모든 키워드가 시도되어야 함 (한 키워드 실패가 다른 키워드 처리를 막아선 안 됨)
+ verify(youTubePort, times(totalKeywords)).searchVideos(any(), any())
+ // keywordCount 는 필터 통과한 키워드 수 — 검색 실패 여부와 무관하게 동일
+ assertEquals(totalKeywords, result.keywordCount)
+ }
+
+ @Test
+ fun `개별 영상 분석이 실패해도_같은 키워드의 다른 영상과 task 결과는 정상 보존된다 (영상 단위 격리)`() {
+ // 영상 단위 실패가 키워드/잡 전체를 무너뜨리지 않게.
+ whenever(youTubePort.searchVideos(any(), any())).thenReturn(
+ listOf(
+ searchResult("video-1"),
+ searchResult("video-2-fail"),
+ searchResult("video-3"),
+ ),
+ )
+ whenever(videoAnalyzeUseCase.analyzeVideo(any(), eq(Source.BATCH))).thenAnswer { invocation ->
+ val url = invocation.arguments[0] as String
+ if (url.endsWith("video-2-fail")) {
+ throw RuntimeException("분석 일시 오류")
+ }
+ videoAnalysisTask(url)
+ }
+ val totalKeywords = SearchKeywordLoader.getAll().size
+
+ val result = service.analyzeByKeywords(region = null, country = null, maxResults = 5)
+
+ // 키워드당 3 영상 시도, 그 중 1개만 실패 → 키워드당 2 task 성공
+ assertEquals(totalKeywords * 2, result.tasks.size)
+ verify(videoAnalyzeUseCase, times(totalKeywords * 3)).analyzeVideo(any(), eq(Source.BATCH))
+ }
+
+ private fun searchResult(videoId: String): YouTubeSearchResult =
+ YouTubeSearchResult(
+ videoId = videoId,
+ title = "title-$videoId",
+ description = "",
+ thumbnailUrl = "",
+ channelId = "",
+ channelTitle = "",
+ publishedAt = "",
+ )
+
+ private fun videoAnalysisTask(youtubeUrl: String): VideoAnalysisTask =
+ VideoAnalysisTask(
+ id = "task-$youtubeUrl",
+ youtubeUrl = youtubeUrl,
+ valid = false,
+ status = VideoAnalysisTaskStatus.PENDING,
+ source = Source.BATCH,
+ )
+}
diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt
index 8bfc04c..7cd0163 100644
--- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt
+++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt
@@ -1,5 +1,6 @@
package com.linktrip.application.domain.video
+import com.linktrip.application.domain.quota.ApiQuotaGuardService
import com.linktrip.application.domain.trip.TripPlanRequest
import com.linktrip.application.domain.trip.TripPlanService
import com.linktrip.application.port.output.external.VideoAnalysisNotificationPort
@@ -50,6 +51,9 @@ class VideoAnalysisQueueConsumerTest {
@Mock
lateinit var rateLimitBucketStore: RateLimitBucketStore
+ @Mock
+ lateinit var apiQuotaGuardService: ApiQuotaGuardService
+
private fun createConsumer() =
VideoAnalysisQueueConsumer(
videoAnalysisQueuePort = videoAnalysisQueuePort,
@@ -61,6 +65,7 @@ class VideoAnalysisQueueConsumerTest {
tripPlanRequestPort = tripPlanRequestPort,
tripPlanService = tripPlanService,
rateLimitBucketStore = rateLimitBucketStore,
+ apiQuotaGuardService = apiQuotaGuardService,
)
@Test
diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml
index 31add0a..cf96f9f 100644
--- a/linktrip-bootstrap/src/main/resources/application.yml
+++ b/linktrip-bootstrap/src/main/resources/application.yml
@@ -46,3 +46,25 @@ youtube:
api-key: ${YOUTUBE_API_KEY}
health-check:
sentinel-video-id: TVM6Nswlfbg
+
+api:
+ # 외부 API 일일 호출 한도. 초과 시 컨슈머가 dequeue 자체를 멈춰 비용 발생 호출을 사전 차단.
+ # 환경변수 미설정 시 아래 default 값이 적용된다. 키 자체가 누락되면
+ # ApiQuotaGuardService 가 fail-closed 로 throw → 가드가 조용히 비활성화되는 사고를 막는다.
+ # 무제한 운영이 필요하면 환경변수에 매우 큰 값을 명시할 것.
+ daily-quota:
+ gemini: ${API_DAILY_QUOTA_GEMINI:500}
+ youtube-data: ${API_DAILY_QUOTA_YOUTUBE_DATA:9000}
+ google-places: ${API_DAILY_QUOTA_GOOGLE_PLACES:3000}
+ # API 1회 호출당 추정 비용 (KRW). 누적 비용이 1000원 단위로 임계값 넘으면 Discord 알림.
+ # 단가는 공급사 가격 변동에 맞춰 운영 중 조정.
+ cost-per-call-krw:
+ gemini: ${API_COST_GEMINI_KRW:50}
+ youtube-data: ${API_COST_YOUTUBE_DATA_KRW:0}
+ google-places: ${API_COST_GOOGLE_PLACES_KRW:7}
+
+member:
+ # 멤버 1명이 하루에 요청할 수 있는 서로 다른 영상 분석 수.
+ # trip_plan_request 의 (member_id, task_id) unique 덕에 같은 영상 중복 요청은 카운트 X.
+ # 초과 시 TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY 로 응답하며 어뷰징 방지에 사용.
+ daily-video-analyze-limit: ${MEMBER_DAILY_VIDEO_ANALYZE_LIMIT:10}
diff --git a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
index 03823aa..5c38c1b 100644
--- a/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
+++ b/linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
@@ -29,11 +29,13 @@ enum class ExceptionCode(
// 429
TOO_MANY_REQUESTS(429, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."),
+ TOO_MANY_REQUESTS_VIDEO_ANALYZE_DAILY(429, "오늘 요청 가능한 영상 분석 한도를 초과했습니다. 내일 다시 시도해주세요."),
// 500
INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."),
INTERNAL_IMMUTABLE_DATA_DELETE(500, "불변 데이터는 삭제할 수 없습니다."),
INTERNAL_IMMUTABLE_DATA_UPDATE(500, "불변 데이터는 수정할 수 없습니다."),
+ INTERNAL_QUOTA_POLICY_NOT_CONFIGURED(500, "외부 API 일일 한도 정책이 설정되지 않았습니다."),
INTERNAL_ERROR_TEST(500, "에러 테스트용 예외입니다."),
// 502
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt
index f86fb0d..2b66068 100644
--- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/DiscordNotificationAdapter.kt
@@ -1,5 +1,6 @@
package com.linktrip.output.http.adapter
+import com.linktrip.application.domain.notification.CostAlertEvent
import com.linktrip.application.domain.notification.ExceptionAlertEvent
import com.linktrip.application.port.output.notification.NotificationPort
import com.linktrip.output.http.properties.DiscordNotificationProperties
@@ -33,6 +34,24 @@ class DiscordNotificationAdapter(
}
}
+ override fun sendCostAlert(event: CostAlertEvent) {
+ val message = buildCostMessage(event)
+ val payload = mapOf("content" to message)
+ runCatching {
+ discordRestClient.post()
+ .uri(properties.webhookUrlError)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(payload)
+ .retrieve()
+ .toBodilessEntity()
+ }.onFailure { e ->
+ logger.warn(e) {
+ "디스코드 웹훅 전송 실패 " +
+ "(date=${event.date}, total=${event.breakdown.totalKrw}원)"
+ }
+ }
+ }
+
private fun buildMessage(event: ExceptionAlertEvent): String {
val fullMessage =
"""
@@ -54,4 +73,29 @@ class DiscordNotificationAdapter(
fullMessage.take(limit) + "\n\n...(truncated)"
}
}
+
+ private fun buildCostMessage(event: CostAlertEvent): String {
+ val perApiLines =
+ event.breakdown.nonZero()
+ .joinToString("\n") { item -> " - ${item.apiType}: ${item.costKrw}원" }
+
+ val fullMessage =
+ """
+ 💸 <@${properties.mentionUserId}>
+
+ 💰 [Linktrip 외부 API 비용 알림]
+ 📅 일자: ${event.date}
+ 💵 누적 비용: ${event.breakdown.totalKrw}원 (${event.thresholdKrw}원 임계값 도달)
+
+ 📊 API 별:
+ $perApiLines
+ """.trimIndent()
+
+ val limit = 1000
+ return if (fullMessage.length <= limit) {
+ fullMessage
+ } else {
+ fullMessage.take(limit) + "\n\n...(truncated)"
+ }
+ }
}
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt
index 339cd5e..d95e442 100644
--- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/GooglePlacesAdapter.kt
@@ -1,18 +1,26 @@
package com.linktrip.output.http.adapter
import com.google.auth.oauth2.GoogleCredentials
+import com.linktrip.application.domain.quota.ApiCallCounterService
+import com.linktrip.application.domain.quota.ApiQuotaGuardService
+import com.linktrip.application.domain.quota.ApiType
import com.linktrip.application.domain.video.PlaceSearchResult
import com.linktrip.application.port.output.external.GooglePlacesPort
+import com.linktrip.common.exception.ExceptionCode
+import com.linktrip.common.exception.LinktripException
import com.linktrip.output.http.properties.GcpProperties
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
+import org.springframework.web.client.body
import java.io.FileInputStream
@Component
class GooglePlacesAdapter(
private val gcpProperties: GcpProperties,
private val googlePlacesRestClient: RestClient,
+ private val apiQuotaGuardService: ApiQuotaGuardService,
+ private val apiCallCounterService: ApiCallCounterService,
) : GooglePlacesPort {
private val credentials: GoogleCredentials by lazy {
GoogleCredentials.fromStream(FileInputStream(gcpProperties.credentialsPath))
@@ -23,6 +31,10 @@ class GooglePlacesAdapter(
name: String,
destination: String?,
): PlaceSearchResult? {
+ if (apiQuotaGuardService.isExceeded(ApiType.GOOGLE_PLACES)) {
+ throw LinktripException(ExceptionCode.BAD_GATEWAY_GOOGLE_PLACES)
+ }
+
val query = if (destination != null) "$name $destination" else name
credentials.refreshIfExpired()
@@ -40,7 +52,8 @@ class GooglePlacesAdapter(
),
)
.retrieve()
- .body(TextSearchResponse::class.java)
+ .body()
+ apiCallCounterService.recordSuccess(ApiType.GOOGLE_PLACES)
val place = response?.places?.firstOrNull() ?: return null
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt
new file mode 100644
index 0000000..6adee9c
--- /dev/null
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiCostPolicyAdapter.kt
@@ -0,0 +1,18 @@
+package com.linktrip.output.http.adapter
+
+import com.linktrip.application.domain.quota.ApiType
+import com.linktrip.application.port.output.quota.ApiCostPolicyPort
+import com.linktrip.output.http.properties.ApiCostProperties
+import org.springframework.stereotype.Component
+
+@Component
+class PropertiesApiCostPolicyAdapter(
+ private val properties: ApiCostProperties,
+) : ApiCostPolicyPort {
+ override fun perCallKrw(apiType: ApiType): Long =
+ when (apiType) {
+ ApiType.GEMINI -> properties.gemini
+ ApiType.YOUTUBE_DATA -> properties.youtubeData
+ ApiType.GOOGLE_PLACES -> properties.googlePlaces
+ }
+}
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt
new file mode 100644
index 0000000..a352764
--- /dev/null
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/PropertiesApiQuotaPolicyAdapter.kt
@@ -0,0 +1,18 @@
+package com.linktrip.output.http.adapter
+
+import com.linktrip.application.domain.quota.ApiType
+import com.linktrip.application.port.output.quota.ApiQuotaPolicyPort
+import com.linktrip.output.http.properties.ApiQuotaProperties
+import org.springframework.stereotype.Component
+
+@Component
+class PropertiesApiQuotaPolicyAdapter(
+ private val properties: ApiQuotaProperties,
+) : ApiQuotaPolicyPort {
+ override fun dailyLimit(apiType: ApiType): Long? =
+ when (apiType) {
+ ApiType.GEMINI -> properties.gemini
+ ApiType.YOUTUBE_DATA -> properties.youtubeData
+ ApiType.GOOGLE_PLACES -> properties.googlePlaces
+ }
+}
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt
index d5b3bfc..3b1fca4 100644
--- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt
@@ -6,6 +6,9 @@ import com.google.genai.Client
import com.google.genai.types.Content
import com.google.genai.types.HttpOptions
import com.google.genai.types.Part
+import com.linktrip.application.domain.quota.ApiCallCounterService
+import com.linktrip.application.domain.quota.ApiQuotaGuardService
+import com.linktrip.application.domain.quota.ApiType
import com.linktrip.application.domain.video.VideoAnalysisResult
import com.linktrip.application.port.output.external.VideoAnalyzePort
import com.linktrip.common.exception.ExceptionCode
@@ -31,6 +34,8 @@ class VideoAnalyzeAdapter(
private val gcpProperties: GcpProperties,
private val objectMapper: ObjectMapper,
private val transcriptClient: YoutubeTranscriptClient,
+ private val apiQuotaGuardService: ApiQuotaGuardService,
+ private val apiCallCounterService: ApiCallCounterService,
) : VideoAnalyzePort {
private val credentials: GoogleCredentials by lazy {
FileInputStream(gcpProperties.credentialsPath).use { stream ->
@@ -112,6 +117,9 @@ class VideoAnalyzeAdapter(
transcript: String,
videoId: String,
): VideoAnalysisResult {
+ if (apiQuotaGuardService.isExceeded(ApiType.GEMINI)) {
+ throw LinktripException(ExceptionCode.BAD_GATEWAY_GEMINI)
+ }
try {
val response =
client.models.generateContent(
@@ -121,6 +129,7 @@ class VideoAnalyzeAdapter(
),
null,
)
+ apiCallCounterService.recordSuccess(ApiType.GEMINI)
val rawText = response.text()
logger.debug { "Gemini 응답 길이: ${rawText?.length ?: 0}자" }
@@ -159,6 +168,9 @@ class VideoAnalyzeAdapter(
* 자막 추출 자체가 불가능한 영상에 대한 fallback 으로 와이어업할 경우에만 사용한다.
*/
private fun analyzeByAiVideoIngestion(youtubeUrl: String): VideoAnalysisResult {
+ if (apiQuotaGuardService.isExceeded(ApiType.GEMINI)) {
+ throw LinktripException(ExceptionCode.BAD_GATEWAY_GEMINI)
+ }
try {
val response =
client.models.generateContent(
@@ -169,6 +181,7 @@ class VideoAnalyzeAdapter(
),
null,
)
+ apiCallCounterService.recordSuccess(ApiType.GEMINI)
val jsonText = stripMarkdownCodeBlock(response.text())
val aiResponse = objectMapper.readValue(jsonText, AiApiResponse::class.java)
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt
index a1f4105..fb9be7a 100644
--- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YouTubeAdapter.kt
@@ -1,10 +1,15 @@
package com.linktrip.output.http.adapter
+import com.linktrip.application.domain.quota.ApiCallCounterService
+import com.linktrip.application.domain.quota.ApiQuotaGuardService
+import com.linktrip.application.domain.quota.ApiType
import com.linktrip.application.domain.youtube.YouTubeChannelDetail
import com.linktrip.application.domain.youtube.YouTubeRecentVideo
import com.linktrip.application.domain.youtube.YouTubeSearchResult
import com.linktrip.application.domain.youtube.YouTubeVideoMeta
import com.linktrip.application.port.output.external.YouTubePort
+import com.linktrip.common.exception.ExceptionCode
+import com.linktrip.common.exception.LinktripException
import com.linktrip.output.http.dto.youtube.YouTubeChannelResponse
import com.linktrip.output.http.dto.youtube.YouTubePlaylistItemResponse
import com.linktrip.output.http.dto.youtube.YouTubeSearchResponse
@@ -18,11 +23,14 @@ import org.springframework.web.client.body
class YouTubeAdapter(
private val youtubeProperties: YouTubeProperties,
private val youtubeRestClient: RestClient,
+ private val apiQuotaGuardService: ApiQuotaGuardService,
+ private val apiCallCounterService: ApiCallCounterService,
) : YouTubePort {
override fun searchVideos(
query: String,
maxResults: Int,
): List {
+ guardOrThrow()
val response =
youtubeRestClient.get()
.uri { builder ->
@@ -39,6 +47,7 @@ class YouTubeAdapter(
}
.retrieve()
.body()
+ apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA)
return response?.items
?.filter { it.id.videoId != null }
@@ -61,6 +70,7 @@ class YouTubeAdapter(
if (videoIds.isEmpty()) return emptyList()
return videoIds.chunked(MAX_IDS_PER_REQUEST).flatMap { chunk ->
+ guardOrThrow()
val response =
youtubeRestClient.get()
.uri { builder ->
@@ -73,6 +83,7 @@ class YouTubeAdapter(
}
.retrieve()
.body()
+ apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA)
response?.items?.map { item ->
YouTubeVideoMeta.create(
@@ -102,6 +113,7 @@ class YouTubeAdapter(
maxResults: Int,
topicId: String?,
): List {
+ guardOrThrow()
val searchResponse =
youtubeRestClient.get()
.uri { builder ->
@@ -121,6 +133,7 @@ class YouTubeAdapter(
}
.retrieve()
.body()
+ apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA)
val channelIds =
searchResponse?.items
@@ -134,6 +147,7 @@ class YouTubeAdapter(
if (channelIds.isEmpty()) return emptyList()
return channelIds.chunked(MAX_IDS_PER_REQUEST).flatMap { chunk ->
+ guardOrThrow()
val response =
youtubeRestClient.get()
.uri { builder ->
@@ -146,6 +160,7 @@ class YouTubeAdapter(
}
.retrieve()
.body()
+ apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA)
response?.items?.map { item ->
YouTubeChannelDetail(
@@ -168,6 +183,7 @@ class YouTubeAdapter(
): List {
val uploadsPlaylistId = channelId.replaceFirst("UC", "UU")
+ guardOrThrow()
val response =
youtubeRestClient.get()
.uri { builder ->
@@ -181,6 +197,7 @@ class YouTubeAdapter(
}
.retrieve()
.body()
+ apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA)
val candidates =
response?.items?.mapNotNull { item ->
@@ -214,6 +231,7 @@ class YouTubeAdapter(
if (videoIds.isEmpty()) return emptyMap()
return videoIds.chunked(MAX_IDS_PER_REQUEST).flatMap { chunk ->
+ guardOrThrow()
val response =
youtubeRestClient.get()
.uri { builder ->
@@ -226,6 +244,7 @@ class YouTubeAdapter(
}
.retrieve()
.body()
+ apiCallCounterService.recordSuccess(ApiType.YOUTUBE_DATA)
response?.items?.mapNotNull { item ->
val categoryId = item.snippet?.categoryId ?: return@mapNotNull null
@@ -234,6 +253,12 @@ class YouTubeAdapter(
}.toMap()
}
+ private fun guardOrThrow() {
+ if (apiQuotaGuardService.isExceeded(ApiType.YOUTUBE_DATA)) {
+ throw LinktripException(ExceptionCode.BAD_GATEWAY_YOUTUBE)
+ }
+ }
+
companion object {
private const val SEARCH_URI = "/search"
private const val VIDEOS_URI = "/videos"
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt
new file mode 100644
index 0000000..3c16bac
--- /dev/null
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/config/ApiQuotaPropertiesConfig.kt
@@ -0,0 +1,10 @@
+package com.linktrip.output.http.config
+
+import com.linktrip.output.http.properties.ApiCostProperties
+import com.linktrip.output.http.properties.ApiQuotaProperties
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.context.annotation.Configuration
+
+@Configuration
+@EnableConfigurationProperties(ApiQuotaProperties::class, ApiCostProperties::class)
+class ApiQuotaPropertiesConfig
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt
new file mode 100644
index 0000000..e381a00
--- /dev/null
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiCostProperties.kt
@@ -0,0 +1,14 @@
+package com.linktrip.output.http.properties
+
+import org.springframework.boot.context.properties.ConfigurationProperties
+
+/**
+ * API 1회 호출당 추정 비용 (KRW). yml 의 `api.cost-per-call-krw.*`.
+ * 0 = 비용 추적 비활성.
+ */
+@ConfigurationProperties(prefix = "api.cost-per-call-krw")
+data class ApiCostProperties(
+ val gemini: Long = 0L,
+ val youtubeData: Long = 0L,
+ val googlePlaces: Long = 0L,
+)
diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt
new file mode 100644
index 0000000..731992c
--- /dev/null
+++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/ApiQuotaProperties.kt
@@ -0,0 +1,14 @@
+package com.linktrip.output.http.properties
+
+import org.springframework.boot.context.properties.ConfigurationProperties
+
+/**
+ * 외부 API 일일 호출 한도 (yml 의 `api.daily-quota.*`).
+ * null 또는 미설정 = 한도 무시.
+ */
+@ConfigurationProperties(prefix = "api.daily-quota")
+data class ApiQuotaProperties(
+ val gemini: Long? = null,
+ val youtubeData: Long? = null,
+ val googlePlaces: Long? = null,
+)
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt
new file mode 100644
index 0000000..a2124c0
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiCallCountPersistenceAdapter.kt
@@ -0,0 +1,42 @@
+package com.linktrip.output.persistence.mysql.adapter
+
+import com.linktrip.application.domain.quota.ApiCallCount
+import com.linktrip.application.domain.quota.ApiType
+import com.linktrip.application.port.output.quota.ApiCallCountPersistencePort
+import com.linktrip.output.persistence.mysql.entity.ApiCallCountEntity
+import com.linktrip.output.persistence.mysql.repository.ApiCallCountJpaRepository
+import com.linktrip.output.persistence.mysql.repository.ApiCallCountQuerydslRepository
+import org.springframework.stereotype.Component
+import org.springframework.transaction.annotation.Transactional
+import java.time.LocalDate
+
+/**
+ * 일별 API 호출 카운트 영속화 어댑터.
+ *
+ * update-first 패턴: 먼저 atomic UPDATE +1 시도, 영향 row 가 0 이면 (그날 첫 호출) 신규 INSERT.
+ * - +1 은 [ApiCallCountQuerydslRepository.incrementCallCount] (QueryDSL UPDATE) 로 atomic 처리해 lost update 차단.
+ * - row 가 없을 때 두 트랜잭션이 동시 진입하면 한쪽이 unique 제약 위반으로 rollback 되어 fail-loud.
+ */
+@Component
+class ApiCallCountPersistenceAdapter(
+ private val jpaRepository: ApiCallCountJpaRepository,
+ private val querydslRepository: ApiCallCountQuerydslRepository,
+) : ApiCallCountPersistencePort {
+ @Transactional
+ override fun increment(apiCallCount: ApiCallCount) {
+ val updated = querydslRepository.incrementCallCount(apiCallCount.apiType, apiCallCount.callDate)
+ if (updated == 0L) {
+ jpaRepository.save(ApiCallCountEntity.from(apiCallCount))
+ }
+ }
+
+ @Transactional(readOnly = true)
+ override fun findByApiTypeAndDate(
+ apiType: ApiType,
+ date: LocalDate,
+ ): ApiCallCount? = jpaRepository.findByApiTypeAndCallDate(apiType, date)?.toDomain()
+
+ @Transactional(readOnly = true)
+ override fun findAllByDate(date: LocalDate): List =
+ jpaRepository.findAllByCallDate(date).map { it.toDomain() }
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt
new file mode 100644
index 0000000..f40e073
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ApiDailyCostAlertPersistenceAdapter.kt
@@ -0,0 +1,35 @@
+package com.linktrip.output.persistence.mysql.adapter
+
+import com.linktrip.application.domain.quota.ApiDailyCostAlert
+import com.linktrip.application.port.output.quota.ApiDailyCostAlertPersistencePort
+import com.linktrip.output.persistence.mysql.entity.ApiDailyCostAlertEntity
+import com.linktrip.output.persistence.mysql.repository.ApiDailyCostAlertJpaRepository
+import org.springframework.stereotype.Component
+import org.springframework.transaction.annotation.Transactional
+import java.time.LocalDate
+
+/**
+ * 일자별 비용 알림 임계값 추적 어댑터.
+ *
+ * 알림 임계값 통과 순간에만 호출되는 저빈도 경로라 단순한 SELECT → 변경/INSERT 흐름으로 처리.
+ * - row 가 있으면 dirty checking 으로 lastSentThresholdKrw 갱신.
+ * - row 가 없으면 신규 INSERT.
+ */
+@Component
+class ApiDailyCostAlertPersistenceAdapter(
+ private val jpaRepository: ApiDailyCostAlertJpaRepository,
+) : ApiDailyCostAlertPersistencePort {
+ @Transactional(readOnly = true)
+ override fun findLastSentThresholdKrw(date: LocalDate): Long? =
+ jpaRepository.findByAlertDate(date)?.lastSentThresholdKrw
+
+ @Transactional
+ override fun upsert(alert: ApiDailyCostAlert) {
+ val existing = jpaRepository.findByAlertDate(alert.alertDate)
+ if (existing != null) {
+ existing.lastSentThresholdKrw = alert.lastSentThresholdKrw
+ } else {
+ jpaRepository.save(ApiDailyCostAlertEntity.from(alert))
+ }
+ }
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt
index d10d9cc..0b2b827 100644
--- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/TripPlanRequestPersistenceAdapter.kt
@@ -6,6 +6,7 @@ import com.linktrip.output.persistence.mysql.entity.TripPlanRequestEntity
import com.linktrip.output.persistence.mysql.repository.TripPlanRequestJpaRepository
import com.linktrip.output.persistence.mysql.repository.TripPlanRequestQuerydslRepository
import org.springframework.stereotype.Component
+import java.time.LocalDate
@Component
class TripPlanRequestPersistenceAdapter(
@@ -29,4 +30,9 @@ class TripPlanRequestPersistenceAdapter(
override fun saveAll(requests: List) {
jpaRepository.saveAll(requests.map { TripPlanRequestEntity.from(it) })
}
+
+ override fun countByMemberIdAndDate(
+ memberId: String,
+ date: LocalDate,
+ ): Long = querydslRepository.countByMemberIdAndDate(memberId, date)
}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt
new file mode 100644
index 0000000..8b0b299
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiCallCountEntity.kt
@@ -0,0 +1,56 @@
+package com.linktrip.output.persistence.mysql.entity
+
+import com.linktrip.application.domain.quota.ApiCallCount
+import com.linktrip.application.domain.quota.ApiType
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.Id
+import jakarta.persistence.Index
+import jakarta.persistence.Table
+import jakarta.persistence.UniqueConstraint
+import java.time.LocalDate
+
+@Entity
+@Table(
+ name = "api_call_count",
+ uniqueConstraints = [
+ UniqueConstraint(name = "uk_api_call_count_type_date", columnNames = ["api_type", "call_date"]),
+ ],
+ indexes = [
+ Index(name = "idx_api_call_count_date_type", columnList = "call_date, api_type"),
+ ],
+)
+class ApiCallCountEntity(
+ @Id
+ @Column(length = 36)
+ val id: String,
+ @Enumerated(EnumType.STRING)
+ @Column(name = "api_type", nullable = false, length = 40)
+ val apiType: ApiType,
+ @Column(name = "call_date", nullable = false)
+ val callDate: LocalDate,
+ @Column(name = "call_count", nullable = false)
+ var callCount: Long = 0L,
+) : BaseTimeEntity() {
+ fun toDomain(): ApiCallCount =
+ ApiCallCount(
+ id = this.id,
+ apiType = this.apiType,
+ callDate = this.callDate,
+ callCount = this.callCount,
+ createdAt = this.createdAt,
+ updatedAt = this.updatedAt,
+ )
+
+ companion object {
+ fun from(apiCallCount: ApiCallCount): ApiCallCountEntity =
+ ApiCallCountEntity(
+ id = apiCallCount.id,
+ apiType = apiCallCount.apiType,
+ callDate = apiCallCount.callDate,
+ callCount = apiCallCount.callCount,
+ )
+ }
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt
new file mode 100644
index 0000000..8cbc059
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/ApiDailyCostAlertEntity.kt
@@ -0,0 +1,48 @@
+package com.linktrip.output.persistence.mysql.entity
+
+import com.linktrip.application.domain.quota.ApiDailyCostAlert
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Id
+import jakarta.persistence.Table
+import jakarta.persistence.UniqueConstraint
+import java.time.LocalDate
+
+/**
+ * 일자별로 마지막 발송된 비용 알림의 임계값 (KRW) 추적.
+ * 같은 임계값 구간에서 알림 1회만 발송되도록 함.
+ */
+@Entity
+@Table(
+ name = "api_daily_cost_alert",
+ uniqueConstraints = [
+ UniqueConstraint(name = "uk_api_daily_cost_alert_date", columnNames = ["alert_date"]),
+ ],
+)
+class ApiDailyCostAlertEntity(
+ @Id
+ @Column(length = 36)
+ val id: String,
+ @Column(name = "alert_date", nullable = false)
+ val alertDate: LocalDate,
+ @Column(name = "last_sent_threshold_krw", nullable = false)
+ var lastSentThresholdKrw: Long,
+) : BaseTimeEntity() {
+ fun toDomain(): ApiDailyCostAlert =
+ ApiDailyCostAlert(
+ id = this.id,
+ alertDate = this.alertDate,
+ lastSentThresholdKrw = this.lastSentThresholdKrw,
+ createdAt = this.createdAt,
+ updatedAt = this.updatedAt,
+ )
+
+ companion object {
+ fun from(alert: ApiDailyCostAlert): ApiDailyCostAlertEntity =
+ ApiDailyCostAlertEntity(
+ id = alert.id,
+ alertDate = alert.alertDate,
+ lastSentThresholdKrw = alert.lastSentThresholdKrw,
+ )
+ }
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt
index b2e7555..cfa1aa3 100644
--- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/TripPlanRequestEntity.kt
@@ -22,6 +22,10 @@ import jakarta.persistence.UniqueConstraint
name = "idx_trip_plan_request_task_processed",
columnList = "video_analysis_task_id, processed",
),
+ Index(
+ name = "idx_trip_plan_request_member_created",
+ columnList = "member_id, created_at",
+ ),
],
)
class TripPlanRequestEntity(
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt
new file mode 100644
index 0000000..7e8f7f1
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountJpaRepository.kt
@@ -0,0 +1,15 @@
+package com.linktrip.output.persistence.mysql.repository
+
+import com.linktrip.application.domain.quota.ApiType
+import com.linktrip.output.persistence.mysql.entity.ApiCallCountEntity
+import org.springframework.data.jpa.repository.JpaRepository
+import java.time.LocalDate
+
+interface ApiCallCountJpaRepository : JpaRepository {
+ fun findByApiTypeAndCallDate(
+ apiType: ApiType,
+ callDate: LocalDate,
+ ): ApiCallCountEntity?
+
+ fun findAllByCallDate(callDate: LocalDate): List
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt
new file mode 100644
index 0000000..f3a7375
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiCallCountQuerydslRepository.kt
@@ -0,0 +1,31 @@
+package com.linktrip.output.persistence.mysql.repository
+
+import com.linktrip.application.domain.quota.ApiType
+import com.linktrip.output.persistence.mysql.entity.QApiCallCountEntity
+import com.querydsl.jpa.impl.JPAQueryFactory
+import org.springframework.stereotype.Repository
+import java.time.LocalDate
+
+@Repository
+class ApiCallCountQuerydslRepository(
+ private val queryFactory: JPAQueryFactory,
+) {
+ private val apiCallCount = QApiCallCountEntity.apiCallCountEntity
+
+ /**
+ * (apiType, callDate) row 의 callCount 를 atomic 하게 1 증가.
+ * row 가 없으면 0 을 반환하므로 어댑터에서 INSERT 분기로 넘어간다.
+ */
+ fun incrementCallCount(
+ apiType: ApiType,
+ callDate: LocalDate,
+ ): Long =
+ queryFactory
+ .update(apiCallCount)
+ .set(apiCallCount.callCount, apiCallCount.callCount.add(1))
+ .where(
+ apiCallCount.apiType.eq(apiType),
+ apiCallCount.callDate.eq(callDate),
+ )
+ .execute()
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt
new file mode 100644
index 0000000..0621615
--- /dev/null
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/ApiDailyCostAlertJpaRepository.kt
@@ -0,0 +1,9 @@
+package com.linktrip.output.persistence.mysql.repository
+
+import com.linktrip.output.persistence.mysql.entity.ApiDailyCostAlertEntity
+import org.springframework.data.jpa.repository.JpaRepository
+import java.time.LocalDate
+
+interface ApiDailyCostAlertJpaRepository : JpaRepository {
+ fun findByAlertDate(alertDate: LocalDate): ApiDailyCostAlertEntity?
+}
diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt
index 8a32332..599db25 100644
--- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt
+++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/TripPlanRequestQuerydslRepository.kt
@@ -4,6 +4,7 @@ import com.linktrip.output.persistence.mysql.entity.QTripPlanRequestEntity
import com.linktrip.output.persistence.mysql.entity.TripPlanRequestEntity
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.stereotype.Repository
+import java.time.LocalDate
@Repository
class TripPlanRequestQuerydslRepository(
@@ -30,4 +31,22 @@ class TripPlanRequestQuerydslRepository(
request.deleted.isFalse,
)
.fetch()
+
+ fun countByMemberIdAndDate(
+ memberId: String,
+ date: LocalDate,
+ ): Long {
+ val startOfDay = date.atStartOfDay()
+ val startOfNextDay = date.plusDays(1).atStartOfDay()
+ return queryFactory
+ .select(request.count())
+ .from(request)
+ .where(
+ request.memberId.eq(memberId),
+ request.createdAt.goe(startOfDay),
+ request.createdAt.lt(startOfNextDay),
+ request.deleted.isFalse,
+ )
+ .fetchOne() ?: 0L
+ }
}
diff --git a/sql/ddl/api_call_count.sql b/sql/ddl/api_call_count.sql
new file mode 100644
index 0000000..ff0f55d
--- /dev/null
+++ b/sql/ddl/api_call_count.sql
@@ -0,0 +1,24 @@
+-- ============================================================
+-- api_call_count
+-- 외부 API 별 일자별 호출 누적 카운트.
+-- update-first 패턴: atomic UPDATE +1 시도, 그날 첫 호출 시에만 INSERT.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `api_call_count` (
+ `id` VARCHAR(36) NOT NULL,
+ `api_type` VARCHAR(40) NOT NULL COMMENT 'GEMINI / YOUTUBE_DATA / GOOGLE_PLACES',
+ `call_date` DATE NOT NULL,
+ `call_count` BIGINT NOT NULL DEFAULT 0,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- atomic UPSERT 의 row 식별. (api_type, call_date) 조합 중복 차단.
+ -- ApiCallCountQuerydslRepository.incrementCallCount 의 WHERE 조건이 사용.
+ UNIQUE KEY `uk_api_call_count_type_date` (`api_type`, `call_date`),
+
+ -- ApiCallCounterService.computeCostBreakdown 의 findAllByDate(date) 가 사용.
+ -- call_date 가 leading column 이라 해당 일자 row 를 즉시 range scan.
+ KEY `idx_api_call_count_date_type` (`call_date`, `api_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/api_daily_cost_alert.sql b/sql/ddl/api_daily_cost_alert.sql
new file mode 100644
index 0000000..e1286a7
--- /dev/null
+++ b/sql/ddl/api_daily_cost_alert.sql
@@ -0,0 +1,19 @@
+-- ============================================================
+-- api_daily_cost_alert
+-- 일자별 마지막으로 발송된 비용 알림 임계값 (KRW) 추적.
+-- 같은 임계값 구간에 대해 알림이 두 번 나가지 않도록 함.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `api_daily_cost_alert` (
+ `id` VARCHAR(36) NOT NULL,
+ `alert_date` DATE NOT NULL,
+ `last_sent_threshold_krw` BIGINT NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 1일 1 row 보장.
+ -- ApiDailyCostAlertJpaRepository.findByAlertDate / 어댑터의 select-then-update 에서 사용.
+ UNIQUE KEY `uk_api_daily_cost_alert_date` (`alert_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/hashtag.sql b/sql/ddl/hashtag.sql
new file mode 100644
index 0000000..578b26e
--- /dev/null
+++ b/sql/ddl/hashtag.sql
@@ -0,0 +1,17 @@
+-- ============================================================
+-- hashtag
+-- 영상 분석 결과의 해시태그 마스터 테이블.
+-- video_analysis_task_hashtag 가 (task, hashtag) 매핑을 담당.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `hashtag` (
+ `id` VARCHAR(36) NOT NULL,
+ `name` VARCHAR(50) NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 동일 이름 해시태그 중복 INSERT 방지. 신규 해시태그 등록 시 존재 여부 조회에도 사용.
+ UNIQUE KEY `uk_hashtag_name` (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/member.sql b/sql/ddl/member.sql
new file mode 100644
index 0000000..0cd5341
--- /dev/null
+++ b/sql/ddl/member.sql
@@ -0,0 +1,16 @@
+-- ============================================================
+-- member
+-- 사용자 계정.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `member` (
+ `id` VARCHAR(36) NOT NULL,
+ `serial_number` VARCHAR(255) NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 외부 식별자 (디바이스 시리얼 등) 의 중복 가입 방지 + 로그인/조회 키.
+ UNIQUE KEY `uk_member_serial_number` (`serial_number`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/place.sql b/sql/ddl/place.sql
new file mode 100644
index 0000000..2239c87
--- /dev/null
+++ b/sql/ddl/place.sql
@@ -0,0 +1,22 @@
+-- ============================================================
+-- place
+-- Google Places 로 보강된 장소 정보. 한 번 INSERT 후 immutable
+-- (PlaceEntity 의 @PreUpdate / @PreRemove / softDelete 모두 throw 처리).
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `place` (
+ `id` VARCHAR(36) NOT NULL,
+ `name` VARCHAR(255) NOT NULL,
+ `google_place_id` VARCHAR(255) NOT NULL,
+ `address` VARCHAR(500) DEFAULT NULL,
+ `latitude` DOUBLE DEFAULT NULL,
+ `longitude` DOUBLE DEFAULT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- Google Places 의 동일 장소를 여러 row 로 저장하지 않도록 차단.
+ -- 신규 enrich 결과가 들어올 때 기존 row 가 있는지 검증하는 키.
+ UNIQUE KEY `uk_place_google_place_id` (`google_place_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/travel_itinerary_item.sql b/sql/ddl/travel_itinerary_item.sql
new file mode 100644
index 0000000..67fba07
--- /dev/null
+++ b/sql/ddl/travel_itinerary_item.sql
@@ -0,0 +1,26 @@
+-- ============================================================
+-- travel_itinerary_item
+-- 한 영상 분석 task 안의 일자/순서별 여행 아이템 (식당/관광지/이동 등).
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `travel_itinerary_item` (
+ `id` VARCHAR(36) NOT NULL,
+ `video_analysis_task_id` VARCHAR(36) NOT NULL,
+ `day` INT NOT NULL,
+ `item_order` INT NOT NULL,
+ `category` VARCHAR(30) NOT NULL COMMENT 'ATTRACTION / EAT / SHOPPING / TRANSPORTATION_HUB / TRANSPORTATION_TRANSIT',
+ `name` VARCHAR(255) NOT NULL,
+ `description` VARCHAR(500) DEFAULT NULL,
+ `tips` VARCHAR(500) DEFAULT NULL,
+ `place_id` VARCHAR(36) DEFAULT NULL,
+ `place_search_count` INT NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 한 task 의 미삭제 아이템을 day → item_order 순으로 조회.
+ -- TravelItineraryItemQuerydslRepository / TripPlanQuerydslRepository 의
+ -- itinerary 조회 / 정렬 쿼리가 사용. deleted 를 키 안에 둬서 soft delete 항목 자동 제외.
+ KEY `idx_itinerary_item_task_deleted_day_order` (`video_analysis_task_id`, `deleted`, `day`, `item_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/trip_plan.sql b/sql/ddl/trip_plan.sql
new file mode 100644
index 0000000..86e7de0
--- /dev/null
+++ b/sql/ddl/trip_plan.sql
@@ -0,0 +1,19 @@
+-- ============================================================
+-- trip_plan
+-- 멤버가 특정 영상 분석 결과로부터 생성한 여행 계획.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `trip_plan` (
+ `id` VARCHAR(36) NOT NULL,
+ `member_id` VARCHAR(36) NOT NULL,
+ `video_analysis_task_id` VARCHAR(36) NOT NULL,
+ `title` VARCHAR(255) NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 동일 멤버가 동일 영상에 대해 여행 계획을 두 번 생성하지 않도록 차단.
+ -- 멤버의 task 별 plan 존재 여부 조회 키로도 사용.
+ UNIQUE KEY `uk_trip_plan_member_task` (`member_id`, `video_analysis_task_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/trip_plan_item.sql b/sql/ddl/trip_plan_item.sql
new file mode 100644
index 0000000..086f478
--- /dev/null
+++ b/sql/ddl/trip_plan_item.sql
@@ -0,0 +1,21 @@
+-- ============================================================
+-- trip_plan_item
+-- 여행 계획에 속한 일자/순서별 아이템.
+-- travel_itinerary_item 과 1:1 로 연결되며, 일자/순서는 trip_plan 안에서 재정렬 가능.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `trip_plan_item` (
+ `id` VARCHAR(36) NOT NULL,
+ `trip_plan_id` VARCHAR(36) NOT NULL,
+ `travel_itinerary_item_id` VARCHAR(36) NOT NULL,
+ `day` INT NOT NULL,
+ `item_order` INT NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 한 trip_plan 의 미삭제 아이템을 day → item_order 순으로 조회.
+ -- TripPlanItemQuerydslRepository / TripPlanQuerydslRepository 의 plan 상세 조회가 사용.
+ KEY `idx_trip_plan_item_plan_deleted_day_order` (`trip_plan_id`, `deleted`, `day`, `item_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/trip_plan_request.sql b/sql/ddl/trip_plan_request.sql
new file mode 100644
index 0000000..90f0139
--- /dev/null
+++ b/sql/ddl/trip_plan_request.sql
@@ -0,0 +1,27 @@
+-- ============================================================
+-- trip_plan_request
+-- 멤버가 특정 영상 분석 task 에 대해 여행 계획 생성을 요청한 기록.
+-- 분석 완료 시점에 미처리 (processed=false) 요청을 찾아 trip_plan 으로 전환.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `trip_plan_request` (
+ `id` VARCHAR(36) NOT NULL,
+ `member_id` VARCHAR(36) NOT NULL,
+ `video_analysis_task_id` VARCHAR(36) NOT NULL,
+ `processed` BIT(1) NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 동일 멤버가 동일 task 에 중복 요청하지 않도록 차단 + 멤버별 요청 조회 키.
+ UNIQUE KEY `uk_trip_plan_request_member_task` (`member_id`, `video_analysis_task_id`),
+
+ -- 분석 완료된 task 에 대한 미처리 요청 batch 조회.
+ -- TripPlanRequestQuerydslRepository 의 후속 처리 로직이 사용.
+ KEY `idx_trip_plan_request_task_processed` (`video_analysis_task_id`, `processed`),
+
+ -- 멤버별 일일 요청 카운트 (어뷰징 방지) 시 created_at 기준 range scan.
+ -- TripPlanRequestQuerydslRepository.countByMemberIdAndDate 가 사용.
+ KEY `idx_trip_plan_request_member_created` (`member_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/video_analysis_task.sql b/sql/ddl/video_analysis_task.sql
new file mode 100644
index 0000000..d73dd06
--- /dev/null
+++ b/sql/ddl/video_analysis_task.sql
@@ -0,0 +1,26 @@
+-- ============================================================
+-- video_analysis_task
+-- YouTube 영상에 대한 분석 task. 큐 컨슈머가 PENDING → PROCESSING → COMPLETED/FAILED 로 전이.
+-- source 는 task 생성 트리거 (USER / BATCH) 의 audit 기록 (immutable).
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `video_analysis_task` (
+ `id` VARCHAR(36) NOT NULL,
+ `youtube_url` VARCHAR(512) NOT NULL,
+ `valid` BIT(1) NOT NULL,
+ `status` VARCHAR(20) NOT NULL COMMENT 'PENDING / PROCESSING / COMPLETED / FAILED / INVALID',
+ `source` VARCHAR(20) NOT NULL COMMENT 'USER / BATCH (audit, immutable)',
+ `estimated_min_cost` BIGINT DEFAULT NULL,
+ `estimated_max_cost` BIGINT DEFAULT NULL,
+ `summary` TEXT DEFAULT NULL,
+ `cost_basis` VARCHAR(20) DEFAULT NULL COMMENT 'ITEM_ESTIMATED / VIDEO_MENTIONED',
+ `destination` VARCHAR(100) DEFAULT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 동일 YouTube URL 에 대한 분석 task 중복 생성 방지.
+ -- 신규 분석 요청 시 기존 task 존재 여부 조회 키로도 사용.
+ UNIQUE KEY `uk_video_analysis_task_youtube_url` (`youtube_url`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/video_analysis_task_hashtag.sql b/sql/ddl/video_analysis_task_hashtag.sql
new file mode 100644
index 0000000..992f030
--- /dev/null
+++ b/sql/ddl/video_analysis_task_hashtag.sql
@@ -0,0 +1,18 @@
+-- ============================================================
+-- video_analysis_task_hashtag
+-- video_analysis_task 와 hashtag 의 다대다 매핑.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `video_analysis_task_hashtag` (
+ `id` VARCHAR(36) NOT NULL,
+ `video_analysis_task_id` VARCHAR(36) NOT NULL,
+ `hashtag_id` VARCHAR(36) NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 동일 (task, hashtag) 매핑 중복 INSERT 차단.
+ -- task 별 hashtag 조회 시 leading column 이 task_id 라 그대로 활용.
+ UNIQUE KEY `uk_task_hashtag` (`video_analysis_task_id`, `hashtag_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/video_timeline.sql b/sql/ddl/video_timeline.sql
new file mode 100644
index 0000000..f504157
--- /dev/null
+++ b/sql/ddl/video_timeline.sql
@@ -0,0 +1,20 @@
+-- ============================================================
+-- video_timeline
+-- 영상 분석 결과의 timestamp 별 설명 (목차).
+-- 한 task 안에서 timestamp_seconds 오름차순으로 노출.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `video_timeline` (
+ `id` VARCHAR(36) NOT NULL,
+ `video_analysis_task_id` VARCHAR(36) NOT NULL,
+ `timestamp_seconds` INT NOT NULL,
+ `description` VARCHAR(255) NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 한 task 의 미삭제 timeline 을 시간 순으로 조회.
+ -- VideoTimelineQuerydslRepository.findByVideoAnalysisTaskId 가 사용.
+ KEY `idx_video_timeline_task_deleted_timestamp` (`video_analysis_task_id`, `deleted`, `timestamp_seconds`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/youtube_channel.sql b/sql/ddl/youtube_channel.sql
new file mode 100644
index 0000000..7596a70
--- /dev/null
+++ b/sql/ddl/youtube_channel.sql
@@ -0,0 +1,24 @@
+-- ============================================================
+-- youtube_channel
+-- YouTube 채널 메타데이터. 채널 발견 / 구독자수 기준 추천에 사용.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `youtube_channel` (
+ `id` VARCHAR(36) NOT NULL,
+ `channel_id` VARCHAR(64) NOT NULL,
+ `title` VARCHAR(500) NOT NULL,
+ `description` TEXT NOT NULL,
+ `thumbnail_url` VARCHAR(1000) NOT NULL,
+ `subscriber_count` BIGINT NOT NULL,
+ `video_count` BIGINT NOT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- YouTube 의 외부 채널 ID 중복 저장 방지 + upsert 시 기존 row 조회 키.
+ UNIQUE KEY `uk_youtube_channel_channel_id` (`channel_id`),
+
+ -- 구독자수 기준 채널 정렬/조회 (인기 채널 추천 등).
+ KEY `idx_youtube_channel_subscriber_count` (`subscriber_count`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/youtube_recent_video.sql b/sql/ddl/youtube_recent_video.sql
new file mode 100644
index 0000000..99cd641
--- /dev/null
+++ b/sql/ddl/youtube_recent_video.sql
@@ -0,0 +1,19 @@
+-- ============================================================
+-- youtube_recent_video
+-- 채널별 최근 업로드 영상 캐시 (BaseTimeEntity 미상속 — deleted/created_at/updated_at 없음).
+-- 채널 상세 조회 시 최근 영상 N 개를 즉시 반환하는 용도.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `youtube_recent_video` (
+ `id` VARCHAR(36) NOT NULL,
+ `channel_id` VARCHAR(64) NOT NULL,
+ `video_id` VARCHAR(64) NOT NULL,
+ `title` VARCHAR(500) NOT NULL,
+ `thumbnail_url` VARCHAR(1000) NOT NULL,
+ `published_at` VARCHAR(64) NOT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- 채널별 최근 영상 조회 시 leading column.
+ -- YouTubeRecentVideoQuerydslRepository 의 채널 별 영상 일괄 조회가 사용.
+ KEY `idx_youtube_recent_video_channel_id` (`channel_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/sql/ddl/youtube_video.sql b/sql/ddl/youtube_video.sql
new file mode 100644
index 0000000..465d5b4
--- /dev/null
+++ b/sql/ddl/youtube_video.sql
@@ -0,0 +1,38 @@
+-- ============================================================
+-- youtube_video
+-- YouTube 영상 메타데이터. 발견 / 추천 / 분석 후보 풀에 사용.
+-- ============================================================
+CREATE TABLE IF NOT EXISTS `youtube_video` (
+ `id` VARCHAR(36) NOT NULL,
+ `video_id` VARCHAR(32) NOT NULL,
+ `title` VARCHAR(500) NOT NULL,
+ `description` TEXT NOT NULL,
+ `thumbnail_url` VARCHAR(1000) NOT NULL,
+ `channel_id` VARCHAR(64) NOT NULL,
+ `channel_title` VARCHAR(500) NOT NULL,
+ `view_count` BIGINT NOT NULL,
+ `like_count` BIGINT NOT NULL,
+ `duration` VARCHAR(32) NOT NULL,
+ `published_at` VARCHAR(64) NOT NULL,
+ `region` VARCHAR(32) NOT NULL,
+ `country` VARCHAR(64) NOT NULL,
+ `city` VARCHAR(64) DEFAULT NULL,
+ `theme` VARCHAR(32) DEFAULT NULL,
+ `deleted` BIT(1) NOT NULL,
+ `created_at` DATETIME(6) DEFAULT NULL,
+ `updated_at` DATETIME(6) DEFAULT NULL,
+
+ PRIMARY KEY (`id`),
+
+ -- YouTube 의 외부 영상 ID 중복 저장 방지 + upsert 시 기존 row 조회 키.
+ UNIQUE KEY `uk_youtube_video_video_id` (`video_id`),
+
+ -- 국가별 인기 영상 조회 (조회수 정렬). 추천/발견 화면이 사용.
+ KEY `idx_youtube_video_country_view` (`country`, `view_count`),
+
+ -- 지역별 인기 영상 조회 (조회수 정렬).
+ KEY `idx_youtube_video_region_view` (`region`, `view_count`),
+
+ -- 테마별 최신 영상 조회. YouTubeVideoQuerydslRepository.findAllByTheme / cursor 페이지네이션이 사용.
+ KEY `idx_youtube_video_theme_created` (`theme`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;