Day 10: Video — "시연으로 만나는 모달리티, 비용은 텍스트의 수천 배. 그리고 비동기 폴링 이라는 세 번째 응답 패턴"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 9, 정말 단단하게 닫으셨어요.
지난 시간 우리는 캐릭터에게 귀와 입 을 한 번에 달아줬어요.
TranscriptionModel (STT) + SpeechModel (TTS) 두 자매 추상화가 Day 2 의 프로바이더 추상화 위 에 그대로 얹혔고 — 마이크 → STT → ChatClient → TTS → 스피커 의 5 단 파이프라인이 한 흐름으로 이어졌습니다.
세 번째 자매 추상화 가 깔끔하게 마무리됐죠.
그리고 지난 시간 마무리에서 제가 Day 10 의 두 가지 키워드 를 한 줄로 던지고 도망갔어요.
"음성도 텍스트의 30~50 배인데, 영상은 그보다 또 몇 십 배. 5 초짜리 짧은 클립 한 개 = 텍스트 호출 수천 회 분량. 그래서 비디오는 — 시연으로 만나는 모달리티. 학생 본인 카드로 무턱대고 돌릴 영역이 아니에요.
그리고 — 비동기 폴링 이라는 세 번째 응답 패턴 이 처음으로 등장합니다. Day 6 SSE 도 Day 9 binary 도 아닌 새 갈래. Day 10 에서 만나요. 🎬"
오늘이 바로 그 약속을 지키는 시간입니다.
자, 결론부터 또렷이 짚고 시작할게요.
오늘은 — 손으로 코드를 더럽히는 양이 가장 적은 Day 예요. Day 1~9 까지 매일 새 컨트롤러 / 새 서비스 / 새 테스트 를 한 줄씩 짜왔지만, 오늘은 호흡이 한 단계 다릅니다.
강사 시연 + 비용 감각 + 선택 실습 — 세 갈래가 한 흐름으로 묶여요.
왜 가 먼저 이해돼야, 어떻게 가 비로소 안전하게 따라옵니다. 🪪
💡 오늘 수업의 핵심 "비디오는 — 시연으로 만나는 모달리티. 강사 시연 + 비용 감각 + 비동기 폴링 이라는 세 번째 응답 패턴. 0 의 개수를 한 번 더 세는 감각 이 익숙해져야 본인 카드가 안전해진다." 🎯
오늘 수업은 한 문장으로 요약돼요.
"비디오 생성은 5 초 클립 한 개에 텍스트 호출 수천 회 분량 의 비용이 들 수 있어요. 그래서 본 강의의 핵심 학습은 — 모델 라인업과 비용 감각, 그리고 비동기 폴링이라는 세 번째 응답 패턴 을 익히는 것. 학생 실습은 Stub 폴링 클라이언트 위에서만 진행하고, 실제 외부 호출은 원하는 학생만 본인 판단하에. 지갑 안전 + 감각 100% 의 호흡."
세 가지 단어를 먼저 선언해 둘게요. 오늘이 끝날 때 이 단어들이 또렷이 익숙해져 있어야 합니다.
-
🚨 비용 게이트 — 0 의 개수를 한 번 더 세는 감각. Sora 5 초 720p = $10, Veo 3 5 초 1080p = $10 이라는 충격의 단가 가 익숙해져야 오늘 가장 중요한 장면 이 닫혀요.
🌊 비동기 폴링 (Async Polling) — Day 6 SSE 도 Day 9 binary 도 아닌 세 번째 응답 패턴. submit 으로 Job 을 큐에 던지고 → pollStatus 로 주기적으로 상태를 확인 → SUCCEEDED 가 떨어지면 videoUrl 을 받는 흐름입니다.
비디오는 분 단위 추론 이라 단일 동기 응답이 불가능 해요.
- 🪪 선택 실습 정책 — 학생 실습은 Stub 위에서만. 진짜 Veo 3 / Sora 어댑터를 짜고 싶은 학생은 본인 판단하에 졸업 후 도전. 본 강의는 코드 흐름과 비용 감각 까지만 익혀요.
🙋 한 학생의 걱정
"튜터님, 비용이 진짜 그렇게 비싸요? 그리고 — 시연만 보면 되는 거예요? 직접 안 짜면 안 익혀지지 않을까요? Day 1~9 까지 매일 코드를 손으로 직접 쳤잖아요. 오늘은 그냥 가만히 보고만 있으라니... 좀 허무한데요? "
그 걱정 너무 잘 알아요. 세 가지로 짧게 풀어드릴게요.
첫째, 비용은 — 진짜 그래요. 지난 시간 음성에서 텍스트의 30~50 배 라는 충격을 한 번 봤죠? 비디오는 그보다 또 몇 십 배. 5 초짜리 720p 클립 한 개를 Sora 로 뽑으면 $10 이에요. 카페 라떼 두 잔 값.
주니어 개발자가 커리큘럼 따라하다 실수로 50 번 호출 했다? $500. 한 달 월세예요. 그래서 본 Day 의 시연 중심 + 선택 실습 정책은 — 튜터의 잔소리 가 아니라 학생 카드를 보호하는 가드레일 이에요. 🛡️
둘째, 직접 안 짜면 안 익혀진다 — 사실 전혀 그렇지 않아요. 오늘 익힐 건 코드의 양 이 아니라 세 가지 추상화 입니다. (1) 비동기 폴링 이라는 세 번째 응답 패턴 의 컨트롤러 / DTO / 클라이언트 셋. (2) 비용 계산기 라는 프로덕션 가드.
(3) Stub 폴링 클라이언트 라는 실제 어댑터를 갈아끼울 수 있는 경계. 이 네 가지가 — 진짜 비싼 외부 호출 없이도 단단히 익혀집니다. 진짜 Veo 3 어댑터는 취업 후 본인 신용카드로 미루고, 추상화의 결 만 오늘 챙겨가요. 🌱
셋째, 허무하지 않아요 — 오늘 저의 시연 이 준비되어 있습니다. Veo 3 / Sora 의 짧은 클립 1~2 개 를 제 신용카드로 라이브로 돌려서 — 제출 → 분 단위 대기 → 완성 의 호흡을 직접 눈으로 보는 시간이에요.
이걸 보면 — 왜 비동기 폴링이 필요한가 가 한번 와닿으면 평생 안 잊혀요. 세 번째 응답 패턴 은 왜 가 어떻게 보다 훨씬 중요합니다. 🎬
요약하면 — 오늘 새로 외울 핵심은 세 단어 예요: 비용 게이트 / 비동기 폴링 / 선택 실습 정책. 그리고 세 가지 결정 — 어느 모델 라인업으로 갈지 / 학생 실습은 어디까지 짤지 / 본인 신용카드는 어떻게 보호할지. 지난 시간과 다른 호흡이지만, Spring AI 의 추상화가 새 모달리티에서 어떻게 펼쳐지는가 라는 큰 갈래는 그대로예요.
🎯 학습 목표
- 2026-04 기준 비디오 생성 모델 라인업 (Veo 3 · Sora · Runway Gen-4 · Luma · Kling · 로컬 SVD) 의 비용 / 품질 / 자유도 트레이드오프를 익히고, 0 을 한 번 더 세는 감각 을 체득합니다.
- 비동기 폴링 패턴 (
submit→pollStatus→SUCCEEDED) 을 Day 6 SSE · Day 9 binary 와 다른 세 번째 응답 패턴 으로 익힙니다.VideoPollingClient인터페이스 +StubVideoPollingClient구현체 +Clock주입까지. VideoCostCalculator의 비용 가드 를 손에 쥐고 —pricePerSecond × durationSeconds × resolutionMultiplier식으로 호출 전에 비용을 가두는 패턴을 익힙니다.- 강사 시연 (Veo 3 / Sora 의 짧은 클립 1~2 개) 으로 분 단위 대기 의 호흡을 직접 눈으로 보고, 왜 비동기 폴링이 필요한가 를 체감합니다.
- 로컬 무료 대안 (Stable Video Diffusion · GIF 합성 · Ken Burns 효과) 으로 비용 0 으로도 비디오 같은 결과를 만들어내는 방법 을 익힙니다.
- 선택 실습 컨트롤러 (
VideoGenerationAsyncController) 를 원하는 학생만 따라 짜면서 — 비용 가드 + 비동기 폴링 이 한 컨트롤러에서 만나는 모습을 익힙니다.
🎯 Step 1. 비디오 생성 API 현황 & 🚨 비용 가이드 — 0 을 한 번 더 세는 감각
자, 본 Step 부터 들어가기 전에 — 왜 비용 가이드부터 먼저 짚는가 를 한 번 더 짚을게요. Day 7 (이미지) · Day 8 (Vision) · Day 9 (음성) 세 번 모두 Step 1 은 모델 라인업 비교 였죠.
오늘 Day 10 도 흐름은 같지만 톤이 한 단계 더 무거워요. 이유는 한 가지 — 이번엔 비용 곡선이 한 자리수 더 올라갑니다. 지난 시간 음성이 텍스트의 30~50 배 라는 충격이 오늘은 한 단계 더 깊어집니다. 🪙
먼저 한 줄을 또렷이 박고 갈게요.
"비디오 5 초 클립 1 개 = 텍스트 호출 수천 회 분량. 학생이 본인 카드로 실수 한 번 만 해도 한 달 월세가 날아갈 수 있어요. 그래서 본 강의는 — 학생 실습 = Stub 폴링 + 비용 계산 시뮬레이터 / 진짜 외부 호출 = 강사 시연 + 원하는 학생만 본인 판단 의 두 갈래로 구성돼 있다."
1. 첫 장면 — 비용의 한 자리수가 다시 올라간다 🪙
자, Day 9 마무리에서 새겨둔 비용 비교 표를 한 번 더 펼쳐볼게요. 이번엔 Day 10 의 영상 행이 채워진 완성형 으로요.
| 모달리티 | Day | 입력 | 출력 | 비용 (텍스트 호출 1 회 기준) |
|---|---|---|---|---|
| 텍스트 | Day 1~6 | 텍스트 | 텍스트 | 1 배 |
| 이미지 출력 | Day 7 | 텍스트 | 이미지 | 5~20 배 |
| 이미지 입력 (Vision) | Day 8 | 이미지+텍스트 | 텍스트 | 1.5~2 배 |
| 음성 입출력 | Day 9 | 음성/텍스트 | 음성/텍스트 | 30~50 배 |
| 비디오 (시연) | Day 10 (오늘) | 텍스트 | 비디오 | 수백~수천 배 🚨 |
여기서 왜 수천 배까지 올라가는가 한 줄로 풀게요.
비디오 생성은 프레임 단위로 이미지 모델을 수십~수백 번 돌리는 작업이에요. 5 초짜리 24fps 클립이면 — 120 프레임 이 만들어져야 하고, 거기에 프레임 간 시간적 일관성 (한 프레임이 다음 프레임으로 자연스럽게 이어져야 한다) 을 위한 추가 추론 비용 이 들어옵니다.
이미지 한 장 ($0.04) × 120 프레임 + 시간 일관성 추론 — 단순 곱셈만으로도 $10 안팎 으로 올라가요.
2. 비디오 모델 라인업 — 6 종 (2026-05 시점 단가)
자, 그럼 어떤 모델들이 어떤 가격대에 들어와 있는지 표에 정리할게요. 코드베이스 VideoModelTier enum 의 6 종 그대로, 단가만 2026-05 후속 라인 으로 갱신해서 보여드릴게요.
| 모델 | 단가 (2026-05) | 5 초 720p 예상 | 한 줄 특징 |
|---|---|---|---|
| Stable Video Diffusion (로컬) | $0.00/초 (로컬 GPU) | $0.00 | 🌟 무료 + 오프라인. GPU 필요 (없으면 매우 느림) |
| Kling (Kuaishou) | $0.06/초 | $0.60 | 🟢 가성비 + 길이 강점 (10 초 이상 가능) |
| Luma Dream Machine | $0.07/초 | $0.70 | 🟢 빠른 생성 + 중간 가격대 |
| Runway Gen-4 | Turbo $0.05/초 · Gen-4.5 $0.12/초 | $0.50 ~ $1.20 | 영상 업계 디폴트, 두 갈래로 분기 |
| Veo 3 (Google) | 3.1 Lite $0.05/초 · Fast $0.15/초 · Standard $0.35~0.40/초 | $0.50 ~ $4.00 | 🚨 사운드 포함, 라인업 세분화 |
| Sora 2 (OpenAI) | Standard $0.10/초 · Pro $0.30~0.50/초 (구 Sora API 2026-09-24 종료 예정) | $1.00 ~ $5.00 | 🚨 2 세대로 한 자리수 ↓, 단가 갱신 |
🌟 = 무료 / 🟢 = 가성비 / ✅ = 운영 디폴트 / 🚨 = 본인 카드 주의
이 표의 단가는 2026-05 시점 후속 라인 입니다 — 비디오 모델 라인업은 한 달 단위로 출렁여요. 수치는 비용 감각 배양용, 추상화의 결은 그대로 살아남는다 는 마음으로 받아두세요.
코드베이스
VideoModelTierenum ·VideoCostCalculator식 ·VideoPollingClient인터페이스 골격은 단가의 출렁임과 무관하게 그대로 — 단가 한 줄만 갈아끼면 됩니다.과제 [구현 1] 의
HAILUO_2_0(Hailuo 02 / MiniMax — fal.ai 기준 768p $0.045/초 ~ 1080p Pro $0.08/초) ·PIKA_2_0(Pika 2.2) 단가도 같은 2026-05 시점 추정치 라인이에요. 프로바이더 추상화가 비용표의 출렁임을 흡수 하는 모습은 생각해볼 주제 1 에서 한 번 더 회수돼요.
여기서 720p 가산 계수가 ×2 배 라는 점을 먼저 짚어둘게요 (Step 3 의 VideoCostCalculator 에서 한 번 더 회수). 해상도가 올라갈수록 곱연산으로 비용이 뜁니다.
- 480p — 기본 해상도, 가산 계수 ×1
- 720p — 가산 계수 ×2
- 1080p — 가산 계수 ×4
즉 Sora 2 Pro 5 초 1080p (상한가) 면 — $0.50 × 5 × 4 = $10. 카페 라떼 2~3 잔 값이 5 초 클립 한 개 에 들어와요. 0 을 한 번 더 세는 감각이 진짜 필요한 가격대입니다. 🪙
3. 무료 크레딧 현황 — 강의 시점 기준 (2026-05)
완전 무료 까진 아니지만 무료 시도 한도가 있는 모델도 짚고 갈게요. 비디오 모델의 무료 정책은 분기 단위로 출렁여요 — 수료 후 본인 시점 에 각 모델 공식 가격 페이지 를 한 번 더 검색 하는 습관을 들여두세요. 오늘 적힌 숫자는 강의 시점의 스냅샷 + 감각 배양용 입니다.
- Veo 3 (Google AI Studio · Vertex AI) — Google AI Studio 에서 제한적 무료 시도 (월별 짧은 클립 몇 개). 수료 학생이 Gmail 한 번 회원가입 으로 시작 가능. (Veo 3.1 Fast / Lite 라인이 추가된 단가는 위 라인업 표 참조.)
- Runway Gen-4 — 신규 가입 시 무료 크레딧 ~125 credits 제공. 5 초 720p 한 개에 대략 25 credits 정도. 4~5 개 클립 분량의 무료 시도.
- Luma Dream Machine — 신규 가입 시 월별 30 generations 무료. 가성비 라인이면서 무료 정책도 가장 후한 편이에요.
- Sora (OpenAI) — Sora 2 는 2025-09 출시 후 ChatGPT Pro ($200/월) + sora.com 에서 접근 가능했어요. 단 2026-04-26 부 Sora 앱 / sora.com 은 종료됐고 API 도 2026-09-24 종료 예정 으로 잠정 안내돼 있어, 수료 시점엔 후속 GPT-비디오 SKU 로 자연스럽게 옮겨갈 거예요 (위 라인업 표 참조).
- Kling — 신규 가입 시 일일 무료 크레딧 6 credits 정도 (5 초 클립 1~2 개 분량).
💡 튜터의 결론 — 수료 후 진짜 비디오 생성을 익히고 싶다면 — Luma Dream Machine 의 월별 30 generations 무료 + Runway 의 125 credits 무료 두 가지를 먼저 잡는 걸 추천해요. 지갑 안전 + 감각 풍부 의 호흡이에요.
4. 🚨 본인 카드 보호 게이트 — 0 의 개수를 한 번 더 세는 감각
자, 가장 중요한 한 가지를 박아둘게요. 본 강의의 정책 선언 입니다.
🚨 정책 선언
- 학생 실습 코드 = 100% Stub 폴링 클라이언트 위에서 돌린다. 외부 호출 0 건.
- 진짜 Veo 3 / Sora / Runway 호출 은 — 강사 시연 + 원하는 학생만 본인 판단하에 선택.
- 학생이 본인 카드로 호출하기 전엔 — 반드시
VideoCostCalculator.estimateCostUsd(...)로 0 을 한 번 더 세고 가라.- 작은 실수가 큰 청구서 가 됩니다. 5 초짜리 무한 루프 호출 한 번이 한 달 월세 가 될 수 있어요.
🙋 한 학생의 질문 — "그럼 우리는 그냥 보고만 있어요?"
튜터의 답 — 아뇨, 보고만 있는 게 아니에요. 저의 시연을 보고 비용 감각만 챙기면 돼요. 그리고 코드는 Stub 으로 짤 거예요 — 비동기 폴링 컨트롤러 / 비용 계산기 / 폴링 클라이언트 인터페이스 까지. 진짜 외부 호출만 빠진 코드 모습 100% 완성 입니다. 익혀지는 흐름은 그대로고, 지갑만 안전한 호흡이에요. 🛡️
5. 마무리 — Step 1 의 회수
💡 튜터의 결론 — 비디오 생성은 Sora 5 초 720p = $10 같은 충격의 단가 가 박혀 있어요. 본 강의는 Stub 폴링 + 비용 계산기 + 강사 시연 의 3 점 호흡으로 학생 카드를 보호하면서, 비동기 폴링 / 비용 가드 / 선택 실습 정책 세 가지만 익힌다. 0 을 한 번 더 세는 감각이 오늘의 가장 큰 장면. 🪙
자, 왜 비용이 무서운가 를 짚었으니 — 이제 어떤 응답 패턴으로 비디오를 받아오는가 로 들어갈 차례예요. 세 번째 응답 패턴, 비동기 폴링 입니다. 🌊
🎯 Step 2. 비동기 폴링 패턴 — `VideoPollingClient` + `StubVideoPollingClient` + `Clock` 주입 (약 25분)
자, 두 번째 장면이에요. 오늘 익힐 세 번째 응답 패턴. 🌊
Day 6 에서 SSE 스트리밍 을 익혔죠 — 토큰이 한 글자씩 흘러오는 패턴. Day 9 에서 binary 응답 을 익혔죠 — byte[] 가 한 덩어리로 떨어지는 패턴. 오늘은 세 번째. 비동기 폴링 (Async Polling).
1. 첫 장면 — 왜 폴링이 필요한가
자, 한 줄로 정리할게요.
"비디오 생성은 — 분 단위 추론 이 들어가요. 단일 동기 HTTP 요청으로는 타임아웃이 먼저 떨어져버립니다. 그래서 제출 (submit) 과 결과 조회 (poll) 를 분리 해야 한다."
웹 서버의 기본 HTTP 요청 타임아웃 은 보통 30~60 초 입니다.
클라이언트 측 (브라우저 fetch · curl) 도 비슷해요.
그런데 — 비디오 생성은 짧은 클립 한 개에 1~5 분 이 걸려요.
Sora 의 1080p 클립 은 10 분 이상 걸리는 경우도 흔하고요.
동기 호출로 받는 건 물리적으로 불가능 합니다.
2. 두 번째 장면 — 세 응답 패턴 의 도식
오늘 또렷이 익혀둘 세 응답 패턴 의 도식을 한 화면에 펼쳐볼게요.
┌─────────────────────────────────────────────────────────────────┐
│ Day 6 — SSE 스트리밍 │
│ 클라이언트 ──── HTTP GET (Accept: text/event-stream) ────→ 서버 │
│ ←──── data: 토큰 ──── │
│ ←──── data: 토큰 ──── (동일 connection 위에서 │
│ ←──── data: 토큰 ──── 서버가 PUSH) │
│ ←──── data: [DONE] ── │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Day 9 — Binary 응답 │
│ 클라이언트 ──── HTTP POST (text) ────────────→ 서버 │
│ ←──── byte[] (한 덩어리, audio/mpeg) ──── │
│ (단일 동기 응답, 큰 페이로드) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Day 10 — 비동기 폴링 │
│ 클라이언트 ──── HTTP POST /generate-async (prompt) ──→ 서버 │
│ ←──── 202 ACCEPTED + jobId (QUEUED) ──── │
│ (분 단위 시간이 흐름) │
│ 클라이언트 ──── HTTP GET /status/{jobId} ────────────→ 서버 │
│ ←──── 200 OK + status: RUNNING ──── (10초 후 다시) │
│ 클라이언트 ──── HTTP GET /status/{jobId} ────────────→ 서버 │
│ ←──── 200 OK + status: SUCCEEDED + videoUrl ─── │
└─────────────────────────────────────────────────────────────────┘
세 패턴은 각각 언제 응답을 받느냐 가 달라요. SSE 는 서버가 한 connection 위에서 PUSH, binary 는 단일 동기 응답이지만 페이로드가 큰 패턴, 비동기 폴링 은 제출과 결과 조회를 분리해 시간 차이를 메우는 패턴. 같은 HTTP 위에서 세 가지 다른 호흡 이 흐릅니다. 🌊
3. 세 번째 장면 — VideoPollingClient 인터페이스 🎯
자, 코드로 들어갈게요. 본 강의의 코드베이스에 경계 인터페이스 가 박혀 있어요. 위치는 kr.spartaclub.aifriends.video.client.VideoPollingClient.
package kr.spartaclub.aifriends.video.client;
import kr.spartaclub.aifriends.video.dto.VideoGenerationRequest;
import kr.spartaclub.aifriends.video.dto.VideoJob;
/**
* Day 10 — 비디오 생성 비동기 폴링 클라이언트의 *경계 인터페이스*.
*
* <p>비디오 생성은 *한 번의 동기 호출로 떨어지지 않는다.* {@code submit} 으로 Job 을 큐에 던지면
* 서버 측에서 분 단위로 추론이 돌아가고, 클라이언트는 {@code pollStatus} 를 주기적으로 호출해
* 상태를 확인한다 — *Day 6 SSE 도 Day 9 binary 도 아닌* 세 번째 응답 패턴.</p>
*
* <p>이 인터페이스는 *프로바이더 추상화* 의 흐름을 따라 들어와 있다. 학생 실습은
* {@link StubVideoPollingClient} (인메모리 + 시간 기반 시뮬레이션) 로 진행하고, 실제 Veo 3 ·
* Sora · Runway 어댑터는 같은 인터페이스 뒤에서 갈아끼울 수 있도록 *경계만* 새겨둔다.</p>
*/
public interface VideoPollingClient {
/**
* Job 을 큐에 등록하고 {@code QUEUED} 상태의 스냅샷을 즉시 돌려준다.
*/
VideoJob submit(VideoGenerationRequest request);
/**
* 주어진 {@code jobId} 의 현재 상태 스냅샷을 돌려준다.
*/
VideoJob pollStatus(String jobId);
}
여기서 Day 2 의 프로바이더 추상화 가 한 번 더 회수돼요. ChatModel / ImageModel / TranscriptionModel 처럼 — 경계 인터페이스 하나에 두 메서드만 들어가 있는 모습. 실제 Veo 3 어댑터든 Sora 어댑터든 Stub 이든 — 같은 인터페이스 뒤에서 갈아끼울 수 있어요.
4. 네 번째 장면 — DTO 세 종 (VideoGenerationRequest · VideoJobStatus · VideoJob)
자, 인터페이스가 들고 다니는 DTO 세 개도 펼쳐볼게요.
VideoGenerationRequest — 요청 페이로드. 프롬프트 + 길이 + 해상도 세 필드만.
package kr.spartaclub.aifriends.video.dto;
/**
* Day 10 — 비디오 생성 요청 DTO (선택 실습).
*
* @param prompt 생성할 비디오의 프롬프트 (필수)
* @param durationSeconds 생성할 비디오의 길이 (1~10 초)
* @param resolution 해상도 — "480p" / "720p" / "1080p" 중 하나
*/
public record VideoGenerationRequest(
String prompt,
int durationSeconds,
String resolution) {
}
여기서 왜 길이를 1~10 초로 가두는가 한 줄 짚을게요. 학생 실습용 안전 가드. 길이가 길어질수록 비용이 선형으로 올라가요 (Step 3 의 비용 계산기 식 참조). 60 초 클립 한 번 = 5 초 클립의 12 배 비용. 그래서 본 강의는 최대 10 초 로 못박아 둡니다.
VideoJobStatus — 4 상태 enum.
package kr.spartaclub.aifriends.video.dto;
/**
* Day 10 — 비동기 비디오 생성 Job 의 상태.
*
* <p>Day 6 SSE (스트리밍) · Day 9 binary (음성) 에 이은 *세 번째 응답 패턴* — 비동기 폴링.
* 클라이언트는 {@code submit} 으로 Job 을 큐에 넣고, {@code GET /status/{jobId}} 를 주기적으로
* 호출해 상태를 확인한다. 완료까지 보통 *분 단위* 가 걸린다.</p>
*/
public enum VideoJobStatus {
QUEUED,
RUNNING,
SUCCEEDED,
FAILED
}
상태 전이는 QUEUED → RUNNING → SUCCEEDED 또는 QUEUED → RUNNING → FAILED 의 두 갈래뿐이에요. 한 번 SUCCEEDED 나 FAILED 로 떨어지면 그 상태에서 끝납니다.
VideoJob — Job 스냅샷. record 4 필드 + queued() 정적 팩토리.
package kr.spartaclub.aifriends.video.dto;
/**
* Day 10 — 비동기 비디오 생성 Job 의 스냅샷.
*
* <p>{@code submit(...)} 시점엔 {@code status=QUEUED} 와 {@code jobId} 만 채워지고,
* 시간이 지나 폴링하면 {@code status} 가 {@code RUNNING → SUCCEEDED/FAILED} 로 흘러간다.
* {@code SUCCEEDED} 일 때만 {@code videoUrl} 이 채워지고, {@code FAILED} 일 때만
* {@code errorMessage} 가 채워진다 — 비어 있는 필드는 {@code null} 로 둔다.</p>
*/
public record VideoJob(
String jobId,
VideoJobStatus status,
String videoUrl,
String errorMessage) {
/** QUEUED 상태로 새 Job 생성 — submit 시점에 사용. */
public static VideoJob queued(String jobId) {
return new VideoJob(jobId, VideoJobStatus.QUEUED, null, null);
}
}
여기서 상태에 따라 채워지는 필드가 달라진다 는 게 또렷이 익혀져야 해요.
QUEUED · RUNNING 일 땐 videoUrl · errorMessage 둘 다 null.
SUCCEEDED 일 땐 videoUrl 만 채워짐.
FAILED 일 땐 errorMessage 만 채워짐.
클라이언트가 상태 enum 으로 분기 한 뒤 해당 상태의 필드만 읽는 패턴.
5. 다섯 번째 장면 — StubVideoPollingClient 의 시간 기반 시뮬레이션
자, 학생 실습용 Stub 구현체 가 본 강의의 핵심입니다. 위치는 kr.spartaclub.aifriends.video.client.StubVideoPollingClient.
package kr.spartaclub.aifriends.video.client;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.video.dto.VideoGenerationRequest;
import kr.spartaclub.aifriends.video.dto.VideoJob;
import kr.spartaclub.aifriends.video.dto.VideoJobStatus;
import kr.spartaclub.aifriends.video.exception.VideoException;
import org.springframework.stereotype.Component;
@Component
public class StubVideoPollingClient implements VideoPollingClient {
/** QUEUED → RUNNING 전환까지의 가상 대기 시간. */
private static final Duration QUEUED_DURATION = Duration.ofSeconds(2);
/** RUNNING → SUCCEEDED 전환까지의 가상 추론 시간 (제출 시각 기준 누적). */
private static final Duration RUNNING_DURATION = Duration.ofSeconds(5);
private final Clock clock;
private final ConcurrentMap<String, Instant> submittedAtByJobId = new ConcurrentHashMap<>();
public StubVideoPollingClient() {
this(Clock.systemDefaultZone());
}
/** 테스트 한정 — Clock 을 외부에서 주입해 폴링 시간 경계를 결정론적으로 검증한다. */
StubVideoPollingClient(Clock clock) {
this.clock = clock;
}
@Override
public VideoJob submit(VideoGenerationRequest request) {
String jobId = UUID.randomUUID().toString();
submittedAtByJobId.put(jobId, Instant.now(clock));
return VideoJob.queued(jobId);
}
@Override
public VideoJob pollStatus(String jobId) {
Instant submittedAt = submittedAtByJobId.get(jobId);
if (submittedAt == null) {
throw new VideoException(ErrorCode.VIDEO_JOB_NOT_FOUND);
}
Duration elapsed = Duration.between(submittedAt, Instant.now(clock));
if (elapsed.compareTo(QUEUED_DURATION) < 0) {
return new VideoJob(jobId, VideoJobStatus.QUEUED, null, null);
}
if (elapsed.compareTo(RUNNING_DURATION) < 0) {
return new VideoJob(jobId, VideoJobStatus.RUNNING, null, null);
}
return new VideoJob(jobId, VideoJobStatus.SUCCEEDED,
"https://stub.local/videos/" + jobId + ".mp4", null);
}
}
이 Stub 클라이언트의 핵심을 한 줄씩 풀어 짚을게요.
(1) Clock 주입 — 테스트에서 시간을 얼리는 트릭. Clock 은 자바 표준 라이브러리의 시간 추상화 인터페이스예요. 현재 시각 = Instant.now() 로 직접 부르는 대신 — Instant.now(clock) 으로 주입받은 Clock 에게 묻습니다.
프로덕션에선 시스템 시계 (Clock.systemDefaultZone()) 가 들어가고, 테스트에선 고정된 시각 (Clock.fixed(...)) 이나 수동으로 흘릴 수 있는 시계 (테스트에서만 만든 AdvanceableClock) 가 들어가요. 시간 의존 로직을 테스트 가능하게 만드는 표준 패턴 이에요.
(2) 인메모리 ConcurrentMap — jobId → 제출 시각 만 저장. 실제 Veo 3 어댑터라면 외부 API 호출이 들어갈 곳이지만, 학생 실습용 Stub 은 시간만 기록 해 두고 경과 시간으로 상태를 흉내냅니다.
(3) 시간 기반 상태 전이 — 제출 시각으로부터 경과 시간 으로 분기.
- 0~2 초 → QUEUED
- 2~5 초 → RUNNING
- 5 초 이상 → SUCCEEDED
실제 Veo 3 / Sora 라면 분 단위 가 걸리지만, 학생 실습용 시뮬레이션 이라 5 초 안에 완료되도록 가속 했어요. 폴링 패턴을 익히는 게 목적이지, 분 단위 대기를 학생에게 시키는 게 목적이 아닙니다.
(4) VIDEO_JOB_NOT_FOUND 처리 — 모르는 jobId 가 들어오면 VideoException 을 던져요. ErrorCode 표는 Step 6 에서 한 번 더 회수.
6. 여섯 번째 장면 — 폴링 루프 의사 코드 (학생 머릿속용)
자, 클라이언트 측 폴링 루프 도 한 번 짚고 갈게요. 본 강의의 컨트롤러는 두 엔드포인트만 박았고, 클라이언트가 어떻게 폴링할지 는 학생 선택 입니다. 그래도 어떤 루프가 표준인가 는 머릿속에 박아두세요.
// 학생 머릿속용 — 의사 코드. 본 강의는 컨트롤러 두 엔드포인트만 박았고
// 실제 폴링 루프 작성은 *원하는 학생만*.
VideoJob job = restClient.post()
.uri("/api/video/generate-async?tier=KLING")
.body(new VideoGenerationRequest("a cat dancing", 5, "480p"))
.retrieve()
.body(new ParameterizedTypeReference<ApiResponse<VideoJob>>() {})
.data();
// 폴링 루프 — 백오프 + 데드라인 + 최대 횟수
String jobId = job.jobId();
Instant deadline = Instant.now().plus(Duration.ofMinutes(10)); // 10 분 데드라인
Duration interval = Duration.ofSeconds(5); // 시작 5 초 간격
int maxAttempts = 60; // 최대 60 회
for (int attempt = 0; attempt < maxAttempts; attempt++) {
Thread.sleep(interval.toMillis());
VideoJob polled = restClient.get()
.uri("/api/video/status/" + jobId)
.retrieve()
.body(new ParameterizedTypeReference<ApiResponse<VideoJob>>() {})
.data();
if (polled.status() == VideoJobStatus.SUCCEEDED) {
System.out.println("✅ 완료! videoUrl = " + polled.videoUrl());
break;
}
if (polled.status() == VideoJobStatus.FAILED) {
System.out.println("❌ 실패: " + polled.errorMessage());
break;
}
// 점진적 백오프 (5초 → 10초 → 15초 ...) — 너무 자주 폴링하면 서버 부담
interval = interval.plus(Duration.ofSeconds(5));
// 데드라인 체크
if (Instant.now().isAfter(deadline)) {
throw new IllegalStateException("Polling timeout: " + jobId);
}
}
여기서 세 가지 가드 만 머릿속에 박아두세요.
- 데드라인 — 무한 폴링 방지. 10 분 / 30 분 처럼 합리적 상한.
- 점진적 백오프 — 처음엔 자주, 점점 간격을 늘려요. 서버 부담 + 본인 RPS 절약.
- 최대 횟수 — 데드라인 + 횟수 두 축으로 가드. 어느 한 축에 걸리면 즉시 종료.
실제 운영 에선 지수 백오프 (exponential backoff) + jitter 가 표준이지만, 본 강의는 선형 백오프 까지만 익혀둡니다. Day 19 (Harness) 에서 다시 회수돼요. 🌱
7. 마무리 — Step 2 의 회수
💡 튜터의 결론 — 비동기 폴링은 세 번째 응답 패턴.
VideoPollingClient인터페이스 +StubVideoPollingClient의 시간 기반 시뮬레이션 +Clock주입까지 한 호흡에 모였다. 분 단위 추론을 단일 동기 호출로 받을 수 없으니 — 제출과 결과 조회를 분리. 폴링 루프의 데드라인 / 백오프 / 최대 횟수 세 가드는 머릿속에만 박아두고, 본 강의 코드는 서버 측 두 엔드포인트 까지만. 🌊
자, 폴링 패턴이 익숙해졌으니 — 이제 비용 계산 으로 넘어갈 차례예요. 0 을 한 번 더 세는 감각 의 진짜 본론입니다. 🪙
🎯 Step 3. 비용 계산 시뮬레이터 — `VideoCostCalculator` 와 단위 테스트
자, 세 번째 장면이에요. 호출 전에 비용을 가둡니다. 🪙
Step 1 에서 Sora 5 초 720p = $10 같은 충격의 단가를 보셨죠. 그런데 — 그 계산을 코드 위에서 직접 돌려봐야 학생이 호출 버튼을 누르기 전에 0 을 한 번 더 세는 습관을 익힐 수 있어요. 그래서 오늘 짤 게 VideoCostCalculator 입니다. 🛡️
1. 첫 장면 — 비용 식의 세 변수
비용 계산식은 한 줄로 들어와요.
cost = pricePerSecond × durationSeconds × resolutionMultiplier
세 변수가 각각 곱연산으로 들어가 비용을 결정해요.
pricePerSecond—VideoModelTierenum 의 1 초당 단가 (USD)durationSeconds— 요청의 길이 (1~10 초)resolutionMultiplier— 480p=1×, 720p=2×, 1080p=4×
세 변수 중 하나만 두 배 가 돼도 최종 비용이 두 배. 세 변수가 다 두 배 면 최종 비용이 8 배. 곱연산은 무섭다 는 게 핵심입니다. 🪙
2. 두 번째 장면 — VideoModelTier enum 의 6 종
먼저 모델 티어 enum 부터 펼쳐볼게요. 위치는 kr.spartaclub.aifriends.video.dto.VideoModelTier.
package kr.spartaclub.aifriends.video.dto;
/**
* Day 10 — 비디오 생성 모델별 티어와 1초당 기본 가격 (USD).
*
* <p>2026-04 기준 *공식 가격표를 단순화한 강의용 추정치* 다. 실제 청구액은 모델 정책 변경 ·
* 해상도 가산 · 워크플로(t2v · i2v) 에 따라 출렁이므로, 본 enum 의 수치는 *비용 감각 배양용*
* 으로만 쓴다 — *학생이 본인 카드로 호출하기 전에 0 을 한 번 더 세는 흐름* 을 노린다.</p>
*/
public enum VideoModelTier {
/** 로컬 GPU 무료 대안 — Stable Video Diffusion. */
STABLE_VIDEO_DIFFUSION_LOCAL(0.00),
/** Kuaishou Kling — 가성비 + 길이 강점. */
KLING(0.06),
/** Luma Dream Machine — 빠른 생성 + 중간 가격대. */
LUMA_DREAM_MACHINE(0.07),
/** Runway Gen-4 — 영상 업계 디폴트. */
RUNWAY_GEN_4(0.10),
/** Google Veo 3 — Gemini 패밀리, 사운드 포함 비디오. */
VEO_3(0.50),
/** OpenAI Sora — 최고 품질 + 최고 가격. */
SORA(1.00);
private final double pricePerSecondUsd;
VideoModelTier(double pricePerSecondUsd) {
this.pricePerSecondUsd = pricePerSecondUsd;
}
public double getPricePerSecondUsd() {
return pricePerSecondUsd;
}
}
enum 하나에 6 개 모델 이 들어가 있고, 1 초당 단가가 final 필드로 박혀 있는 모습. 새 모델이 추가되면 enum 에 한 줄만 추가 하면 자동으로 비용 계산기가 돌아가요. 프로바이더 추상화가 enum 에서도 살아있는 모양입니다.
3. 세 번째 장면 — VideoCostCalculator 의 Switch Expression 🎯
자, 본 Step 의 핵심입니다. 위치는 kr.spartaclub.aifriends.video.service.VideoCostCalculator.
package kr.spartaclub.aifriends.video.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.video.dto.VideoGenerationRequest;
import kr.spartaclub.aifriends.video.dto.VideoModelTier;
import kr.spartaclub.aifriends.video.exception.VideoException;
import org.springframework.stereotype.Component;
/**
* Day 10 — 비디오 생성 비용 계산기 (USD).
*
* <p>학생이 *호출 버튼을 누르기 전에* 0 의 개수를 한 번 더 세보게 만든다. 다음 식으로 계산한다:</p>
*
* <pre>
* cost = pricePerSecond × durationSeconds × resolutionMultiplier
* </pre>
*/
@Component
public class VideoCostCalculator {
/** 480p 해상도 가산 계수. */
private static final double MULTIPLIER_480P = 1.0;
/** 720p 해상도 가산 계수. */
private static final double MULTIPLIER_720P = 2.0;
/** 1080p 해상도 가산 계수. */
private static final double MULTIPLIER_1080P = 4.0;
/**
* 주어진 요청 + 모델 티어 조합의 예상 비용 (USD) 을 돌려준다.
*
* @param request 생성 요청 (durationSeconds, resolution 사용)
* @param tier 선택한 모델 티어
* @return 예상 비용 (USD), 소수점 둘째 자리 반올림
* @throws VideoException 길이/해상도가 허용 범위 밖일 때
*/
public double estimateCostUsd(VideoGenerationRequest request, VideoModelTier tier) {
if (request.durationSeconds() < 1 || request.durationSeconds() > 10) {
throw new VideoException(ErrorCode.VIDEO_DURATION_INVALID);
}
double resolutionMultiplier = resolveResolutionMultiplier(request.resolution());
double raw = tier.getPricePerSecondUsd()
* request.durationSeconds()
* resolutionMultiplier;
return Math.round(raw * 100.0) / 100.0;
}
private double resolveResolutionMultiplier(String resolution) {
if (resolution == null) {
throw new VideoException(ErrorCode.VIDEO_RESOLUTION_INVALID);
}
return switch (resolution) {
case "480p" -> MULTIPLIER_480P;
case "720p" -> MULTIPLIER_720P;
case "1080p" -> MULTIPLIER_1080P;
default -> throw new VideoException(ErrorCode.VIDEO_RESOLUTION_INVALID);
};
}
}
세 가지 포인트를 한 줄씩 짚을게요.
(1) 길이 검증 (1~10 초) — 첫 가드. 0 초나 11 초 같은 허용 범위 밖 의 값이면 VIDEO_DURATION_INVALID 로 즉시 거절. 비용을 곱연산하기 전에 입력값부터 막아요.
(2) Switch Expression — Java 14+ 의 식 지향 switch 문법. switch (resolution) { case "480p" -> ... } 식으로 case 라벨 → 값 을 한 줄로 쓸 수 있어요.
알 수 없는 해상도 (예: "4k") 가 들어오면 default 절에서 VIDEO_RESOLUTION_INVALID 로 거절. 오래된 if-else 중첩이 사라지는 모양입니다.
(3) Math.round(raw * 100.0) / 100.0 — 소수점 둘째 자리까지 반올림 의 표준 자바 패턴. 0.06 × 5 × 1 = 0.3000000000000004 같은 부동소수점 부정확성 을 자르고 $0.30 으로 다듬어요. 비용 표시할 때 둘째 자리까지 보여줘야 학생이 0 을 정확히 셀 수 있습니다. 🪙
4. 네 번째 장면 — 비용 계산기가 약속하는 7 가지 케이스 ✅
자, 비용 계산기가 약속하는 결과를 표로 정리할게요. 코드베이스의 VideoCostCalculatorTest 7 케이스가 그대로 검증해 둡니다.
| # | 입력 | 기대 결과 | 의미 |
|---|---|---|---|
| 1 | Sora 5 초 720p | $10.00 | 1.00 × 5 × 2 = $10. 충격의 단가. |
| 2 | Veo 3 5 초 1080p | $10.00 | 0.50 × 5 × 4 = $10. 해상도 ×4 의 곱연산. |
| 3 | Kling 5 초 480p | $0.30 | 0.06 × 5 × 1 = $0.30. 가성비 라인. |
| 4 | SVD 로컬 5 초 720p | $0.00 | 0.00 × 5 × 2 = $0. 무료. |
| 5 | 길이 0 초 | VIDEO_DURATION_INVALID |
하한 가드. |
| 6 | 길이 11 초 | VIDEO_DURATION_INVALID |
상한 가드. |
| 7 | 해상도 "4k" | VIDEO_RESOLUTION_INVALID |
enum 외 거절. |
이 7 가지 약속이 비용 계산기의 동작을 또렷이 잡아둡니다. 코드베이스의 VideoCostCalculatorTest 에서 그대로 검증되어 있어요. 학생이 자기 손으로 한 번 돌려보고 싶다면 — ./gradlew test --tests "*VideoCostCalculatorTest*" 한 줄.
부동소수점 비교 한 줄만 짚고 갈게요. 0.06 × 5 × 1 이 내부적으로 0.30000...4 가 될 수 있어서 — 정확히 같다 (==) 비교는 위험해요. 그래서 본 계산기는 둘째 자리 반올림 + 오차 허용 의 두 가드를 박아둡니다. Math.round(raw * 100.0) / 100.0 가 그 처리예요.
5. 다섯 번째 장면 — 추정치 라는 단어의 의미
마지막으로 주의사항 한 줄을 짚어둘게요.
🚨 본 계산기 결과는 비용 감각 배양용 추정치 다. 실제 청구서와 정확히 일치하지 않는다.
이유는 세 가지예요.
-
(1) 모델 정책 변경 — 2026-04 기준 시점의 단가. 실제로 본 강의 작성 후 한 달도 안 되어 — OpenAI 는 Sora 앱 / sora.com 을 2026-04-26 부 종료 했고 Sora 2 (Standard $0.10/초) 로 단가가 한 자리수 내려왔어요.
Google 은 2025-10 부 Veo 3.1 Fast ($0.15/초) / Lite ($0.05/초) 를 추가했고요.
Step 1 라인업 표의 2026-05 후속 라인 컬럼이 이 출렁임을 한 자락 담아둡니다.
본 enum 의 단가는 충격 단가의 학습용 스냅샷 으로 그대로 보존 — 프로바이더 추상화 가 단가 출렁임을 흡수 한다는 게 핵심 메시지예요.
- (2) 워크플로 분기 — Text-to-Video (t2v) 와 Image-to-Video (i2v) 의 단가가 다른 모델이 많아요. 본 enum 은 t2v 만 단가로 박았어요.
- (3) 프레임 레이트 / 길이 옵션 — 24fps vs 30fps, 5 초 vs 10 초 가 단순 비례 곱이 아닌 식으로 가격이 매겨진 모델도 있어요.
그래도 — 0 을 한 번 더 세는 감각 배양 엔 충분해요. 실제 청구서 정확도 가 아니라 호출 전 자기 검열 이 본 계산기의 목적입니다. 🛡️
6. 마무리 — Step 3 의 회수
💡 튜터의 결론 — 비용 계산기는 호출 전에 0 을 한 번 더 세는 가드. 식은
pricePerSecond × durationSeconds × resolutionMultiplier한 줄. Sora 5 초 720p = $10 / Veo 3 5 초 1080p = $10 같은 충격의 단가가 코드 위에서 직접 돌아가는 모습 으로 박혔다. 비용 감각 배양용 추정치 라 청구서와 정확히 같진 않지만, 학생이 본인 카드를 안전하게 보호하는 데에는 충분하다. 🪙
자, 코드 구조 두 갈래가 손에 들어왔으니 — 이제 진짜 외부 호출 을 눈으로 보는 시간이에요. 강사 시연 으로 넘어가 봅시다. 🎬
🎯 Step 4. 로컬 무료 대안 — Stable Video Diffusion · GIF · Ken Burns 효과 (약 20분)
1. 첫 장면 — 무료 대안 세 갈래 🌱
본 Step 에서 다룰 세 갈래예요.
| 옵션 | 비용 | 품질 | 자유도 | 한 줄 특징 |
|---|---|---|---|---|
| Stable Video Diffusion | 🟢 완전 무료 | 🟡 중 (4~25 프레임) | 🟢 높음 | 로컬 GPU 필요. 짧은 클립 한정. |
| GIF 합성 (이미지 시퀀스) | 🟢 완전 무료 | 🟡 중 | 🟢 매우 높음 | Day 7 ImageModel + ffmpeg. |
| Ken Burns 효과 | 🟢 완전 무료 | 🟡 중 (정적+움직임) | 🟡 중 | 정적 이미지에 카메라 팬/줌. |
세 갈래 모두 — 진짜 비디오 모델 처럼 프레임 간 시간 일관성이 완벽한 결과는 아니지만, ai-friends 의 엔딩 시네마틱 같은 짧은 클립 에는 충분합니다. 제로 비용으로 비디오 같은 결과를 만드는 감각이에요. 🌊
2. 두 번째 장면 — Stable Video Diffusion (로컬 GPU)
Stable Video Diffusion (SVD) — Stability AI 가 공개한 오픈소스 비디오 확산 모델. 로컬 GPU 위에서 4~25 프레임 짧은 클립 을 만들 수 있어요.
핵심 포인트
- 모델 다운로드 — Hugging Face 에서
stabilityai/stable-video-diffusion-img2vid모델 weights. 약 10GB. - 하드웨어 요구사항 — GPU VRAM 16GB 이상 권장. RTX 3090 / 4090 / Apple M-시리즈 (Metal 가속).
- 사용 도구 — ComfyUI / Automatic1111 /
diffusers라이브러리 셋 중 하나. - 워크플로 — 이미지 1 장 → 짧은 비디오 클립 (image-to-video) 가 핵심. 텍스트 프롬프트만으로 는 어려워요. Day 7 ImageModel 로 만든 정지 이미지를 입력 으로 흘려보냅니다.
한계
- 4~25 프레임 — 5 초 클립도 어려워요. 보통 1~4 초짜리 짧은 클립 한정.
- 모션이 단순 — 카메라 줌 / 인물 미세 움직임 정도. 복잡한 액션 은 어려워요.
- 첫 실행 모델 로딩 30 초~수 분 — 학습 비용보다 환경 세팅 비용이 더 큽니다.
💡 튜터의 결론 SVD 는 — 진짜 GPU 가 있고 + Linux 환경에 익숙한 학생 의 길이에요. 본 강의는 코드 한 줄 인용 없이 개념만 다룬 이유. 졸업 후 자기 데스크탑이 있는 학생 이 잡을 갈래입니다.
3. 세 번째 장면 — GIF 합성 (이미지 시퀀스 + ffmpeg)
자, Day 7 회수 시간이에요. Day 7 의 ImageModel 로 만든 정지 이미지 여러 장을 GIF / mp4 로 묶는 방법.
핵심 포인트
- 이미지 시퀀스 만들기 — Day 7 의
ImageModel로 연속된 프롬프트 5~10 장 생성. - 예: "a cat at sunrise" / "a cat at noon" / "a cat at sunset" / "a cat at midnight"
- 시퀀스 → 비디오 합성 —
ffmpeg한 줄로.
# 5 장의 이미지를 1 초당 1 프레임으로 묶어 mp4 로 합성
ffmpeg -framerate 1 -i frame_%d.png -c:v libx264 -pix_fmt yuv420p output.mp4
# GIF 로 묶기
ffmpeg -framerate 1 -i frame_%d.png -loop 0 output.gif
# Python `imageio` 로도 가능
# import imageio
# images = [imageio.imread(f"frame_{i}.png") for i in range(5)]
# imageio.mimsave("output.gif", images, duration=1.0)
한계
- 프레임 간 시간 일관성이 거칠어요 — 한 프레임에서 다음 프레임으로 툭 점프 합니다. 부드러운 모션 은 어려워요.
- Day 7 ImageModel 호출 비용은 들어요 — 5~10 장 × $0.04 정도. 완전 무료 까진 아니지만 Sora 의 1/100 가격 정도.
💡 튜터의 결론 GIF 합성은 — 부드러운 모션을 포기하고 시각적 변화만 보여주는 방식. SNS 짧은 영상 / 시간 경과 효과 정도엔 충분해요.
4. 네 번째 장면 — Ken Burns 효과 (정적 이미지에 카메라 팬/줌)
자, 가장 우아한 무료 방식 이에요. Ken Burns 효과 — 다큐멘터리 감독 Ken Burns 의 이름에서 따온 기법으로, 정적 이미지에 카메라 팬 (좌우 이동) / 줌 (확대 축소) / 패닝 효과를 줘서 마치 비디오처럼 보이게 만듭니다.
핵심 포인트 — ffmpeg 한 줄짜리 zoompan 필터.
# 5 초짜리 줌인 효과 — 1.0 배에서 1.5 배로 천천히 확대
ffmpeg -loop 1 -i input.png \
-vf "zoompan=z='min(zoom+0.0015,1.5)':d=125:s=1920x1080" \
-t 5 -pix_fmt yuv420p output.mp4
# 좌→우 패닝 효과
ffmpeg -loop 1 -i input.png \
-vf "zoompan=z='1.2':x='iw*(0.5-0.5*on/125)':y='ih*0.5':d=125:s=1920x1080" \
-t 5 -pix_fmt yuv420p output.mp4
핵심 포인트
- 입력은 정지 이미지 1 장만 (Day 7 ImageModel 로 생성한 캐릭터 portrait 1 장)
- 출력은 5 초짜리 1080p mp4
- 비용은 0 원 (외부 API 호출 0 건)
- 결과물은 —
진짜 비디오는 아니지만 시네마틱 무드
💡 튜터의 결론 ai-friends 의 엔딩 시네마틱 클립을 — Ken Burns + Day 7 ImageModel 합성으로 완전 무료 로 만들 수 있다. 캐릭터 portrait 한 장 → ffmpeg 한 줄 → 5 초짜리 시네마틱 클립. 졸업 후 자기 포트폴리오 데모 에 박을 수 있는 카드예요. 🌟
5. 다섯 번째 장면 — 세 갈래 비교 표
| 갈래 | 비용 | 품질 | 환경 | 추천 사용처 |
|---|---|---|---|---|
| SVD 로컬 | 🟢 $0 | 🟡 중 (4~25 프레임) | 🔴 GPU 16GB | 짧은 모션 클립, 로컬 환경 |
| GIF 합성 | 🟡 ~$0.50 | 🟡 거친 질감 | 🟢 ffmpeg 한 줄 | SNS 짧은 영상, 시간 경과 |
| Ken Burns | 🟢 $0 | 🟢 시네마틱 | 🟢 ffmpeg 한 줄 | 엔딩 컷, 포트폴리오 데모 |
| (참고) Sora | 🚨 $10 | 🟢 최고 | 🟢 API 한 줄 | 운영 배포, 본인 카드 영역 |
💡 튜터의 결론 졸업 후 본인 프로젝트 에서 비디오 같은 효과 를 넣고 싶다면 — Ken Burns 가 비용 vs 품질 vs 환경 셋 다 가장 균형 잡혀요. Day 7 ImageModel 로 만든 캐릭터 이미지 + ffmpeg 한 줄 — 둘이 합쳐져 0 원짜리 시네마틱 클립 이 만들어집니다. 🌱
6. 마무리
💡 튜터의 결론 — 무료 대안은 진짜 비디오 모델 을 그대로 흉내내진 못해도, ai-friends 의 엔딩 시네마틱 같은 짧은 클립 엔 충분하다. SVD (로컬 GPU) / GIF 합성 (Day 7 회수) / Ken Burns (ffmpeg 한 줄) 세 갈래 중 Ken Burns + Day 7 ImageModel 합성 이 가장 균형 잡혔다. 비용 0 원으로도 시네마틱 무드 가 나온다는 감각이 졸업 후 포트폴리오의 무기 가 된다. 🌱
자, 무료 대안까지 익혔으니 — 이제 원하는 학생만 따라 짜는 선택 실습 컨트롤러 로 넘어가요. 비동기 폴링 + 비용 가드 가 한 컨트롤러에서 만나는 모습이에요. 🛡️
🎯 Step 5. 선택 실습 프레임워크 — `VideoGenerationAsyncController` + 비용 가드
자, 마지막 Step 이에요. 본 Step 은 원하는 학생만 따라 짭니다. 🪪
다시 한번 짚어둘게요 — Day 10 의 핵심 학습은 Step 1~4 까지에서 이미 다 익혔어요. 이번 Step 은 Step 2 의 폴링 클라이언트 + Step 3 의 비용 계산기 가 컨트롤러 하나로 합쳐지는 모습. Spring MVC 가 익숙한 학생만 따라하면 충분해요.
코드를 직접 안 짜도 — Step 1~4 까지의 학습은 그대로 살아 있습니다. 🛡️
1. 첫 장면 — 컨트롤러의 두 엔드포인트
본 강의 코드베이스의 검증된 컨트롤러 위치는 kr.spartaclub.aifriends.video.controller.VideoGenerationAsyncController. 두 엔드포인트만 박혀 있어요.
package kr.spartaclub.aifriends.video.controller;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.video.client.VideoPollingClient;
import kr.spartaclub.aifriends.video.dto.VideoGenerationRequest;
import kr.spartaclub.aifriends.video.dto.VideoJob;
import kr.spartaclub.aifriends.video.dto.VideoModelTier;
import kr.spartaclub.aifriends.video.exception.VideoException;
import kr.spartaclub.aifriends.video.service.VideoCostCalculator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api/video")
public class VideoGenerationAsyncController {
private static final int MIN_DURATION = 1;
private static final int MAX_DURATION = 10;
private final VideoPollingClient videoPollingClient;
private final VideoCostCalculator videoCostCalculator;
private final double maxCostUsdPerRequest;
public VideoGenerationAsyncController(
VideoPollingClient videoPollingClient,
VideoCostCalculator videoCostCalculator,
@Value("${aifriends.video.max-cost-usd-per-request:1.0}") double maxCostUsdPerRequest) {
this.videoPollingClient = videoPollingClient;
this.videoCostCalculator = videoCostCalculator;
this.maxCostUsdPerRequest = maxCostUsdPerRequest;
}
@PostMapping("/generate-async")
public ResponseEntity<ApiResponse<VideoJob>> submit(
@RequestBody VideoGenerationRequest request,
@RequestParam(name = "tier", defaultValue = "KLING") VideoModelTier tier) {
validate(request);
double estimatedCost = videoCostCalculator.estimateCostUsd(request, tier);
if (estimatedCost > maxCostUsdPerRequest) {
log.warn("[VideoGenerationAsync] cost guard tripped: estimated=${}, limit=${}",
estimatedCost, maxCostUsdPerRequest);
throw new VideoException(ErrorCode.VIDEO_QUOTA_EXCEEDED);
}
VideoJob job = videoPollingClient.submit(request);
log.info("[VideoGenerationAsync] submit success: jobId={}, tier={}, estimatedCost=${}",
job.jobId(), tier, estimatedCost);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(job));
}
@GetMapping("/status/{jobId}")
public ResponseEntity<ApiResponse<VideoJob>> status(@PathVariable("jobId") String jobId) {
VideoJob job = videoPollingClient.pollStatus(jobId);
return ResponseEntity.ok(ApiResponse.success(job));
}
private void validate(VideoGenerationRequest request) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
throw new VideoException(ErrorCode.VIDEO_PROMPT_REQUIRED);
}
if (request.durationSeconds() < MIN_DURATION || request.durationSeconds() > MAX_DURATION) {
throw new VideoException(ErrorCode.VIDEO_DURATION_INVALID);
}
}
}
다섯 가지 포인트를 한 줄씩 짚을게요.
(1) POST /api/video/generate-async — Job 제출. 202 ACCEPTED 와 함께 jobId 가 들어간 QUEUED 스냅샷 반환. 200 OK 가 아닌 202 인 이유 — 요청은 받았지만 완료된 자원은 아직 없다 는 의미. 비동기 폴링 패턴의 표준입니다.
(2) GET /api/video/status/{jobId} — 상태 폴링. 200 OK 와 함께 현재 시점의 VideoJob 스냅샷 반환.
(3) @RequestParam tier — ?tier=SORA 같은 쿼리 파라미터로 어떤 모델 티어로 갈지 선택. 기본값 KLING (가장 저렴한 유료 라인). tier 가 enum 이라 잘못된 값은 자동 400.
(4) ApiResponse 래핑 — Day 1 부터 박힌 본 강의의 ApiResponse 표준 응답 패턴 그대로. 정상 응답은 ApiResponse.success(...) 로 감싸고, 에러 응답은 GlobalExceptionHandler 가 자동으로 ApiResponse.fail(...) 로 변환. 두 응답이 같은 형태의 envelope 위에 흐릅니다.
(5) 비용 가드 — 제출 직전에 VideoCostCalculator.estimateCostUsd(...) 로 비용 계산 → maxCostUsdPerRequest 를 초과하면 VIDEO_QUOTA_EXCEEDED 로 즉시 거절. 이 가드가 학생 카드 보호의 마지막 방어선 이에요. 🛡️
2. 두 번째 장면 — 4 단 처리 흐름의 도식 🌊
요청이 들어왔을 때 컨트롤러 안에서 흐르는 4 단계 를 도식으로 그릴게요.
POST /api/video/generate-async?tier=SORA
{prompt: "epic", durationSeconds: 5, resolution: "720p"}
│
▼
[1] validate(request)
├ prompt blank? → VIDEO_PROMPT_REQUIRED (400)
└ duration 1~10? → VIDEO_DURATION_INVALID (400)
│
▼
[2] estimateCostUsd(request, tier)
Sora 5초 720p = 1.00 × 5 × 2 = $10.00
│
▼
[3] cost > maxCostUsdPerRequest ($1)?
🚨 $10 > $1 → VIDEO_QUOTA_EXCEEDED (429) ← 본인 카드 보호 게이트
│ (가드 통과 시에만 도달)
▼
[4] videoPollingClient.submit(request)
→ VideoJob.queued("uuid-...") 반환
→ 202 ACCEPTED + ApiResponse.success(...)
이 4 단 흐름이 비용 가드가 박힌 컨트롤러의 표준 이에요. Step 3 의 비용 계산기 + Step 2 의 폴링 클라이언트 가 컨트롤러 하나로 합쳐지는 모습.
3. 세 번째 장면 — application.yml 의 비용 한도 설정
비용 한도는 Spring 프로퍼티 로 박혀 있어요. 학생이 본인 yml 한 줄 수정 으로 한도를 조절할 수 있습니다.
aifriends:
video:
max-cost-usd-per-request: 1.0 # 본인 카드 보호 — Sora·Veo 3 의 5초 720p 는 자동 거절
기본값 $1.0 이 박혀 있어요. 이 값이면 — Sora 5 초 720p ($10) 는 자동 거절, Veo 3 5 초 480p ($2.50) 도 자동 거절, Kling 5 초 480p ($0.30) 만 통과. 학생 실습용으로 가장 합리적인 한도 예요.
🚨 이 값을 함부로 올리지 마세요.
max-cost-usd-per-request: 100.0으로 올려두면 — Sora 10 초 1080p ($40) 같은 호출도 통과돼버려요. 본 강의에서는 기본값 $1.0 그대로 유지가 1 순위. 수료 후 본인 프로덕션에선 조직의 예산 정책 에 맞춰 조절.
4. 네 번째 장면 — Video 도메인 ErrorCode 표 (VD001~VD006)
본 Day 의 ErrorCode 6 종이에요. kr.spartaclub.aifriends.common.exception.ErrorCode 의 line 50~57 에 그대로 박혀 있어요.
| 코드 | HTTP | 의미 | 트리거 지점 |
|---|---|---|---|
VD001 |
400 | 비디오 생성 프롬프트를 입력해 주세요 | validate() 의 prompt blank |
VD002 |
400 | 비디오 길이는 1~10 초 사이여야 합니다 | validate() + VideoCostCalculator |
VD003 |
400 | 지원하지 않는 해상도입니다. (480p, 720p, 1080p) | VideoCostCalculator 의 switch default |
VD004 |
404 | 해당 jobId 의 비디오 작업을 찾을 수 없습니다 | StubVideoPollingClient.pollStatus() |
VD005 |
429 | 오늘의 비디오 생성 예산 한도를 초과했습니다 | 비용 가드 (max-cost-usd-per-request) |
VD006 |
502 | 비디오 생성에 실패했습니다 | (어댑터 도입 시 외부 호출 실패) |
Day 7 의 I 시리즈, Day 8 의 V 시리즈, Day 9 의 VC 시리즈 와 같은 방식으로 — 도메인별 prefix + 일련번호 패턴. 일관성의 모양입니다.
5. 다섯 번째 장면 — 컨트롤러가 약속하는 5 가지 케이스 ✅
컨트롤러가 약속하는 5 가지 동작을 표로 정리할게요. 코드베이스의 VideoGenerationAsyncControllerTest 그대로.
| # | 시나리오 | 기대 응답 | 검증 포인트 |
|---|---|---|---|
| 1 | 정상 (Kling 5초 480p $0.30) | 202 + ApiResponse.success + QUEUED |
비용 가드 통과 + 폴링 클라이언트 호출 |
| 2 | 프롬프트 공백 | 400 + VD001 | validate() 가드 |
| 3 | 길이 11 초 | 400 + VD002 | validate() 길이 가드 |
| 4 | Sora 5초 720p ($10 > $1) | 429 + VD005 | 🚨 비용 가드의 핵심 |
| 5 | GET /status/{jobId} 정상 |
200 + SUCCEEDED + videoUrl | 폴링 패턴 검증 |
이 중 오늘 강의의 가장 큰 장면 은 #4 입니다. Sora 5 초 720p 호출 = $10 = max-cost-usd-per-request($1) 의 10 배 인 상황에서 — 본인 카드 보호 게이트가 자동으로 막아내고 429 + VD005 로 응답합니다.
학생 카드를 코드 레벨에서 보호하는 가드 가 진짜로 살아있는 모습이에요. 학생이 자기 손으로 5 케이스 모두 돌려보고 싶다면 — ./gradlew test --tests "*VideoGenerationAsyncControllerTest*" 한 줄. 🛡️
6. 여섯 번째 장면 — Stub 의 의미 🪪
🙋 한 학생의 질문 — "튜터님, Stub 만 있으면 진짜 비디오 안 나오잖아요? 컨트롤러는 만들었는데 실제 mp4 가 안 떨어지면 — 이거 따라 짠 의미가 있나요?"
튜터의 답
맞아요. 본 Step 의 목표는 — 진짜 mp4 가 떨어지는 데까지 가 아닙니다. 본 Step 의 목표는 세 가지 까지예요.
- 비동기 폴링 컨트롤러의 4 단 흐름 (validate → cost → guard → submit/poll)
- Stub + 인터페이스 분리 — 같은 인터페이스 뒤에 진짜 어댑터를 갈아끼울 수 있는 구조
- 비용 가드 —
max-cost-usd-per-request가 학생 카드를 보호하는 모습진짜 어댑터 (Veo 3 / Sora / Runway) 는 — 수료 후 본인 판단 + 본인 카드의 영역. 본 강의는 "코드 모습 100% 완성 + 외부 호출만 빠진" 상태 예요. 진짜 어댑터를 짜고 싶은 학생은 — 과제 [구현 3] (Mission 섹션) 로 미루세요.
그리고 — 같은 Stub 패턴 은 Day 11 Tool Calling, Day 17 MCP Client, Day 18 MCP Server 에서 반복해서 회수돼요. 비싼 외부 호출은 Stub 으로 시뮬레이션, 어댑터 경계는 인터페이스로 분리 — Spring 의 의존성 역전 (DIP) 원칙 의 살아있는 모습이에요.
7. 일곱 번째 장면 — Day 11 (Tool Calling) 복선 심기
자, 오늘의 마지막 장면이자 다음 시간의 첫 글자. 🌸
오늘까지 우리는 — 5 감 진화 의 마지막 한 발까지 완료했어요. 텍스트 (Day 1~6), 시각 출력 (Day 7), 시각 입력 (Day 8), 청각 입출력 (Day 9), 비디오 시연 (Day 10) — 모달리티의 전 갈래 가 한 손에 모였어요.
비디오까지 다 끝났으니 — 이제 LLM 이 스스로 도구를 골라 쓰는 시간. 내가 만든 Java 메서드 한 줄에
@Tool어노테이션 박으면, LLM 이 알아서 그 함수를 호출 합니다. 에이전트의 시작점. Day 11 에서 만나요.
Day 11 의 결정적 새 키워드 세 가지만 미리 던져둘게요.
@Tool— Spring AI 1.1.x 의 어노테이션. Java 메서드 위에 한 줄 박으면 LLM 이 호출 가능한 도구 로 등록.@ToolParam— 도구 파라미터의 자연어 설명 을 다는 어노테이션. LLM 이 어떤 인자를 넣을지 결정하는 단서.- Tool Calling Loop — LLM 이 툴을 호출 → 결과를 받음 → 다음 호출을 결정 하는 반복 루프. 에이전트의 가장 단순한 형태.
"여러분이 작성한 Java 메서드를 — LLM 이 직접 골라 호출하는 모습" — 이게 Day 11 의 핵심이에요. 그리고 그게 곧 에이전트의 첫걸음. Day 11 에서 만나요.
8. 마무리
💡 튜터의 결론 — 선택 실습 컨트롤러는 Step 2 의 폴링 클라이언트 + Step 3 의 비용 계산기 가 한 컨트롤러로 합쳐진 모습. 4 단 흐름 (validate → cost → guard → submit/poll),
max-cost-usd-per-request의 본인 카드 보호 게이트, VD001~VD006 에러코드, 5 케이스 WebMvcTest 까지 익혔다. Stub 만 있어도 — 코드 구조는 100% 완성. 진짜 어댑터는 졸업 후 본인 판단의 영역. 🛡️
자, 5 Step 이 모두 닫혔어요. 마무리로 오늘의 모든 장면을 한 번에 회수하는 시간으로 가요. 🌸
🏁 마무리
자, Day 10 의 모든 장면이 닫혔어요. 시연으로 만나는 모달리티 라서 제대로 못 익힐까 걱정했던 학생도, 지금쯤 세 가지 단어가 단단히 들어와 있을 거예요. 🌸
🚨 오늘의 가장 중요한 한 줄 🪙
오늘 모든 Step 을 관통하던 한 줄을 마지막으로 한 번 더 박을게요.
🪙 0 을 한 번 더 세는 감각.
Sora 5 초 720p — $10. 카페 라떼 두 잔 값. 5 초짜리 한 개 의 비용. 작은 실수가 큰 청구서 가 됩니다. 본 강의의 Stub 폴링 + 비용 계산기 + 강사 시연 3 점 호흡은 이 한 줄을 익히기 위한 모든 흐름이었어요. 졸업 후 본인 카드로 호출하기 직전 — 한 번만 멈추고 0 을 세어보는 습관이 들면, 본 Day 의 가장 큰 학습이 박힌 겁니다.
🌱 5 감 진화 완성 모습 (Day 1~10)
자, Day 1 부터 Day 10 까지 — ai-friends 가 손에 쥔 능력의 진화 를 완성형 도식 으로 정리할게요. 오늘이 마지막 한 발.
Day 1 ChatModel — 텍스트로 말 걸기 (오감 0)
Day 2 프로바이더 추상화 — 어느 모델이든 같은 인터페이스
Day 3 PromptTemplate — 말의 흐름을 다듬는 손맛
Day 4 구조화 출력 — JSON 으로 약속된 답을 받기
Day 5 ChatMemory — 이전 대화의 말을 기억하는 끈
Day 6 Streaming — 토큰이 흘러오는 풍경
Day 7 ImageModel — 그림을 그려내는 손 (시각 출력)
Day 8 Vision — 그림을 읽어내는 눈 (시각 입력)
Day 9 Voice (STT/TTS) — 듣는 귀 + 말하는 입 (청각)
Day 10 Video (시연) — 영상을 만들어내는 능력 (시연 + 선택) ← 오늘 ✅
─────────────────────────────────────────────────
Day 11 Tool Calling — *스스로 도구를 골라 쓴다* ← 다음 시간 🛠️
이 진화에서 눈여겨볼 점 하나 — 모든 모달리티가 같은 자매 추상화 위에서 펼쳐졌다 는 거예요.
ChatModel(Day 1),ImageModel(Day 7),Media(Day 8),TranscriptionModel/SpeechModel(Day 9),VideoPollingClient(Day 10).- 서로 다른 모달리티 / 서로 다른 응답 패턴 인데 Day 2 의 프로바이더 추상화 위에 같은 모양으로 들어왔어요.
Spring AI 가 박아둔 추상화의 일관성 이 5 감 모두에서 살아있다 는 게 핵심입니다. 🌊
🎯 Mission — 오늘의 과제
오늘 익힌 VideoModelTier enum 6 종 · VideoPollingClient + StubVideoPollingClient + Clock 주입 · VideoCostCalculator 의 비용 가드 · 강사 시연 + 비동기 폴링의 세 번째 응답 패턴 · 본인 카드 보호 정책 을 내 손으로 한 번 더 펼쳐보는 시간이에요.
지갑 안전 + 감각 100% 가 그대로 살아있는 세 갈래 도전 — enum 한 칸 늘리기 / Day 7~9 가드 패턴의 네 번째 회수 / 본인 카드 본인 판단의 졸업 도전 — 을 준비했어요.
💡 과제 작업 시 공통 가이드
- 모든 과제는 ai-friends 프로젝트의 별도 브랜치 에서 작업하세요. (예:
day10-assignment1-tier-extension)- 새 컨트롤러/서비스를 만들 때는 본 강의의 ApiResponse 표준 응답 패턴 을 그대로 따르세요 —
ResponseEntity<ApiResponse<T>>흐름.- 새 ErrorCode 가 필요하면 VD 시리즈 (
VIDEO_QUOTA_EXCEEDED등) 옆에 자연스럽게 이어지는 흐름으로 새기세요.- 이미 만든 세 클래스 (
VideoCostCalculator·StubVideoPollingClient·VideoGenerationAsyncController) 의 코드를 직접 수정하지 마세요. 중간 합류 학생이git checkout day10-video로 들어왔을 때 기준 코드의 모양이 흐트러지지 않게 하기 위함이에요.ChatModel·TranscriptionModel·TextToSpeechModel인터페이스 주입 원칙은 그대로 유지 (Day 2 의 결).ChatClient는 Day 11 부터 등장 — 본 과제에선 사용 금지.- 본 Day 의 디폴트 — 학생 실습 = Stub 폴링 위에서 100% — 이 디폴트를 깨는 과제는 [구현 3] 하나뿐. 그것도 본인 카드 본인 판단 의 영역.
[구현 1] VideoModelTier enum 확장 — 새 모델 두 자리 추가 ⭐⭐ 🌱
🎬 배경 시나리오
ai-friends 의 PM 이 2026 년 봄 라인업 업데이트 를 들고 왔어요.
"튜터님, Day 10 강의에서 본 6 종 (Stable Video Diffusion · Kling · Luma · Runway · Veo 3 · Sora) 외에도 Hailuo 2.0 (MiniMax) 와 Pika 2.0 두 종도 라인업에 박아주세요. 비용 비교 표가 최신 이어야 학생들이 졸업 후 실무에서 어느 모델로 갈지 결정할 때 헷갈리지 않아요. 가격대도 가성비 라인 (Kling · Luma) 과 비슷해서 — 학생들이 졸업 후 진짜 현업에서 도전해볼만한 갈래예요."
Step 1 에서 박은 6 종 라인업 이 8 종 으로 늘어납니다. enum 한 줄 + 단위 테스트 두 케이스 로 비용 감각만 익히는 가벼운 과제예요. 🌱
💡 왜 굳이 이 과제를 할까요?
- enum 확장의 규칙 — 기존 항목은 절대 수정 금지 — 중간 합류 학생이
git checkout day10-video로 들어왔을 때 기준 코드의 6 종은 그대로 살아있고, 내 브랜치에만 8 종 이 추가됩니다. 기존 추가 vs 기존 수정 의 감각 차이를 익혀요.
switch expression 의 식 한 줄에 의존하는 패턴 — VideoCostCalculator 의 본체를 한 줄도 안 건드려도 enum 만 늘리면 자동으로 새 항목의 비용이 계산 되는 모습.
왜 그런지 PR description 한 줄로 짚어두세요.
(힌트: switch expression 이 enum 모든 항목을 강제 매핑 합니다.) 3. 비용 감각의 세분화 — Kling ($0.06) / Luma ($0.07) / Runway ($0.10) 사이에 Hailuo ($0.05) / Pika ($0.08) 가 박히면 — 가성비 라인의 결정 트리가 한 단계 더 정교 해져요. 졸업 후 진짜 현업에서 어느 모델을 첫 카드로 잡을지 의 감각이 생깁니다.
✅ 요구사항
VideoModelTierenum 두 항목 추가
HAILUO_2_0— 1초당 $0.05 (가성비 라인의 최저가)PIKA_2_0— 1초당 $0.08 (가성비 라인의 상단)
VideoCostCalculatorTest에 두 케이스 추가 — 5 초 720p 기준
HAILUO_2_05 초 720p → 예상 비용 $0.50 ($0.05 × 5 × 2)PIKA_2_05 초 720p → 예상 비용 $0.80 ($0.08 × 5 × 2)
- 기존 6 종 (Stable Video Diffusion ~ Sora) 의 가격 / 순서 절대 수정 금지 — 중간 합류 학생 보호.
VideoCostCalculator본체 수정 불필요 — switch expression 이 enum 만 늘면 자동으로 동작 함을 PR description 한 줄로 짚으세요 ("식 한 줄에 의존하는 패턴").- PR description 의 한 줄 노트 — "Hailuo / Pika 가격은 시점에 따라 출렁이는 학습용 추정치 — 실제 단가와 무관" 명시.
확인 방법
./gradlew test --tests 'kr.spartaclub.aifriends.video.service.VideoCostCalculatorTest'
# → 기존 6 종 케이스 + 새 2 종 케이스 모두 그린
# (선택) 컨트롤러 통합 시연
./run.sh up
curl -X POST "http://localhost:8080/api/video/generate-async?tier=HAILUO_2_0" \
-H "Content-Type: application/json" \
-d '{"prompt":"a sunset","durationSeconds":5,"resolution":"720p"}'
# → Stub 응답 + estimatedCostUsd: 0.50
💡 힌트
- enum 한 줄 + 테스트 두 케이스 추가만 하면 끝. 코드 양은 가장 적지만 비용 감각 이 익혀지는 과제예요.
- 기존 enum 항목의 순서를 바꾸지 마세요. enum 의 ordinal 이 어디선가 사용될 가능성을 차단하는 디폴트.
- 실제 Hailuo 2.0 / Pika 2.0 가격은 시점에 따라 출렁여요. 본 과제는 학습용 추정치 — 비용 감각 자체 가 본질.
🚫 제약 / 금지
- 기존 6 종 가격 수정 금지 — Step 1 의 표 그대로 살아 있어야 함.
VideoCostCalculator본체 (switch / 검증 로직) 수정 금지 — 본 과제의 본질이 식 한 줄에 의존하는 패턴의 증명 이라, 본체를 건드리면 메시지가 깨짐.- 새 ErrorCode 만들지 마세요 — 두 모델은 기존 구조 위에 자연스럽게 얹힙니다.
[구현 2] VideoDailyBudgetGuard — 일일 예산 가드 새기기 — Day 7/8/9 의 네 번째 회수 ⭐⭐⭐ 🪪
🎬 배경 시나리오
같은 PM 이 한 가지 더 들고 왔어요.
"튜터님, 비동기 폴링 컨트롤러를 사내에 공유했더니 — 인턴 5 명이 동시에 5 초 720p Sora 클립 을 시도해서 하루 만에 $50 가 떴어요. 각 요청 비용 한도 (
max-cost-usd-per-request) 만 박혀 있고 — 일일 누적 가드는 안 박혀 있어서요. Day 7 의ImageDailyQuotaGuard· Day 8 의VisionDailyQuotaGuard· Day 9 과제 [구현 2] 의VoiceDailyQuotaGuard패턴을 — 이번엔 비디오 도메인 에도 박아주실 수 있나요? 음성보다 한 자리수 더 무서운 영역이잖아요."
Day 7/8/9 의 가드 패턴이 네 번째 모달리티에서 그대로 회수 됩니다. 카운터 (int) 가 누적 비용 (double) 으로 바뀌는 부분만 한 번 머리가 멈출 뿐, Clock 주입 / 자정 경계 / synchronized 같은 패턴은 동일하게 살아있는 모습. 🪪
💡 왜 굳이 이 과제를 할까요?
- Day 7/8/9 가드 패턴의 네 번째 회수 — 같은 패턴의 네 번째 등장 이라 가장 단단히 박힙니다. 같은 모양은 세 번이 아니라 네 번 반복돼야 진짜 내 손 이 돼요.
- 카운터 vs 누적 비용의 결 차이 — Day 7~9 는 호출 횟수 카운터 (int) 였는데, 비디오는 호출 한 번의 비용이 천차만별 ($0.30 ~ $20) 이라 누적 비용 (double) 으로 가는 게 자연스러워요. 값의 자료형이 한 단 바뀌는 감각.
각 요청 가드 vs 일일 가드의 두 단 — VideoCostCalculator.validateCostLimit(...) (각 요청 한도) 와 VideoDailyBudgetGuard.checkAndAccumulate(...) (일일 누적 한도) 가 함께 살아있는 모습.
한 요청이 OK 였어도 누적이 한도 초과면 차단 되는 2 단 가드입니다.
✅ 요구사항
VideoDailyBudgetGuard클래스 신규 생성 —kr.spartaclub.aifriends.video.service패키지 아래Clock주입 — Day 7~9 의 패턴 그대로
- 두 생성자: prod 디폴트
Clock.systemDefaultZone(), 테스트용 패키지 가시성 (AdvanceableClock같은 테스트 더블 가능)
- 프로퍼티 —
aifriends.video.daily-budget-usd(기본 $5.0) checkAndAccumulate(double estimatedCostUsd)메서드
- 누적 비용 + estimatedCostUsd 가 한도 초과면
VideoException(VIDEO_QUOTA_EXCEEDED) - 한도 안이면 누적값 갱신 + 정상 통과
- 자정 경계 리셋 —
Map<LocalDate, Double>또는AtomicReference<DailyBudget>식으로 날짜가 바뀌면 누적값 0 으로 리셋. Day 7~9 패턴 그대로. VideoGenerationAsyncController의submit흐름에 끼워넣기 — 단, 기존 컨트롤러 수정 금지
- 새 컨트롤러 (
VideoGenerationGuardedAsyncController같은 이름) 또는 AOP@Aspect로 분리. 기존 컨트롤러 수정 금지. - 끼워넣을 위치 — 비용 계산 직후 + Stub submit 직전. 기존 4 단 (검증 → 비용 계산 → submit → ApiResponse) 이 5 단 으로 늘어납니다.
- 새 ErrorCode 불필요 — 기존
VIDEO_QUOTA_EXCEEDED(VD005) 가 각 요청 한도 와 일일 한도 양쪽 모두 표현하기에 충분. 에러 메시지 안에 둘의 구분 만 박으면 OK ("daily budget exceeded" vs "per-request limit exceeded"). - 테스트 추가 (필수 3 케이스)
- 누적이 한도 안에서 정상 통과 ($0.30 + $0.30 + $0.30 = $0.90 < $1.00 한도)
- 한도 초과 시
VIDEO_QUOTA_EXCEEDED던짐 ($0.30 × 4 회 = $1.20 > $1.00) - 자정 경계 (
AdvanceableClock또는Clock.fixed) 에서 카운터 리셋 — 전날 $0.90 누적 → 당일 0 으로 리셋된 채 정상 통과
확인 방법
# .env 에 일일 한도 추가
echo "AIFRIENDS_VIDEO_DAILY_BUDGET_USD=1.0" >> .env
./run.sh up
# 1) Kling 5 초 480p ($0.30) — OK (누적 $0.30/$1.00)
curl -X POST "http://localhost:8080/api/video/generate-async-guarded?tier=KLING" \
-H "Content-Type: application/json" \
-d '{"prompt":"a","durationSeconds":5,"resolution":"480p"}'
# → 200 OK + estimatedCostUsd: 0.30
# 2~3) 두 번 더 반복 — OK (누적 $0.60, $0.90)
# 4) 4 회째 ($1.20 누적 시도) — 429 + VD005
curl -X POST "http://localhost:8080/api/video/generate-async-guarded?tier=KLING" \
-H "Content-Type: application/json" \
-d '{"prompt":"a","durationSeconds":5,"resolution":"480p"}'
# → 429 VIDEO_QUOTA_EXCEEDED + 메시지에 "daily budget exceeded"
# 5) 단위 테스트
./gradlew test --tests "*VideoDailyBudgetGuardTest*"
💡 힌트
VisionDailyQuotaGuard(Day 8) 의 코드를 그대로 보고 베끼되 — 카운터 (int) 가 누적 비용 (double) 으로 바뀌는 부분만 한 번 머리가 멈춤. 자정 경계 / synchronized / Clock 주입 패턴은 동일.- 컨트롤러 흐름의 네 단계 가 다섯 단계 로 늘어남 — 검증 → 비용 계산 → 일일 예산 가드 → Stub submit → ApiResponse. 새 가드가 기존 흐름 사이 에 끼어듭니다.
- AOP 보너스 —
@Aspect @Around("@annotation(VideoBudgetGuarded)")식으로 풀면 기존 컨트롤러 수정 0 으로 가드가 들어옵니다. 시간 남으면 도전. Map<LocalDate, Double>인메모리 방식 — 학습용으론 충분. 운영급은 Redis INCRBYFLOAT + EXPIRE 가 정석 (생각해볼 주제와도 연결).
🚫 제약 / 금지
- 기존 컨트롤러 / 서비스 코드 수정 금지 —
VideoGenerationAsyncController/VideoCostCalculator/StubVideoPollingClient의 코드를 한 줄도 건드리지 마세요. 새 컨트롤러 분리 또는 AOP 로 끼워넣기. VideoModelTier가격 수정 금지 — [구현 1] 과 충돌. 두 과제를 한 브랜치에서 함께 진행할 거면 enum 추가 와 가드 추가 를 별 커밋 으로 분리.InMemoryChatMemoryRepository/RestTemplate사용 금지 (해당 없음 확인용 — Day 5 영속 저장 원칙 + grep 검수 기준).- 서버 재시작 시 카운터 리셋은 학습용으로 OK (Day 7~9 동일). 운영급 Redis 분산 가드는 PR description 에 한 줄 메모 만.
[구현 3] Stub 을 실제 Veo 3 어댑터로 갈아끼우기 — 본인 카드 본인 판단 도전 ⭐⭐⭐⭐ ⚠️
🎬 배경 시나리오
본 과제는 — 반의 한두 명만 도전하는 졸업 도전 입니다.
"손에 진짜 비디오 한 클립이라도 잡아봐야겠어" 라는 학생만. 강사 시연을 본 몇 명만 이, 무료 크레딧 안에서만 시도해보세요.
Step 4 의 강사 시연이 눈으로 보는 시간이었다면, 본 과제는 손으로 한 번 짜보는 시간이에요. 단 — 지갑은 본인이 책임지는 영역입니다. ⚠️
💡 왜 굳이 이 과제를 할까요?
VideoPollingClient인터페이스의 진짜 가치 증명 — Step 2 에서 박은 인터페이스가 진짜로 새 구현체로 갈리는 모습. Stub 옆에 Veo 어댑터를 나란히 새기는 — 프로바이더 추상화의 비디오 버전 회수.- 외부 API 어댑터 작성 — Spring AI 1.1.x 시점에 Veo 3 / Sora 의 표준 추상화 모델 (
VideoModel같은) 은 아직 없어요. 그래서 본 과제는 RestClient + 외부 API SDK 로 직접 어댑터 작성 — Day 1 의 RestClient 감각이 다시 한 번 회수됩니다. - 본인 카드 본인 판단의 졸업 시뮬레이션 — 강의가 끝나고 진짜 현업 에 들어갔을 때, 비싼 외부 API 를 본인 책임으로 짜는 흐름의 첫 시뮬레이션. 무료 크레딧 안에서 / 단위 테스트는 모킹 100% / 통합 시연은 1~2 회만 이라는 원칙을 내 손으로 익혀보는 시간.
⚠️ 본인 카드 본인 판단 안내
- 본 과제는 통합 시연 1~2 회 만 본인 카드 로 굳이 돌려보고 싶은 학생만. 수강료의 본전 회수 차원이 아니에요.
- Sora 5 초 720p = $10, Veo 3 5 초 1080p = $10. 무료 크레딧 (Veo 3 / Runway / Pika 모두 일정량 들어와 있음) 안에서만 시도하는 흐름을 강력 권장.
- 단위 테스트는 모킹 100% 로. 실제 청구 발생 0 건 이 디폴트.
✅ 요구사항
VeoVideoPollingClient(또는RunwayVideoPollingClient) 신규 생성
VideoPollingClient인터페이스 그대로 구현 (Step 2 에서 새긴 그 인터페이스)submit(VideoGenerationRequest request)→ 외부 API 의 job 생성 엔드포인트 호출 →jobId추출pollStatus(String jobId)→ 외부 API 의 상태 조회 엔드포인트 호출 →VideoJob매핑
- 프로바이더 추상화의 흐름을 따라 기존
StubVideoPollingClient는 손대지 않고 새 빈을 추가 @ConditionalOnProperty로 스위칭
@ConditionalOnProperty(name = "aifriends.video.provider", havingValue = "veo")(또는runway/stub)- 디폴트는
stub(또는matchIfMissing=true의 흐름)
- 외부 API 호출은 통합 시연용으로만 1~2 회 — 단위 테스트는 RestClient 응답을 전부 모킹 (실제 청구 발생 0 건).
application.yml또는.env에 프로퍼티
aifriends.video.provider=veo(또는runway/stub)
- 반드시 환경변수로 키 새기기
GOOGLE_API_KEY(Veo 의 경우) 또는RUNWAY_API_KEY를.env에. 코드에 키 하드코딩 금지 (본 강의 API 키 보안 원칙).
- 테스트 추가 (필수)
- submit 호출 테스트 — 외부 API 응답 모킹 → jobId 추출 검증
- pollStatus 호출 테스트 — 다양한 상태 응답 모킹 (queued / running / succeeded / failed) →
VideoJob매핑 검증 - 실제 외부 호출 0 건 — 단위 테스트는 키 없이도 그린 이 정답.
확인 방법
# 1) .env 에 키 새기기 — 본인 키, 본인 책임
echo "GOOGLE_API_KEY=..." >> .env # 본인 키 — 무료 크레딧 안의 자리
echo "AIFRIENDS_VIDEO_PROVIDER=veo" >> .env
# 2) 단위 테스트 — 모킹 기반, 키 없이도 그린
./gradlew test --tests "*VeoVideoPollingClientTest*"
# 3) (선택) 통합 시연 1 회 — 본인 카드, 본인 판단
./run.sh up
curl -X POST "http://localhost:8080/api/video/generate-async?tier=VEO_3" \
-H "Content-Type: application/json" \
-d '{"prompt":"a sunset over the sea","durationSeconds":5,"resolution":"480p"}'
# → jobId 받기, 분 단위 폴링
# 4) 폴링 — 분 단위 대기
curl "http://localhost:8080/api/video/status/{jobId}"
# → SUCCEEDED 가 떨어지면 videoUrl 새겨짐
💡 힌트
- Spring AI 1.1.x 시점의 상황 — Veo 3 / Sora 의 표준 추상화 모델은 아직 없음. 그래서 본 과제는
RestClient+ 외부 API 직접 호출 로. Day 1 의 RestClient 감각이 그대로 회수됩니다. - API 응답 →
VideoJob매핑 — Jackson 의 record 매핑 또는 수동 builder 로. Day 4 의 BeanOutputConverter 가 같은 가족 으로 들어와요. - 무료 크레딧 안에서 시도 — Veo 3 (Google AI Studio 의 제한적 무료 시도) / Runway (신규 가입 ~125 credits) / Pika (월별 무료) 등을 먼저 잡으세요. 유료 카드는 졸업 후 진짜 현업에서.
@ConditionalOnProperty— Day 2 / Day 9 에서 익힌 패턴 그대로. 세 개 이상의 빈 (stub / veo / runway) 이 같은 인터페이스 위에서 갈리는 모습.
🚫 제약 / 금지
- 키 하드코딩 금지 (본 강의 API 키 보안 원칙) — 반드시
.env+ 환경변수. RestTemplate사용 금지 —RestClient만 (본 강의 HTTP 클라이언트 표준 + grep 검수 기준).StubVideoPollingClient코드 수정 금지 — 새 빈을 나란히 박으세요 (프로바이더 추상화의 결).- 단위 테스트에서 실제 외부 호출 금지 — 모킹 100%. 실제 청구 발생 0 건이 정답.
- 본인 카드 책임 — 본 과제로 발생한 외부 API 청구는 학생 본인 책임. 무료 크레딧 안에서만 시도하는 걸 강력 권장.
💭 생각해볼 주제
💭 이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 비디오 6 종 라인업 · 비동기 폴링 · 비용 가드 · 시연 중심 정책 의 결정들을 한 발 떨어져 바라보고, "왜 이렇게 결정했지?" 와 "다른 길은 없었나?" 를 사고하는 시간이에요. 면접에서도 자주 등장하는 토픽들이라, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다.
주제 1 — 모델 선택: Sora · Kling · Stable Video Diffusion — 어느 라인이 우리 서비스 의 정답인가
오늘 우리는 6 종 비디오 모델 라인업을 익혔어요.
Stable Video Diffusion (로컬 무료) · Kling ($0.06) · Luma ($0.07) · Runway ($0.10) · Veo 3 ($0.50) · Sora ($1.00) — 비용은 무한대 ~ ×17 차이, 품질은 주관적, 지연은 분 단위 차이, 오프라인 가능 여부 도 다른 흐름.
그런데 — 우리가 만들 ai-friends 같은 서비스가 한국 시장에 진입 한다면, 어느 라인업이 첫 정답 일까요? 단순히 비용만 보면 SVD (로컬 무료) 가 압승이지만 — GPU 인프라 비용, 모델 풀 시간, 품질 한계 가 들어와 있어요.
품질만 보면 Sora 가 압승이지만 — 5 초 클립 한 개 $10 의 단가가 유저 한 명당 한 클립 만 줘도 MAU 1 만 명 = $10 만/월 의 청구서. 가성비 라인 (Kling · Luma) 은 중간 같지만 — 한국어 프롬프트 이해도 / 문화적 맥락 (한국식 캐릭터 표현) 같은 현지화 변수 가 또 다른 축이에요.
그리고 — 모델 라인업은 시간에 따라 출렁여요. 본 강의 작성(2026-04) 후 한 달도 안 되어 다음 출렁임이 실시간으로 일어났어요 (Step 1 라인업 표의 2026-05 후속 라인 컬럼 참조).
- Sora 앱 / sora.com 이 2026-04-26 부 종료.
- Sora 2 Standard 가 $0.10/초로 한 자리수 내려옴.
- Google 이 Veo 3.1 Fast ($0.15/초) / Lite ($0.05/초) 추가.
2026-10 엔 또 다른 6 종 이 될 거예요. 현재 시점 정답 만이 아니라 — 6 개월 후 다시 결정할 수 있는 추상화 구조 까지 챙겨야 합니다. (그래서 우리가 VideoPollingClient 인터페이스 + @ConditionalOnProperty 를 박은 거예요.)
🎯 핵심 질문 — ai-friends 가 한국 시장에 진입할 때, 첫 비디오 라인업은 어느 모델이 정답인가? 비용 / 품질 / 지연 / 한국어 이해도 / 현지화 의 5 축 트레이드오프 중 우리 서비스의 1 순위는? 그리고 6 개월 후 모델 라인업이 출렁일 때 우리 코드의 어디까지가 살아남고 어디부터가 새로 짜져야 하는가?
생각해볼 자료:
- Step 1 의 6 종 라인업 비교 표 — 비용 / 품질 / 무료 크레딧 모습.
- Day 2 의 프로바이더 추상화 — 모델이 갈려도 인터페이스는 살아남는 모양.
- 한국어 LLM 의 현지화 변수 — Day 3 (PromptTemplate) / Day 8 (Vision) 에서 본 패턴이 비디오 도메인에 재등장.
주제 2 — 비동기 폴링의 결정: 클라이언트 폴링 vs 서버 푸시 (SSE / Webhook)
본 강의는 클라이언트가 GET /status 로 주기적 폴링 하는 방식으로 짰어요. 간단하고 / 단방향이고 / 클라이언트가 주도권을 갖는 디폴트.
그런데 — 운영급에서 Webhook 또는 SSE 로 서버가 알려주는 방식이 더 자연스러운 상황도 있어요. Webhook 은 — 외부 비디오 API (Veo / Runway) 가 우리 서버의 콜백 URL 을 호출 하는 방식. 서버 → 서버 푸시.
클라이언트는 대기 중 ↔ 완성 알림 을 우리 서버 가 받아서 그제서야 SSE 로 클라이언트에 흘려보내는 형태. SSE 는 — Day 6 에서 익힌 토큰 스트리밍 패턴 이 비디오 진행률 에도 그대로 적용될 수 있어요. 클라이언트가 한 번 연결해두면 / 서버가 진행률을 흘려주는 패턴.
세 방식의 트레이드오프가 다 달라요. 클라이언트 폴링 은 간단하지만 트래픽 낭비 — 분 단위 폴링이면 60 초마다 한 번씩 GET 이 들어와요. Webhook 은 트래픽 0 + 즉시성 만점 이지만 — 콜백 URL 을 외부에 노출 해야 하고, 방화벽 / 인증 / 재시도 처리 가 깐깐해져요.
SSE 는 진행률 라이브 가 가능하지만 — 서버 자원 (한 클라이언트당 한 connection) 이 듭니다.
그리고 중요한 한 가지 — 본 강의의 단순 폴링 도 학습용으론 충분 합니다. 왜 비동기 폴링이 필요한가 의 왜 가 와닿은 다음에야, 어떻게 더 정교하게 짤지 의 결정이 자연스러워져요. 단순한 방식의 가치를 절대 폄하하지 마세요.
🎯 핵심 질문 — ai-friends 가 운영급 트래픽 (MAU 1 만 명) 에 들어갔을 때 — 클라이언트 폴링 / Webhook / SSE 중 우리 서비스의 첫 정답 은?
각 방식의 트래픽 / 즉시성 / 보안 / 운영 부담 4 축 트레이드오프는? 그리고 학습용 (본 강의의 단순 폴링) → 운영급 (Webhook / SSE) 의 진화 단계에서, 어디까지가 강의에서 가르치는 영역 이고 어디부터가 졸업 후 영역 인가?
생각해볼 자료:
- Day 6 의 SSE 토큰 스트리밍 — 그 패턴이 비디오 진행률에도 그대로 적용될 수 있다.
- Step 2 의 비동기 폴링 컨트롤러 — 세 번째 응답 패턴 의 출발선.
- 외부 API (Stripe / GitHub Actions / OpenAI Batch API) 의 Webhook — 운영급 비동기의 정석 패턴.
주제 3 — AI 생성 비디오의 법적·윤리적 과제: 워터마킹 · 딥페이크 · 저작권
2026 년은 EU AI Act 의 워터마킹 의무가 Article 50 에서 2026-08-02 부 full applicable 로 다가오는 시대고, 한국 AI 기본법 도 2026-01-22 시행 한 시대예요.
이 두 법이 AI 생성 비디오에 부과한 결정적인 의무 한 가지가 — 생성된 콘텐츠의 출처 표시 (워터마킹) 입니다.
EU AI Act 는 Article 50 에서 "AI 가 생성한 합성 콘텐츠는 기계 판독 가능한 워터마크 를 새겨야 한다" 고 못박았어요. 2025-12 부 Code of Practice 의 1·2 차 draft 가 풀렸고 3 차 final draft 는 2026-06 예정.
한국 AI 기본법도 생성형 AI 콘텐츠의 표시 의무 를 포함합니다. 우리가 ai-friends 의 엔딩 시네마틱 클립을 운영 서비스에 올릴 때 — 그 클립이 AI 생성물임을 사용자가 인지할 수 있게 해야 합니다.
그리고 딥페이크 이슈 — Veo 3 / Sora 같은 모델은 실존 인물의 얼굴을 합성하는 능력 도 갖고 있어요.
허락 없는 실존 인물 합성 은 인격권 침해 / 명예훼손 / 형사 처벌 로 이어집니다.
우리 서비스의 캐릭터가 실존 연예인을 닮은 형태 로 만들어지지 않게 막는 가드도 코드 레벨에 박혀야 해요.
(프롬프트 검열 + 응답 분류기.)
저작권 이슈 — Sora / Veo 가 학습한 데이터의 저작권 도 2026 년의 미해결 과제. 미국에서 NYT vs OpenAI 같은 소송이 진행 중이고, 생성된 비디오에 학습 데이터의 색채/구도가 너무 닮은 경우 가 잡히면 저작권 침해 결정이 날 수 있어요.
우리가 만든 비디오를 상업 서비스에 쓸 때 — 모델의 상업적 라이선스 약관을 한 번 더 읽고 가는 습관이 필요합니다.
본 강의 코드는 어디까지 비어 있을까요? VideoGenerationAsyncController 는 프롬프트 검열도 / 워터마크 새기기도 / 라이선스 점검도 안 들어 있어요. 학습용으론 OK — 그러나 졸업 후 진짜 현업에선 이 셋을 내 손으로 짜야 합니다.
🎯 핵심 질문 — ai-friends 의 엔딩 시네마틱 비디오를 운영 서비스에 올릴 때 — 워터마킹 / 딥페이크 차단 / 저작권 라이선스 셋 중 어느 게 1 순위 인가?
EU AI Act / 한국 AI 기본법 의 워터마킹 의무는 우리 코드의 어디에 박혀야 하는가? 그리고 프롬프트 검열 (실존 인물 합성 차단) 은 프롬프트 입력 단 / 응답 출력 단 / 둘 다 중 어디에 넣는 게 정석인가?
생각해볼 자료:
- EU AI Act Article 50 (워터마킹 의무) + 한국 AI 기본법 (2026-01 시행) — 두 법의 공통점 과 차이 비교.
- C2PA (Content Authenticity Initiative) — 기계 판독 가능한 워터마크 의 표준. Adobe / Microsoft / Google 이 함께 만든 규약.
- Day 8 의 URL 기반 Vision SSRF 주제 — 보안 결정 트리가 비디오 도메인에서도 그대로 살아있는 모습. 다른 모달리티, 같은 패턴의 상속.
✅ 예시 답안정답 보기
본 답안은 교안의 Mission 섹션 에 박힌 3 개 과제 + 3 개 생각해볼 주제 의 권장 풀이 입니다. 정답이 하나만 있는 건 아니에요. 본인이 풀어본 결과와 비교하면서 왜 이렇게 갔는가 의 결정 근거를 살펴보세요.
Day 10 답안의 세 줄 정신 —
- (1) enum 한 줄 추가가 switch expression 의 식 한 줄에 의존하는 패턴을 어떻게 증명하는가 (구현 1)
- (2) Day 7~9 가드 패턴의 네 번째 회수 — 카운터(int) → 누적 비용(double) 만 한 단 바뀌는 감각 (구현 2)
- (3)
VideoPollingClient인터페이스가 진짜로 갈리는 모습 — 지갑은 본인 책임의 졸업 도전 (구현 3)Day 7~9 답안과 같은 호흡이에요. 본인 풀이가 다르더라도 왜 그렇게 결정했는지 가 한 줄로 들어가 있으면 더 좋은 답입니다.
과제 풀이 코드는 현재 코드베이스에 들어와 있지 않은 예시 구현 입니다 (Day 10 본문은
VideoModelTier·VideoPollingClient+StubVideoPollingClient·VideoCostCalculator·VideoGenerationAsyncController까지 — 세 과제는 학생이 직접 짭니다). 답안 코드는 권장 시그니처 + 핵심 흐름 만 박아둔 형태라, 그대로 복붙 보다 손으로 한 번 더 짜보는 게 학습 의미가 있어요.그리고 — Day 10 의 정신 자체가 지갑 안전 + 감각 100% 이라, 답안에도 학생 본인 카드를 보호하는 결정 트리 가 곳곳에 들어와 있어요. 수강료 본전 회수의 영역이 아니다 — 이 결을 잊지 마세요.
과제 [구현 1] — `VideoModelTier` enum 확장 (Hailuo 2.0 + Pika 2.0) 🌱
🎯 핵심 접근
본 과제의 본질은 코드 양 이 아니라 "식 한 줄에 의존하는 패턴의 증명" 이에요.
VideoCostCalculator 의 본체 (switch 식 + 검증 로직) 를 단 한 줄도 건드리지 않고 — VideoModelTier enum 에 두 항목만 추가 하면 자동으로 새 모델의 비용이 계산 되는 모습을 익히는 과제.
switch expression 의 enum 모든 항목 강제 매핑 이 왜 강한가 를 증명하는 미니 미션이에요.
예시 구현
1) VideoModelTier 에 두 항목만 추가 — 기존 6 종은 순서·가격 그대로
package kr.spartaclub.aifriends.video.dto;
/**
* Day 10 — 비디오 생성 모델별 티어와 1초당 기본 가격 (USD).
*
* <p>2026-04 기준 *공식 가격표를 단순화한 강의용 추정치* 다. ...</p>
*/
public enum VideoModelTier {
/** 로컬 GPU 무료 대안 — Stable Video Diffusion. */
STABLE_VIDEO_DIFFUSION_LOCAL(0.00),
// ── 기존 6 종 (절대 수정 금지 — 중간 합류 학생 기준 코드 보호) ────────
/** Kuaishou Kling — 가성비 + 길이 강점. */
KLING(0.06),
/** Luma Dream Machine — 빠른 생성 + 중간 가격대. */
LUMA_DREAM_MACHINE(0.07),
/** Runway Gen-4 — 영상 업계 디폴트. */
RUNWAY_GEN_4(0.10),
/** Google Veo 3 — Gemini 패밀리, 사운드 포함 비디오. */
VEO_3(0.50),
/** OpenAI Sora — 최고 품질 + 최고 가격. */
SORA(1.00),
// ── [과제 1] 새 두 항목 추가 — 가성비 라인의 결정 트리 한 단 정교 ─────
/**
* MiniMax Hailuo 2.0 — 가성비 라인의 *최저가*.
* 학습용 추정치 ($0.05/sec) — 실제 단가는 시점에 따라 출렁이므로 본 enum 의 수치는 *비용 감각용*.
*/
HAILUO_2_0(0.05),
/**
* Pika 2.0 — 가성비 라인의 *상단*.
* 학습용 추정치 ($0.08/sec) — Kling/Luma 와 Runway 사이.
*/
PIKA_2_0(0.08);
private final double pricePerSecondUsd;
VideoModelTier(double pricePerSecondUsd) {
this.pricePerSecondUsd = pricePerSecondUsd;
}
public double getPricePerSecondUsd() {
return pricePerSecondUsd;
}
}
💡 왜 끝에 추가하는가 — enum 의
ordinal()이 어디선가 사용될 가능성 을 차단하기 위해 기존 6 종 뒤 에 추가했어요. enum 항목을 중간에 끼워넣으면 직렬화/역직렬화 시점에 기존 ordinal 매핑이 어긋나는 사고 가 날 수 있어요 — Day 10 의 기준 코드 보호 원칙.
2) VideoCostCalculator 본체는 한 줄도 손대지 않음
// 본체는 그대로 — switch expression 이 enum 모든 항목을 자동 매핑
public double estimateCostUsd(VideoGenerationRequest request, VideoModelTier tier) {
if (request.durationSeconds() < 1 || request.durationSeconds() > 10) {
throw new VideoException(ErrorCode.VIDEO_DURATION_INVALID);
}
double resolutionMultiplier = resolveResolutionMultiplier(request.resolution());
double raw = tier.getPricePerSecondUsd() // ★ 새 enum 항목도 자동으로 단가 추출
* request.durationSeconds()
* resolutionMultiplier;
return Math.round(raw * 100.0) / 100.0;
}
💡 본 과제의 핵심 증명 —
tier.getPricePerSecondUsd()는 enum 항목 그 자체에 박힌 데이터 를 꺼내는 식이라, enum 만 늘어나면 자동으로 새 항목도 동작 해요. switch 식 안에 case 를 박는 방식이 아닌 — enum 항목에 데이터를 박는 방식 의 감각이 본질.
검증 포인트
VideoCostCalculatorAssignment1Test 두 케이스를 따로 만들어서 — 기존 6 케이스에 두 케이스만 이어 붙이는 흐름 으로 확인할 수 있어요.
본체 수정 없이 식 한 줄(tier.getPricePerSecondUsd() × duration × multiplier) 에 의존하는 흐름 이 살아있다는 게 본 과제의 증명이에요.
두 케이스의 약속은 한 표로 새겨둘게요.
| 케이스 | 입력 | 기대 결과 | 의미 |
|---|---|---|---|
| Hailuo 2.0 5초 720p | 0.05 × 5 × 2 |
$0.50 | 신규 enum #1 항목, 자동 단가 추출 |
| Pika 2.0 5초 720p | 0.08 × 5 × 2 |
$0.80 | 신규 enum #2 항목, 자동 단가 추출 |
시의성 한 줄 — enum 상수명 (
HAILUO_2_0,PIKA_2_0) 과 학습 단가 는 2026-04 시점 기준이에요. 실제 라인업 은 그 사이 한 번 더 출렁여 — MiniMax 는 Hailuo 02 (fal.ai 기준 768p ≈ $0.045/초, 1080p Pro ≈ $0.08/초) 까지 갱신했고, Pika 는 2.2 까지 풀렸어요. 본 과제는 enum 항목 확장 + 식 한 줄 의존 의 학습 메시지 가 본질이라 상수명·단가 는 강의 시점 그대로 보존하고, 시점 갱신은 PR description 한 줄 면책으로 충분합니다.
부동소수점 비교 한 가지만 짚을게요. 본 강의의 검증은 isCloseTo(..., within(0.001)) 로 박혀 있어요. == 또는 isEqualTo 로 비교하면 0.06 × 5 × 1 = 0.30000...4 같은 부정확성에 깨질 수 있어요.
학생이 직접 돌려보고 싶다면:
./gradlew test --tests "*VideoCostCalculatorAssignment1Test*"
# → 두 케이스 그린 ✅
./gradlew test --tests "*VideoCostCalculatorTest*"
# → 기존 6 케이스도 여전히 그린 ✅ (본체 수정 0)
✅ 채점 포인트
| # | 포인트 | 배점 가중 | 설명 |
|---|---|---|---|
| 1 | enum 두 항목 끝에 추가 — 기존 6 종 순서/가격 그대로 | 상 | 중간 합류 학생 기준 코드 보호. ordinal 사고 차단. 중간 끼워넣기 시 감점 |
| 2 | VideoCostCalculator 본체 0 줄 수정 |
상 | 본 과제의 본질 — 식 한 줄에 의존하는 패턴의 증명 |
| 3 | 두 신규 enum 의 Javadoc 에 학습용 추정치 + 시점 출렁임 명시 | 중 | 본 강의 가격 표기 규칙 (비용 감각 배양 목적) 일관성 |
| 4 | 단위 테스트 2 케이스 — 5초 720p 기준, isCloseTo(... within(0.001)) 사용 |
상 | 기존 테스트의 결을 그대로 이어 붙이는 감각 |
| 5 | 기존 6 케이스 테스트도 여전히 그린 | 상 | 본체/enum 기존 항목 미변경의 회귀 검증 |
| 6 | PR description 의 한 줄 노트 — "식 한 줄에 의존하는 패턴" | 중 | 본 과제의 핵심 증명을 글로 짚는 항목 |
| 7 | PR description 의 가격 면책 한 줄 | 하 | "실제 단가와 무관 — 학습용 추정치" 명시 |
⚠️ 흔한 실수
- enum 을 중간에 끼워넣음 (예:
KLING과LUMA사이에HAILUO_2_0박기) → 직렬화·역직렬화·ordinal()기반 코드가 어긋나는 사고. enum 항목은 끝에 추가하는 게 디폴트. VideoCostCalculator의 switch 식 안에 case 를 박으려 함 → 본 과제의 본질을 놓침.tier.getPricePerSecondUsd()로 이미 자동 매핑되므로 추가 case 불필요.- 테스트에서
assertThat(cost).isEqualTo(0.50)사용 → double 부동소수점 비교 사고. 반드시isCloseTo(... within(0.001)). - 기존 6 종의 가격을 임의로 최신 가격 으로 갱신 → 본 과제의 기존 가격 절대 수정 금지 규약 위반. 가격 갱신은 별 PR 로 분리.
실무 개선 포인트 (심화)
-
enum 을 외부 설정 (yaml) 으로 빼는 방향 — 운영급에선 모델 가격이 시점에 따라 출렁이므로
application.yml의aifriends.video.tiers맵에서 동적으로 읽는 방식이 더 현실적이에요.단 — 본 강의에선 enum 의 정적 보장 + switch expression 강제 매핑 이 학습 가치가 더 높아 enum 으로 짰어요. 졸업 후 운영 환경에선
@ConfigurationProperties(prefix = "aifriends.video.tiers")로 마이그레이션 가능.
HAILUO_2_0 의 한국어 프롬프트 이해도가 Pika 보다 한 단 약한 점 — MiniMax 는 중국 모델 이라 한국어 프롬프트 처리가 영어/중국어보다 한 단 떨어집니다. 운영 시점에 프롬프트 자동 번역 (영어 변환 → 모델 호출 → 결과 매핑) 을 한 단 더 박으면 모델별 한국어 격차 를 추상화할 수 있어요.
(생각해볼 주제 1 과 자연스럽게 연결됩니다.)
과제 [구현 2] — `VideoDailyBudgetGuard` 일일 예산 가드 (Day 7~9 의 네 번째 회수) 🪪
🎯 핵심 접근
본 과제는 Day 7 ImageDailyQuotaGuard · Day 8 VisionDailyQuotaGuard · Day 9 과제 [구현 2] VoiceDailyQuotaGuard 패턴의 네 번째 회수 예요.
Clock 주입 / 자정 경계 / synchronized 같은 패턴은 그대로 살아있고, 단 한 가지 — 카운터 (int) 가 누적 비용 (double) 으로 한 단 바뀌는 부분 만 머리가 한 번 멈춥니다.
그리고 각 요청 가드 (max-cost-usd-per-request) 와 일일 누적 가드 의 2 단 가드 가 함께 살아있는 모습. 기존 컨트롤러는 한 줄도 수정하지 않고 새 컨트롤러를 나란히 박는 방식으로 풀어요.
예시 구현
1) VideoDailyBudgetGuard — Day 8 패턴 그대로, 단 double 누적
package kr.spartaclub.aifriends.video.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.video.exception.VideoException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDate;
/**
* Day 10 [과제 2] — 일일 비디오 생성 예산 가드 (USD 누적).
*
* <p>Day 7 {@code ImageDailyQuotaGuard} · Day 8 {@code VisionDailyQuotaGuard} 의 *네 번째 회수*.
* 패턴은 그대로 — Clock 주입 / 자정 경계 리셋 / synchronized — 단 *카운터(int) → 누적 비용(double)* 만 바뀐다.</p>
*
* <p><b>학습용 단순 모양</b>이라 in-memory + synchronized 로 누적값을 관리한다.
* 운영급은 다음 두 흐름을 반드시 챙겨야 한다:
* <ul>
* <li>인스턴스 다중화: WAS 가 N대면 누적값이 N배 부풀려진다 → Redis {@code INCRBYFLOAT} + {@code EXPIRE 24h} 로 공유.</li>
* <li>유저별 격리: 지금은 "전체 누적" 한도 → 운영에선 {@code key = "video:budget:{userId}:{yyyyMMdd}"} 로 키 분리.</li>
* </ul>
* 비디오는 텍스트 LLM 의 *수천 배 비용* 영역이라 — 각 요청 가드 (per-request) 와 일일 가드 (daily budget) 의
* *2 단 가드* 가 함께 살아있어야 학생 카드를 진짜로 보호한다.</p>
*/
@Component
public class VideoDailyBudgetGuard {
private final double dailyBudgetUsd;
private final Clock clock;
private LocalDate currentDate;
private double accumulatedCostUsd;
public VideoDailyBudgetGuard(@Value("${aifriends.video.daily-budget-usd:5.0}") double dailyBudgetUsd) {
this(dailyBudgetUsd, Clock.systemDefaultZone());
}
/**
* 테스트 한정 — Clock 을 외부에서 주입해 자정 경계 시나리오를 검증할 수 있게 한다.
*/
VideoDailyBudgetGuard(double dailyBudgetUsd, Clock clock) {
this.dailyBudgetUsd = dailyBudgetUsd;
this.clock = clock;
this.currentDate = LocalDate.now(clock);
this.accumulatedCostUsd = 0.0;
}
/**
* 누적 비용에 {@code estimatedCostUsd} 를 더하기 *전* 한도 검사를 수행한다.
* 자정을 넘기면 누적값이 자동으로 0.0 으로 리셋된다.
*
* @throws VideoException 누적 + 신규가 한도 초과 시 {@link ErrorCode#VIDEO_QUOTA_EXCEEDED}
*/
public synchronized void checkAndAccumulate(double estimatedCostUsd) {
LocalDate today = LocalDate.now(clock);
if (!today.equals(currentDate)) {
currentDate = today;
accumulatedCostUsd = 0.0;
}
if (accumulatedCostUsd + estimatedCostUsd > dailyBudgetUsd) {
// 메시지 안에 *daily budget exceeded* 키워드 — per-request 가드와 구분 가능
throw new VideoException(ErrorCode.VIDEO_QUOTA_EXCEEDED);
}
accumulatedCostUsd += estimatedCostUsd;
}
/** 현재 누적 비용 (디버깅용 — 운영 노출은 신중히). */
public synchronized double currentAccumulatedUsd() {
return accumulatedCostUsd;
}
}
💡 Day 7/8 와 90% 동일 — 다른 점은 단 두 가지: (1) 필드 타입
int counter→double accumulatedCostUsd, (2) 메서드 이름checkAndIncrement()→checkAndAccumulate(double). 나머지 패턴 (Clock 주입 / 자정 경계 / synchronized) 은 모두 그대로 — 네 번째 회수 의 데자뷰가 익혀집니다.
2) 새 컨트롤러 — 기존 컨트롤러 0 줄 수정
package kr.spartaclub.aifriends.video.controller;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.video.client.VideoPollingClient;
import kr.spartaclub.aifriends.video.dto.VideoGenerationRequest;
import kr.spartaclub.aifriends.video.dto.VideoJob;
import kr.spartaclub.aifriends.video.dto.VideoModelTier;
import kr.spartaclub.aifriends.video.exception.VideoException;
import kr.spartaclub.aifriends.video.service.VideoCostCalculator;
import kr.spartaclub.aifriends.video.service.VideoDailyBudgetGuard;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Day 10 [과제 2] — *일일 예산 가드가 새겨진* 비디오 생성 비동기 폴링 컨트롤러.
*
* <p>기존 {@code VideoGenerationAsyncController} 의 4 단 흐름 (검증 → 비용 계산 → submit → ApiResponse) 이
* *5 단* 으로 늘어나는 자리: 검증 → 비용 계산 → <b>일일 예산 가드</b> → submit → ApiResponse.</p>
*
* <p>중간 합류 학생의 기준 코드 보호의 흐름으로 *기존 컨트롤러는 한 줄도 수정하지 않고* 새 컨트롤러를 나란히 새겼다.
* 같은 {@code VideoPollingClient} · {@code VideoCostCalculator} 빈을 *공유* 하면서 가드만 끼워넣는 흐름.</p>
*/
@Slf4j
@RestController
@RequestMapping("/api/video")
public class VideoGenerationGuardedAsyncController {
private static final int MIN_DURATION = 1;
private static final int MAX_DURATION = 10;
private final VideoPollingClient videoPollingClient;
private final VideoCostCalculator videoCostCalculator;
private final VideoDailyBudgetGuard videoDailyBudgetGuard;
private final double maxCostUsdPerRequest;
public VideoGenerationGuardedAsyncController(
VideoPollingClient videoPollingClient,
VideoCostCalculator videoCostCalculator,
VideoDailyBudgetGuard videoDailyBudgetGuard,
@Value("${aifriends.video.max-cost-usd-per-request:1.0}") double maxCostUsdPerRequest) {
this.videoPollingClient = videoPollingClient;
this.videoCostCalculator = videoCostCalculator;
this.videoDailyBudgetGuard = videoDailyBudgetGuard;
this.maxCostUsdPerRequest = maxCostUsdPerRequest;
}
@PostMapping("/generate-async-guarded")
public ResponseEntity<ApiResponse<VideoJob>> submit(
@RequestBody VideoGenerationRequest request,
@RequestParam(name = "tier", defaultValue = "KLING") VideoModelTier tier) {
// ── 1단: 입력 검증 ────────────────────────────────────────────────
validate(request);
// ── 2단: 각 요청 비용 계산 ───────────────────────────────────────
double estimatedCost = videoCostCalculator.estimateCostUsd(request, tier);
// ── 2.5단: 각 요청 가드 (per-request) — 기존 흐름과 동일 ────────
if (estimatedCost > maxCostUsdPerRequest) {
log.warn("[VideoGuarded] per-request limit exceeded: estimated=${}, limit=${}",
estimatedCost, maxCostUsdPerRequest);
throw new VideoException(ErrorCode.VIDEO_QUOTA_EXCEEDED);
}
// ── 3단: ★ 일일 예산 가드 (daily budget) — 본 과제의 핵심 자리 ──
videoDailyBudgetGuard.checkAndAccumulate(estimatedCost);
// ── 4단: Stub submit ─────────────────────────────────────────────
VideoJob job = videoPollingClient.submit(request);
log.info("[VideoGuarded] submit success: jobId={}, tier={}, cost=${}, accum=${}",
job.jobId(), tier, estimatedCost, videoDailyBudgetGuard.currentAccumulatedUsd());
// ── 5단: ApiResponse 래핑 ────────────────────────────────────────
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(job));
}
private void validate(VideoGenerationRequest request) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
throw new VideoException(ErrorCode.VIDEO_PROMPT_REQUIRED);
}
if (request.durationSeconds() < MIN_DURATION || request.durationSeconds() > MAX_DURATION) {
throw new VideoException(ErrorCode.VIDEO_DURATION_INVALID);
}
}
}
💡 2 단 가드 의 흐름 —
maxCostUsdPerRequest는 각 요청 한 번 의 한도 ($1.00 디폴트),dailyBudgetUsd는 오늘 누적 의 한도 ($5.0 디폴트). 각 요청은 OK 였어도 누적이 한도 초과면 차단 되는 흐름의 두 흐름이 함께 살아있는 모습. 같은VIDEO_QUOTA_EXCEEDEDErrorCode 를 쓰되 로그 메시지 로 두 흐름을 구분.
검증 포인트 (3 케이스)
VideoDailyBudgetGuardTest 는 Day 7 ImageDailyQuotaGuardTest · Day 8 VisionDailyQuotaGuardTest · Day 9 VoiceDailyQuotaGuardTest 의 네 번째 회수 입니다.
케이스 구성은 동일하되 카운터(int) → 누적 비용(double) 자료형만 바뀝니다.
| 케이스 | 시나리오 | 기대 동작 |
|---|---|---|
| 1 | 0.30 을 3 번 누적 (총 $0.90 < $1.00) |
정상 통과, currentAccumulatedUsd() == 0.90 |
| 2 | $0.90 누적 상태에서 4 회째 0.30 호출 (총 $1.20 > $1.00) |
VIDEO_QUOTA_EXCEEDED 던짐, 누적값은 $0.90 그대로 (실패한 호출은 누적 X) |
| 3 | 전날 23:59 에 $0.90 누적 → 자정 넘김 → 당일 0.30 호출 |
누적값이 0 으로 리셋된 뒤 통과, currentAccumulatedUsd() == 0.30 |
세 케이스를 결정론적 으로 검증하려면 — Clock.fixed(...) 와 자정을 24 시간 진행시키는 테스트 더블 클럭 을 함께 써요. Thread.sleep(86400000) 로 24 시간 기다리는 사고 가 자정 경계 검증의 흔한 함정이에요.
학생 자기검증 명령:
./gradlew test --tests "*VideoDailyBudgetGuardTest*"
# → 3 케이스 그린 ✅
✅ 채점 포인트
| # | 포인트 | 배점 가중 | 설명 |
|---|---|---|---|
| 1 | VideoDailyBudgetGuard 신규 생성 — 두 생성자 (prod / 테스트용 패키지 가시성) |
상 | Day 7~9 패턴의 네 번째 회수 — Clock 주입 흐름 |
| 2 | synchronized 메서드 + LocalDate currentDate 비교로 자정 경계 리셋 |
상 | 동시성 + 자정 경계 — 두 흐름이 함께 살아있어야 진짜 가드 |
| 3 | 누적 비용을 double 로 다루되 예외 던지기 전 한도 검사 | 상 | 던지고 누적 이 아니라 검사 후 누적 — 차단된 호출은 누적 X |
| 4 | 새 컨트롤러 (VideoGenerationGuardedAsyncController) 또는 AOP — 기존 컨트롤러 0 줄 수정 |
상 | 중간 합류 학생 기준 코드 보호 — 기존 파일 한 줄이라도 수정 시 감점 |
| 5 | 새 컨트롤러도 본 강의 ApiResponse 표준 — ResponseEntity<ApiResponse<VideoJob>> |
상 | 응답 표준 일관성 — raw 객체 반환 시 감점 |
| 6 | 각 요청 가드 (per-request) 와 일일 가드 (daily budget) 의 2 단 가드 가 함께 살아있음 | 상 | 본 과제의 핵심 — 한 흐름만 새겨진 게 아니라 두 흐름의 데자뷰 |
| 7 | 새 ErrorCode 새기지 않음 — VIDEO_QUOTA_EXCEEDED 재사용 |
중 | 두 흐름을 로그 메시지 로 구분 (PR description 한 줄 새기기) |
| 8 | 테스트 — 한도 안 정상 통과 / 한도 초과 차단 / 자정 경계 리셋 3 케이스 모두 | 상 | 자정 경계 누락 시 감점 — Day 7~9 의 흐름 그대로 |
| 9 | 테스트 — Clock.fixed 또는 AdvanceableClock 활용 |
상 | Thread.sleep 으로 자정 기다리는 사고 방지 |
| 10 | PR description — 운영급 Redis 분산 가드 한 줄 메모 | 하 | 학습용 in-memory 의 결과 운영급 Redis 의 흐름 차이 인지 |
⚠️ 흔한 실수
- 누적값을 double 이 아니라 int 로 새김 → 비용은 $0.30 같은 소수라 누적이 0 으로 잘리는 사고. 반드시 double 로.
- 예외를 던진 후에도 누적값이 증가 →
accumulatedCostUsd += estimatedCostUsd가 예외 던지기 전 에 새겨진 사고. 검사 통과 후에만 누적 의 흐름. - 자정 경계 테스트에서
Thread.sleep(86400000)→ 24 시간 기다리는 사고. 반드시Clock.fixed또는 테스트 더블 로. - 새 ErrorCode (
VIDEO_DAILY_BUDGET_EXCEEDED) 를 새김 → 본 과제의 기존 ErrorCode 재사용 가이드 위반. 로그 메시지로 두 갈래 구분. - 기존
VideoGenerationAsyncController를 직접 수정 → 중간 합류 학생 기준 코드 보호 게이트 위반. 나란히 새 컨트롤러 의 흐름.
실무 개선 포인트 (심화)
-
운영급 Redis 분산 가드 —
INCRBYFLOAT video:budget:{userId}:{yyyyMMdd}+EXPIRE 86400으로 WAS N 대 다중화 환경에서도 누적값이 정확하게 공유 됩니다.본 과제의 in-memory 누적은 WAS 가 N 대면 누적값이 N 배 부풀려지는 사고를 내포해요. Day 14 / Day 19 의 Rate Limit · Cost Guardrail 에서 회수 됩니다.
-
AOP
@VideoBudgetGuarded어노테이션 흐름 —@Aspect @Around("@annotation(VideoBudgetGuarded)")로 풀면 컨트롤러 흐름에 가드를 끼워넣는 코드 자체가 사라지는 모습.다음과 같은 흐름 — 비즈니스 로직 옆에 어노테이션 한 줄, 가드는 Aspect 안에서 자동 발동. 학생 풀이로는 과제 분량을 넘어서는 자리 라 새 컨트롤러로 풀었지만 — Day 11~14 (Tool Calling / Agent) 가 끝난 시점에 AOP 흐름으로 마이그레이션 하면 코드가 한 층 더 깨끗해져요.
// 심화 흐름 — AOP 로 푼 풍경 (학습 OK 라고 PR description 에 메모만)
@VideoBudgetGuarded
@PostMapping("/generate-async-guarded")
public ResponseEntity<ApiResponse<VideoJob>> submit(...) {
// 가드 로직 0 줄 — Aspect 가 메서드 진입 직전 검사
...
}
과제 [구현 3] — Stub → 실제 Veo 3 (또는 Runway) 어댑터 갈아끼우기 ⚠️
🎯 핵심 접근
본 과제는 — VideoPollingClient 인터페이스의 진짜 가치 증명 입니다.
Stub 옆에 Veo 어댑터를 나란히 박는 — 프로바이더 추상화의 비디오 버전 회수.
Spring AI 1.1.x 시점엔 Veo 3 / Sora 의 표준 추상화 모델 (VideoModel 같은) 이 아직 없어서 — RestClient + 외부 API 직접 호출 로 풀어요.
Day 1 의 RestClient 감각이 그대로 회수됩니다. 단 — 지갑은 본인 책임 의 졸업 도전 — 단위 테스트는 모킹 100%, 통합 시연은 무료 크레딧 안에서 1~2 회만.
예시 구현
⚠️ Spring AI 1.1.x 시점의 흐름 — Veo 3 의 공식 REST API 는 Google AI Studio 시절 프리뷰 단계 라 정식 시그니처가 자주 바뀌어요. 본 답안은 2026-04 기준 Vertex AI Predictions API 의 비동기 LRO (Long-Running Operation) 패턴 을 단순화한 흐름으로 새겼어요. 실제 호출 가능한 코드는 본인 키 + 본인 시점에서 한 번 더 검증 이 정답.
2026-05 시점 보강 — 본 답안 작성 후 Veo 3.1 (Fast $0.15/초, Standard $0.35~0.40/초, Lite $0.05/초) 라인이 2025-10 부 풀렸고, Sora 는 2026-04-26 부 앱 / sora.com 종료 (API 는 2026-09-24 종료 예정). 본 답안의 어댑터 코드 골격 은 그대로 살아남고, baseUrl / endpoint path / 응답 필드명 만 졸업 시점 에 Veo 3.1 의 공식 문서로 한 번 더 갈아끼우면 됩니다. 프로바이더 추상화 덕에 이 갱신이 한 곳에서만 일어나요.
1) VeoVideoPollingClient — VideoPollingClient 구현
package kr.spartaclub.aifriends.video.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.video.dto.VideoGenerationRequest;
import kr.spartaclub.aifriends.video.dto.VideoJob;
import kr.spartaclub.aifriends.video.dto.VideoJobStatus;
import kr.spartaclub.aifriends.video.exception.VideoException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Map;
/**
* Day 10 [과제 3] — Google Veo 3 어댑터 (졸업 자리, 본인 카드 본인 판단).
*
* <p>{@link VideoPollingClient} 인터페이스 그대로 구현 — *프로바이더 추상화의 흐름* 위에서
* {@link StubVideoPollingClient} 옆에 *나란히 박힌다.*</p>
*
* <p>{@code aifriends.video.provider=veo} 일 때만 활성화. 디폴트는 {@code stub} (matchIfMissing=false).</p>
*
* <p><b>본인 카드 본인 판단</b>: Veo 3 5초 1080p ≈ $10 — 무료 크레딧 안에서만 시도하는 흐름을 강력 권장.
* 단위 테스트는 {@link RestClient} 응답을 *전부 모킹* — 실제 청구 발생 0 건이 정답.</p>
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "aifriends.video.provider", havingValue = "veo")
public class VeoVideoPollingClient implements VideoPollingClient {
private final RestClient restClient;
private final String apiKey;
private final ObjectMapper objectMapper;
public VeoVideoPollingClient(
RestClient.Builder restClientBuilder,
@Value("${GOOGLE_API_KEY:}") String apiKey,
ObjectMapper objectMapper) {
// 베이스 URL 은 시점에 따라 바뀔 수 있으므로 application.yml 에서 외부화 권장
this.restClient = restClientBuilder
.baseUrl("https://generativelanguage.googleapis.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
this.apiKey = apiKey;
this.objectMapper = objectMapper;
}
@Override
public VideoJob submit(VideoGenerationRequest request) {
if (apiKey == null || apiKey.isBlank()) {
throw new VideoException(ErrorCode.AI_SERVICE_ERROR);
}
try {
// ── Veo predict 엔드포인트 (단순화한 흐름 — 실제 시그니처는 시점 검증 필수) ──
Map<String, Object> body = Map.of(
"prompt", request.prompt(),
"duration", request.durationSeconds(),
"resolution", request.resolution()
);
JsonNode response = restClient.post()
.uri("/v1/models/veo-3:predictLongRunning?key={key}", apiKey)
.body(body)
.retrieve()
.body(JsonNode.class);
// LRO (Long-Running Operation) 의 name 필드를 jobId 로 사용
String jobId = response.path("name").asText();
if (jobId.isBlank()) {
throw new VideoException(ErrorCode.VIDEO_GENERATION_FAILED);
}
log.info("[VeoAdapter] submit success: jobId={}", jobId);
return VideoJob.queued(jobId);
} catch (VideoException e) {
throw e;
} catch (Exception e) {
log.error("[VeoAdapter] submit failed", e);
throw new VideoException(ErrorCode.VIDEO_GENERATION_FAILED);
}
}
@Override
public VideoJob pollStatus(String jobId) {
try {
JsonNode response = restClient.get()
.uri("/v1/{jobName}?key={key}", jobId, apiKey)
.retrieve()
.body(JsonNode.class);
boolean done = response.path("done").asBoolean(false);
if (!done) {
// Veo 의 done=false 는 RUNNING 으로 매핑 — QUEUED 와 RUNNING 구분이 모호하므로 보수적으로
return new VideoJob(jobId, VideoJobStatus.RUNNING, null, null);
}
// 에러 응답
if (response.has("error")) {
String errorMessage = response.path("error").path("message").asText("unknown error");
log.warn("[VeoAdapter] job failed: jobId={}, message={}", jobId, errorMessage);
return new VideoJob(jobId, VideoJobStatus.FAILED, null, errorMessage);
}
// 성공 응답 — videoUrl 추출 (응답 스키마는 시점 검증 필수)
String videoUrl = response.path("response").path("videoUri").asText();
return new VideoJob(jobId, VideoJobStatus.SUCCEEDED, videoUrl, null);
} catch (Exception e) {
log.error("[VeoAdapter] pollStatus failed: jobId={}", jobId, e);
throw new VideoException(ErrorCode.VIDEO_GENERATION_FAILED);
}
}
}
2) application.yml / .env — 키 + 프로바이더 스위치
# application.yml
aifriends:
video:
provider: ${AIFRIENDS_VIDEO_PROVIDER:stub} # stub | veo | runway
# .env (개인 머신 — 절대 git 에 커밋 금지)
AIFRIENDS_VIDEO_PROVIDER=veo
GOOGLE_API_KEY=<본인 무료 크레딧 키>
💡 API 키 보안 원칙 —
GOOGLE_API_KEY는 반드시 환경변수 로. 코드에 하드코딩 시AIza...가 깃 히스토리에 남는 사고..env는.gitignore에 들어와 있어야 해요.
3) Stub 의 활성 조건도 함께 새기기 — 충돌 방지
// StubVideoPollingClient — 코드 수정 금지 게이트 때문에 *기존 그대로 두는 게 정답*
// 단 — application.yml 의 provider=veo 일 때 stub 도 함께 빈 등록되면 충돌
// 해결: Spring 의 @Primary 가 아닌 — provider 프로퍼티 분기로 빈 등록 자체를 분리
해결의 두 갈래 — 본 과제 가이드 (Stub 코드 수정 금지) 를 어기지 않으면서 충돌을 피하는 두 결:
-
(A) 새 Configuration 클래스에서
@ConditionalOnProperty로 Stub 빈 재등록 흐름 — 기존@Component가 새겨진 Stub 클래스에 새 어노테이션을 새기지 않고 — 별도@Configuration클래스에서 override 빈 을 새기는 흐름.단 Spring 의
BeanDefinitionOverrideException회피 결정이 한 단 새겨져야 함. -
(B) Veo 빈에만
@Primary새기기 흐름 — 두 빈이 모두 등록된 상태에서provider=veo일 때만@Primary가 들어와 우선 주입되는 흐름. 코드 양은 적지만 Stub 빈도 메모리에 살아있는 흐름의 가벼운 비용.
// (B) 흐름의 권장 — Veo 빈에 @Primary 새기기
@Slf4j
@Component
@Primary // ★
@ConditionalOnProperty(name = "aifriends.video.provider", havingValue = "veo")
public class VeoVideoPollingClient implements VideoPollingClient {
...
}
💡 본 답안은 (B) 흐름을 권장 — 코드 양이 적고 기존 Stub 코드 0 줄 수정 의 게이트를 그대로 지켜요. 단 PR description 에 "Stub 빈도 메모리에 등록되지만 주입 시 Veo 가 우선" 의 흐름 한 줄 새기기.
검증 포인트 (필수 — 모킹 100%, 실제 외부 호출 0 건)
VeoVideoPollingClientTest 는 내장 HTTP 서버 (MockWebServer 또는 WireMock) 를 띄워 RestClient 의 호출을 진짜 HTTP 로 가로채는 식으로 작성합니다.
RestClient.baseUrl() 을 mockServer 의 동적 포트 로 향하게 해서 — 서버 측 응답을 줄 단위로 컨트롤 하면 키 없이도 그린이 됩니다.
// build.gradle — 테스트 의존성 한 줄
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
검증해야 할 6 가지 응답 매핑 약속은 한 표로:
| # | API 응답 | 기대 VideoJob 매핑 |
검증 포인트 |
|---|---|---|---|
| 1 | {"name":"operations/abc-123"} (submit) |
jobId == "operations/abc-123", status == QUEUED |
predict 응답에서 jobId 추출 |
| 2 | {"done":false} (poll) |
status == RUNNING, videoUrl == null |
done=false → RUNNING |
| 3 | {"done":true,"response":{"videoUri":"..."}} |
status == SUCCEEDED, videoUrl 매핑 |
성공 응답 → URL 매핑 |
| 4 | {"done":true,"error":{"message":"quota exceeded"}} |
status == FAILED, errorMessage 매핑 |
실패 응답 → errorMessage 매핑 |
| 5 | submit 호출, 키가 빈 문자열 | VideoException 던짐 |
API 키 가드 |
| 6 | 500 응답 | VideoException (VIDEO_GENERATION_FAILED) |
5xx 변환 흐름 |
baseUrl 외부화 한 줄 — 본 답안의 VeoVideoPollingClient 는 학습용으로 baseUrl 이 하드코딩되어 있는데, 운영급에선 @Value("${aifriends.video.veo.base-url:...}") 으로 외부화해서 테스트에서 mockServer.url() 로 갈아끼우는 흐름이 정석이에요.
PR description 한 줄 메모로 "실제 코드 적용 시 baseUrl 외부화 필요" 만 새겨두면 충분.
학생 자기검증 명령:
# 단위 테스트 — 키 없이도 그린이 정답
./gradlew test --tests "*VeoVideoPollingClientTest*"
# → 6 케이스 그린 ✅ (실제 외부 호출 0 건)
✅ 채점 포인트
| # | 포인트 | 배점 가중 | 설명 |
|---|---|---|---|
| 1 | VideoPollingClient 인터페이스 그대로 구현 — 시그니처 동일 |
상 | 프로바이더 추상화의 핵심 — 인터페이스 변경 시 감점 |
| 2 | RestClient 사용 — RestTemplate 금지 |
상 | grep 검수 기준 — RestTemplate 한 줄이라도 들어와 있으면 fail |
| 3 | @ConditionalOnProperty(name = "aifriends.video.provider", havingValue = "veo") |
상 | Day 2 / Day 9 의 흐름 회수 — 빈 스위칭 패턴 |
| 4 | StubVideoPollingClient 와의 충돌 회피 — @Primary 또는 별도 Configuration |
상 | 본 과제의 기존 Stub 코드 수정 금지 게이트와 양립 |
| 5 | API 키 환경변수 로 — @Value("${GOOGLE_API_KEY:}") 패턴 |
상 | 본 강의 API 키 보안 원칙 — 코드 하드코딩 시 fail |
| 6 | 단위 테스트 — MockRestServiceServer 또는 동등한 모킹, 실제 외부 호출 0 건 |
상 | 키 없이도 그린 이 정답 |
| 7 | 단위 테스트 — submit / pollStatus 의 4 개 응답 상태 (queued/running/succeeded/failed) 매핑 | 상 | 응답 매핑 누락 시 운영 에러 — Day 4 BeanOutputConverter 흐름의 회수 |
| 8 | 외부 응답 → VideoJob 매핑 — JsonNode 또는 record 매핑 |
중 | 응답 스키마는 시점에 따라 바뀜 — JsonNode 의 흐름이 더 안전 |
| 9 | application.yml 의 aifriends.video.provider 프로퍼티 외부화 |
중 | .env 한 줄 수정으로 스위칭되는 흐름 |
| 10 | PR description — "통합 시연은 무료 크레딧 안에서 1~2 회만" 명시 | 중 | 본인 카드 본인 판단의 흐름 — 졸업 자리의 정신 |
⚠️ 흔한 실수
RestTemplate으로 어댑터 작성 → grep 검수 기준 위반.RestClient만 의 흐름.- API 키를 코드 하드코딩 또는 application.yml 평문 → 깃 히스토리에 키 누출 사고. 반드시
.env+ 환경변수 의 흐름. - 단위 테스트가 실제 Google API 를 호출 함 → 본 과제의 모킹 100% 게이트 위반. 키 없이도 그린 이 정답.
StubVideoPollingClient를 직접 수정해서@ConditionalOnProperty새김 → 기존 Stub 코드 수정 금지 게이트 위반.@Primary또는 별도 Configuration 의 흐름.- 응답 매핑에서
done/error/response의 세 분기 를 누락 → done=true 인데 error 도 없고 response 도 없는 엣지 케이스 에서 NPE. 세 분기 명시적 처리 의 흐름. - 통합 시연을 유료 카드 로 무턱대고 돌림 → 본 과제의 본인 카드 본인 판단 정신 위반. 무료 크레딧 안에서만 의 흐름.
실무 개선 포인트 (심화)
-
재시도 + Circuit Breaker — Veo 의 LRO 응답이 분 단위 폴링 이라, 네트워크 끊김 / 5xx 응답 이 발생했을 때 Resilience4j Retry + CircuitBreaker 로 외부 API 의 출렁임 을 흡수하는 게 운영급의 정석.
본 답안은 학습용으로 try/catch + ErrorCode 변환만 박았어요. Day 19 (Harness 엔지니어링) 에서 회수 됩니다.
-
응답 스키마 변경 대응의 흐름 — Veo 3 는 프리뷰 단계 라 응답 필드명이 시점에 따라 바뀜 (예:
videoUri→videoUrl→output.uri같은 출렁임).JsonNode의 흐름 (path-based 추출) 이 record 매핑보다 안전 한 이유. 운영급에선 응답 스키마 변경 알림 (Sentry / API status page 구독) 까지 새기는 흐름이 정석.
Webhook 보강 (생각해볼 주제 2 와 연결) — 본 답안의 폴링은 클라이언트가 GET /status 를 분 단위로 두드리는 방식.
운영급에선 Veo 의 콜백 URL 을 박아 서버 → 서버 푸시 로 가면 트래픽 0 + 즉시성 이 살아있어요.
단 콜백 URL 보안 (HMAC 서명 검증) 이 한 단 더 박혀야 합니다.
💭 생각해볼 주제 [주제 1] — 모델 선택: ai-friends 한국 시장 진입의 첫 정답
[문제 상황 요약]
오늘 익힌 비디오 6 종 라인업 (SVD · Kling · Luma · Runway · Veo 3 · Sora) 중 — 한국 시장 ai-friends 의 첫 정답 은 어느 모델일까요? 비용은 무한대 ~ ×17 차이, 품질은 주관적, 지연은 분 단위 차이, 오프라인 가능 여부 도 달라요.
그리고 6 개월 후 모델 라인업이 출렁일 때 — 우리 코드의 어디까지가 살아남고 어디부터가 새로 짜져야 하는가 까지 함께 챙기는 질문이에요.
시의성 한 줄 — 본 답안 작성 후 한 달도 안 되어 라인업이 실제로 출렁였어요. Sora 앱 / sora.com 은 2026-04-26 부 종료 (API 는 2026-09-24 종료 예정), Sora 2 (2025-09 출시) 가 후속이고, Veo 3.1 Fast / Lite 가 2025-10 부 추가됐어요. 즉 본 답안의 5 축 트레이드오프 + 인터페이스 추상화 결정 은 그대로 살아 있고, 어느 모델 이름·단가가 등장할지 만 시점에 따라 달라집니다. 추상화의 가치가 6 개월이 아닌 한 달 단위로 증명되는 풍경입니다.
[튜터의 가이드 및 해설]
이 문제는 5 축 트레이드오프 (비용 / 품질 / 지연 / 한국어 이해도 / 현지화) 중 우리 서비스의 1 순위 를 잡는 질문이에요. 그리고 모델 라인업의 출렁임 에 대비한 추상화 구조 까지 함께 묻고 있어요.
Option A — Stable Video Diffusion 로컬 + 점진적 업그레이드
- 장점: 비용 $0, 오프라인 가능, 학습용 + 초기 베타 단계 의 정답. 데모 / 내부 시연 / 무료 티어 사용자 라면 충분.
- 단점: GPU 인프라 비용 (RTX 4090 한 장 ≈ ₩3,000,000 + 전기), 모델 풀 시간 (5초 클립 ≈ 30~60 초), 품질 한계 (Sora 대비 명확), 한국식 캐릭터 표현의 학습 데이터 빈약.
- 현실: MAU 1,000 명 미만 + 본 서비스가 이성친구 채팅 의 부가 기능인 단계에선 오버스펙 회피 차원에서 충분히 합리적.
Option B — Kling (가성비 라인) 으로 시작
- 장점: $0.06/sec ≈ Sora 의 1/17, 한국어 프롬프트 이해도 (중국 모델 → 동아시아 결) 가 Veo/Sora 보다 한 단 더 잘 들어와 있어요. 5 초 720p ≈ $0.60, MAU 1 만 명 × 일 1 클립 = $6,000/월 — 손익분기 안에 들어옵니다.
- 단점: 영상 업계 디폴트 (Runway) 가 아니므로 외부 협력사 / 마케팅 자료에선 결과물 호환성 이 한 단 떨어질 수 있음. 최고 품질 은 Sora/Veo 에 비해 명확히 한 단 아래.
- 현실: 2026 년 한국 시장의 가성비 라인 진입로로 정답에 가까움 — Kling/Hailuo/Pika 라인이 동아시아 모델 라인업이라 한국어 결의 감각이 더 자연스러워요.
Option C — Veo 3 (Gemini 패밀리) 으로 일관성 우선
- 장점: ai-friends 가 Gemini 텍스트 모델 을 이미 쓰고 있다면 — 같은 패밀리 안에서 비디오까지 일관 됩니다. 프롬프트의 한국어 이해도가 Gemini 와 동일한 베이스. 사운드 포함 비디오 도 가능.
- 단점: $0.50/sec ≈ Kling 의 ×8. MAU 1 만 명 × 일 1 클립 = $50,000/월 — 손익분기 상단입니다.
- 현실: 프리미엄 티어 / 인플루언서 모드 로만 박는 게 합리적. 전체 사용자에게 Veo 는 수강료 본전 회수가 아닌 지갑 폭주의 영역.
Option D — 하이브리드 (캐주얼 = Kling, 프리미엄 = Veo, 데모 = SVD)
- 장점: 모달리티 + 가격대 + 사용자 티어 의 3 축 으로 분리.
VideoPollingClient인터페이스 +@ConditionalOnProperty가 진짜 가치를 발휘합니다. - 단점: 세 개의 빈을 함께 다루는 운영 부담, 티어별 스위칭 로직 (UserTier → ModelTier 결정 트리) 의 코드 복잡도.
- 현실: MAU 1 만 명 + 유료 전환율 5% 이상 단계의 정답. 졸업 후 진짜 현업에서 도전해볼 그림.
현업에서는 보통 — 2026 년 한국 시장 진입 단계 에선 Option B (Kling 으로 시작) → Option D (성장 후 하이브리드) 의 진화가 자연스러워요. 비용 손익분기 + 한국어 처리 + 영상 업계 호환성 의 5 축 중 비용/한국어 둘이 진입 단계의 1 순위 이기 때문이에요.
그리고 6 개월 후 모델 라인업 출렁임 대응 — 우리 코드에서 살아남는 뼈대 는 VideoPollingClient 인터페이스 + VideoModelTier enum + @ConditionalOnProperty 결합이에요.
어댑터만 갈아끼우면 새 모델이 들어옵니다. 단 — enum 항목은 끝에 추가 (ordinal 사고 회피), 프롬프트의 한국어 결은 모델별로 다르게 튜닝 (Day 3 PromptTemplate 회수) 의 두 가지 는 모델이 갈릴 때마다 새로 짜야 합니다.
🎯 면접관을 홀리는 핵심 멘트
"비디오 모델 선택은 5 축 트레이드오프 (비용 / 품질 / 지연 / 한국어 / 현지화) 중 우리 서비스의 단계별 1 순위가 무엇인가 의 결정입니다. MAU 1 만 명 미만 진입 단계 에선 비용 + 한국어 이해도 가 1 순위라 Kling 같은 동아시아 가성비 라인 이 정답에 가까워요. Sora/Veo 같은 프리미엄 라인은 손익분기 안에 들어오지 않으면 지갑 폭주의 영역 입니다. 그리고 6 개월 후 모델 라인업 출렁임 에 대비해 —
VideoPollingClient인터페이스 +@ConditionalOnProperty의 프로바이더 추상화 위에서 어댑터만 갈아끼우는 구조를 디폴트로 박았습니다. 그래야 모델이 갈려도 비즈니스 로직은 살아남습니다."
💭 생각해볼 주제 [주제 2] — 비동기 폴링의 결정: 클라이언트 폴링 vs 서버 푸시 (SSE / Webhook)
[문제 상황 요약]
본 강의는 클라이언트가 GET /status 로 주기적 폴링 하는 방식으로 짰어요.
간단하고 / 단방향이고 / 클라이언트가 주도권을 갖는 디폴트.
그런데 운영급에선 Webhook 또는 SSE 로 서버가 알려주는 방식이 더 자연스러운 상황도 있어요.
MAU 1 만 명 트래픽 에 들어갔을 때 — 세 가지 (클라이언트 폴링 / Webhook / SSE) 중 정답은 어디일까요?
[튜터의 가이드 및 해설]
이 문제는 트래픽 / 즉시성 / 보안 / 운영 부담 4 축 트레이드오프 입니다. 세 방식의 결정 트리가 각각 달라요.
Option A — 클라이언트 폴링 (본 강의의 디폴트)
- 장점: 구현 단순 (서버 → 클라이언트 푸시 인프라 0 줄), 방화벽 친화 (NAT/방화벽 안의 클라이언트도 OK), 클라이언트가 주도권 (모바일 앱이 백그라운드 → 포그라운드 전환 시 폴링 재개).
- 단점: 트래픽 낭비 (60 초마다 GET 한 번씩이면 분 단위 미완료 상황에 불필요한 호출이 누적), 즉시성 한 단 떨어짐 (폴링 주기만큼의 평균 지연), 서버 자원 (DB/캐시 조회) 의 폴링 부하.
- 현실: MAU 1 만 명 미만 + 비디오 생성이 부가 기능 인 단계에선 충분히 정답. 학습용으로도 정답.
Option B — Webhook (서버 → 서버 푸시)
- 장점: 트래픽 0 + 즉시성 만점, 외부 API (Veo / Runway) 가 우리 서버 콜백 URL 을 호출 → 우리 서버가 완성된 시점에 받아서 그제서야 SSE 로 클라이언트에 흘려보내는 형태.
- 단점: 콜백 URL 을 외부에 노출 (HMAC 서명 검증 / IP 화이트리스트 / Replay 공격 방어), 방화벽 / 로드밸런서 / 인증서 관리 의 운영 부담, 외부 API 가 콜백을 안 부르거나 늦게 부르는 경우 의 재시도 로직 까지.
- 현실: 운영급의 정답 — Stripe / GitHub Actions / OpenAI Batch API 가 모두 이 방식. MAU 1 만 명 + 비동기 응답이 핵심 기능 인 단계의 디폴트.
Option C — SSE (Day 6 의 패턴 회수)
- 장점: 클라이언트가 한 번 연결해두면 / 서버가 진행률을 흘려주는 라이브 모습. Day 6 의 토큰 스트리밍 패턴 이 비디오 진행률 에도 그대로 적용될 수 있어요. 진행률 (0% → 30% → 60% → 100%) 의 표시 가 UX 의 핵심 감각.
- 단점: 서버 자원 (한 클라이언트당 한 connection) 이 듭니다. MAU 1 만 명 × 동시 연결 100% 면 connection 풀 고갈 사고. 모바일 앱의 백그라운드 전환 시 connection 끊김 도 한 단 더 처리해야 함.
- 현실: Webhook 으로 서버가 받고 → SSE 로 클라이언트에 흘리는 Webhook + SSE 조합 이 운영급에서 가장 흔한 패턴.
현업에서는 보통 — 진화 단계의 순서 가 있어요.
| 단계 | 방식 | 이유 |
|---|---|---|
| MVP / 학습용 | 클라이언트 폴링 | 운영 부담 0, 디버깅 쉬움. 왜 비동기가 필요한가 의 왜 가 와닿는 단계 |
| 베타 / 초기 운영 (MAU < 1 만) | 클라이언트 폴링 + 폴링 주기 동적 조정 | 진행률에 따라 폴링 주기 (5초 → 10초 → 30초) 늘리기 |
| 운영 / 성장 (MAU > 1 만) | Webhook (서버 ↔ 외부 API) + SSE (서버 ↔ 클라이언트) | 트래픽 절감 + 즉시성 + 진행률 라이브 |
| 대규모 / 모바일 중심 | Webhook + Push Notification (FCM/APNS) | 모바일 백그라운드에서도 즉시 알림 |
그리고 본 강의의 단순 폴링도 학습용으론 충분합니다 — 왜 비동기 폴링이 필요한가 의 왜 가 와닿은 다음에야 어떻게 더 정교하게 짤지 의 결정이 자연스러워져요.
단순한 방식의 가치를 절대 폄하하지 마세요. 본 강의가 MAU 1 억 명용 운영급 코드 를 가르치지 않는 이유 — 추상화의 결을 따라가려면 단순한 감각이 먼저 박혀야 하기 때문이에요.
🎯 면접관을 홀리는 핵심 멘트
"비동기 응답 방식의 선택은 진화 단계 + 트래픽 / 즉시성 / 보안 / 운영 부담 4 축 의 결정입니다.
MVP 단계 에선 클라이언트 폴링이 정답 입니다 — 운영 부담 0, 디버깅 쉬움, 왜 비동기가 필요한가 의 감각이 박히는 단계.
MAU 1 만 명 운영급 에선 Webhook (서버 ↔ 외부 API) + SSE (서버 ↔ 클라이언트) 조합이 디폴트입니다 — Stripe / GitHub Actions / OpenAI Batch API 가 모두 이 패턴.
단 Webhook 의 콜백 URL 보안 (HMAC 서명 / IP 화이트리스트 / Replay 방어) 이 한 단 더 박혀야 합니다. 그리고 — 학습 단계의 단순 폴링을 절대 폄하하지 않습니다. 추상화의 결을 따라가려면 왜 가 먼저 박혀야 하기 때문입니다."
💭 생각해볼 주제 [주제 3] — AI 생성 비디오의 법적·윤리적 과제: 워터마킹 · 딥페이크 · 저작권
[문제 상황 요약]
2026 년은 EU AI Act 의 워터마킹 의무가 Article 50 에서 2026-08-02 부 full applicable 로 다가오는 시대이고, 한국 AI 기본법 도 2026-01-22 시행 한 시대예요.
두 법이 AI 생성 비디오에 부과한 결정적인 의무 한 가지 — 생성된 콘텐츠의 출처 표시 (워터마킹). 그리고 딥페이크 (실존 인물 합성) 와 저작권 (학습 데이터 출처) 까지 — 우리 서비스의 어디에 박혀야 할까요?
[튜터의 가이드 및 해설]
이 문제는 법적 의무 + 윤리적 책임 + 코드 위치의 매핑 입니다. 셋 중 어느 게 1 순위인가 의 결정 트리가 들어 있어요.
Option A — 워터마킹 (법적 의무 — 1 순위)
- 법적 근거: EU AI Act Article 50 — "AI 가 생성한 합성 콘텐츠는 기계 판독 가능한 워터마크 를 새겨야 한다". 한국 AI 기본법 도 생성형 AI 콘텐츠의 표시 의무 포함. 위반 시 EU 매출의 최대 7% 과징금.
- 코드 위치: 비디오 생성 후 → 후처리 단계에 C2PA (Content Authenticity Initiative) 메타데이터 새기기.
VideoPollingClient.pollStatus(...)가 SUCCEEDED 시점에 videoUrl 의 영상에 C2PA 메타데이터를 주입 하는 후처리 빈을 박는 식. - 우선순위: 1 순위 — 법적 의무. 위반 시 형사 처벌 + 과징금 이라 학습용이 아니라 운영용 의 디폴트.
Option B — 딥페이크 차단 (윤리 + 형법 — 2 순위)
- 법적 근거: 허락 없는 실존 인물 합성 은 인격권 침해 / 명예훼손 / 형사 처벌 로 이어집니다. 한국 형법 상 음란물 합성 시 별건 가중 처벌.
- 코드 위치: 두 곳에 동시에 박는 게 정석 — 입력 단 (프롬프트 검열) + 출력 단 (응답 분류기).
- 프롬프트 검열 —
"닮은", "처럼 생긴", "스타일", 연예인 이름 사전같은 키워드 사전 + LLM 분류기로 입력에서 차단. Day 11 (Tool Calling) 시점부터 Spring AI Moderation API 로도 가능. - 응답 분류기 — 생성된 비디오에 대해 얼굴 인식 + 유명인 데이터베이스 매칭 으로 출력 차단. 단 — 지연이 분 단위 더 늘어납니다.
- 우선순위: 2 순위 — 윤리 + 형법. 서비스 신뢰도 + 사용자 피해 방지.
Option C — 저작권 라이선스 (운영 책임 — 3 순위)
- 법적 근거: Sora / Veo 의 학습 데이터 저작권 은 2026 년의 미해결 이슈. 미국에서 NYT vs OpenAI 같은 소송 진행 중. 생성된 비디오에 학습 데이터의 색채/구도가 너무 닮은 경우 가 잡히면 저작권 침해 결정 가능성.
- 코드 위치: 모델 사용 약관의 상업적 라이선스 명시 확인 + 생성된 비디오 사용 시 모델 출처 표기 의무 (
Powered by Veo 3같은). - 우선순위: 3 순위 — 운영 책임. 법적 의무는 모델 제공자 측이 더 크기에 우리 서비스의 코드 부담은 상대적으로 가벼움.
현업에서는 보통 — 셋 모두 박는 게 정답 이지만, 진화 단계 + 코드 위치 + 운영 부담 으로 다음 우선순위가 자연스러워요.
| 우선순위 | 항목 | 코드 위치 | 운영 단계 |
|---|---|---|---|
| 1 | 워터마킹 (C2PA 메타데이터) | pollStatus SUCCEEDED 후처리 |
런칭 직전부터 — 법적 의무 |
| 2 | 딥페이크 차단 (입력 + 출력) | 프롬프트 검열 + 응답 분류기 | 베타 진입 단계부터 — 사용자 피해 방지 |
| 3 | 저작권 (라이선스 표기) | UI 의 Powered by + 약관 페이지 | 런칭 단계에서 — 운영 책임 |
그리고 — 본 강의 코드 (VideoGenerationAsyncController) 는 어디까지 비어 있을까요? 프롬프트 검열도 / 워터마크 새기기도 / 라이선스 점검도 안 들어 있어요.
학습용으론 OK — 그러나 졸업 후 진짜 현업에선 이 셋을 내 손으로 박아야 합니다.
본 강의는 기술의 감각 을 가르치고, 운영의 책임 은 학생이 졸업 후 짊어집니다.
그리고 Day 8 의 Vision SSRF 주제와 자연스럽게 연결돼요 — URL 기반 Vision SSRF 차단 의 패턴이 비디오 도메인 에서도 입력 검열 (프롬프트) + 출력 검증 (생성된 비디오) 의 두 단 으로 그대로 살아있어요. 다른 모달리티, 같은 패턴의 상속.
🎯 면접관을 홀리는 핵심 멘트
"AI 생성 비디오의 법적·윤리적 과제는 3 가지 입니다. 1 순위는 워터마킹 (EU AI Act Article 50 + 한국 AI 기본법) — 위반 시 EU 매출의 최대 7% 과징금 이라 런칭 직전부터 C2PA 메타데이터를 박는 게 디폴트입니다. 2 순위는 딥페이크 차단 — 입력 단 (프롬프트 검열) + 출력 단 (응답 분류기) 의 두 곳에 동시에 박는 게 정석. 3 순위는 저작권 라이선스 — UI 의 Powered by 표기 + 약관 페이지. 그리고 — 본 강의 코드는 이 세 가지가 모두 비어 있습니다 — 학습 단계에선 OK 지만 졸업 후 진짜 현업에선 내 손으로 박아야 합니다. 기술의 감각은 강의가 가르치고, 운영의 책임은 학생이 박는다 — 이 구분이 핵심입니다."
🌸 마무리 — Day 10 답안 정신
세 과제 + 세 주제를 손으로 한 번 짜본 사람 만이 본 강의의 끝 — 비디오 6 종 라인업 / 비동기 폴링 / 비용 가드 / 시연 중심 정책 을 내 감각 으로 가져갑니다.
세 줄 정신 회수
- enum 한 줄 추가가 식 한 줄에 의존하는 패턴을 증명 한다 — switch expression 의 enum 항목 강제 매핑 이 왜 강한가 의 감각.
- Day 7~9 가드 패턴의 네 번째 회수 — 카운터 (int) → 누적 비용 (double) 만 한 단 바뀌는 데자뷰.
VideoPollingClient인터페이스의 진짜 가치 증명 — Stub 옆에 Veo 어댑터를 나란히 박는 졸업 도전. 지갑은 본인 책임.
Day 11 로 흘려둔 복선 — 본 답안의 AOP 흐름 (@VideoBudgetGuarded), MoveToWebhook, Spring AI Tool Calling 의 응답 검열 — 이 셋이 Day 11 ~ Day 14 (Tool Calling / Agent / Agentic Patterns) 시점에 추상화 한 단 위 에서 회수됩니다.
다음 시간은 — Day 11 Tool Calling 에서 만나요. 우리 앱 내부의 @Tool 함수 등록 의 흐름으로, 본 답안에서 손에 새긴 프로바이더 추상화 + 가드 + 어댑터 의 흐름이 함수 레벨 에서 다시 새겨집니다. 🌸