Day 9. Voice (STT/TTS) — "캐릭터가 처음으로 목소리 를 갖는, 듣고 말하는 모달리티"
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
Day 8, 정말 단단하게 마무리하셨어요. 지난 시간 우리는 캐릭터에게 눈을 달아주는 진행 을 익히셨어요. ChatModel 의 멀티모달 입구
Media / MediaContent / UserMessage.builder() 세 단어, 그 직전 시간(Day 7) 만든 portrait URL 이 그대로 입력으로 흘러가는 클로저, MultipartFile 업로드 → 정적 URL 의 두 번째 모드, VisionDailyQuotaGuard + Clock 주입까지 —
생성 → 인식 → 대화 의 7 가지을 손에 정리해두고 마무리했죠.
그리고 지난 시간 마무리에서 제가 클로저 한 줄 을 정리해두고 도망갔어요.
"오늘 눈 을 달았으면, 다음 시간엔 귀와 입 을 달 차례. 캐릭터가 사용자의 목소리를 듣고 자기 목소리로 답하는 부분 — 대화의 모든 입출력이 음성으로 흐르는. Day 9 에서 만나요."
오늘이 그 약속의 반대쪽 모달리티 (청각) 를 펼치는 날이에요.
지난 시간까지의 ai-friends 를 한 번 더 떠올려 봅시다.
캐릭터는 그림을 그릴 줄도 알고 (Day 7), 그림을 보고 답할 줄도 알아요 (Day 8). 시각이라는 모달리티는 입력 · 출력 양방향 모두 들어왔죠.
그런데 — 대화 라는 결에서 가장 자연스러운 모달리티가 하나 빠져 있어요.
사람과 사람이 친구처럼 이야기하는 그림엔 목소리 가 있어야 하잖아요.
오늘은 그 마지막 한 조각 을 정리하는 날이에요. 사용자가 마이크에 대고 말하면 — 캐릭터가 그 음성을 듣고 (STT), 답을 생각하고 (ChatClient), 자기 목소리로 (TTS) 다시 답해주는 그림. 캐릭터가 처음으로 목소리를 갖는 곳이에요.
💡 오늘 수업의 핵심 "TranscriptionModel / TextToSpeechModel 자매 추상화 — 지난 시간까지 익힌 추상화가 세 번째 모달리티 에서 다시 등장된다. 마이크 → STT → ChatModel → TTS → 스피커, 5 단 파이프라인이 한 부분에서 닫힌다."
오늘 수업은 한 문장으로 요약돼요.
"Spring AI 는 음성 도메인에도 자매 추상화 를 똑같이 정리해뒀다.
ChatModel옆에TranscriptionModel(STT) 과TextToSpeechModel(TTS) 두 빈이 나란히 놓이고, 프로바이더는.env한 줄로 스위칭 된다. 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인이 — 세 추상화가 차례로 손을 잡는 모습이다."
여기서 다시 자매 추상화의 모양 이 등장해요.
Day 2 의 프로바이더 추상화 (ChatModel) 가 첫 재등장, Day 7 의 모달리티 자매 (ImageModel) 가 두 번째 등장, 오늘이 세 번째 등장 예요.
같은 모양의 추상화가 다른 모달리티에 한 번 더, 한 번 더 라는 그림.
손이 한 번에 들어옵니다.
-
TranscriptionModel— 음성 → 텍스트 의 자매 추상화.chatModel.call(prompt)와 같은 방식으로transcriptionModel.call(audioPrompt)한 줄. 입력은Resource(오디오 바이트), 출력은 텍스트. -
TextToSpeechModel— 텍스트 → 음성 의 자매 추상화.speechModel.call(speechPrompt)한 줄로 byte[] 음성 데이터 가 떨어져요. 그대로 브라우저에 흘려보내면<audio>태그가 받아 재생.
5 단 파이프라인 — 브라우저 MediaRecorder 로 음성 녹음 → /api/voice/transcribe 에서 STT → ChatModel.call 호출 → /api/voice/speak 에서 TTS → 음성 응답을 브라우저가 재생.
세 추상화 (TranscriptionModel · ChatModel · TextToSpeechModel) 가 한 줄 위에서 손을 잡는.
🙋 한 학생의 걱정
"튜터님, 지난 시간 눈 도 겨우 적응했는데 오늘은 귀와 입 두 가지를 한꺼번에 한다고요? 게다가 마이크 녹음 같은 프론트엔드 일도 끼어 있고... 그리고 솔직히 — 음성도 텍스트랑 비용이 비슷한가요? 지난 시간 Vision 이 텍스트의 1.5~2 배라고 하셨잖아요. 음성은 또 얼마나 비싸지..."
그 걱정 너무 잘 알아요. 세 가지를 짧게 풀어드릴게요.
첫째, 오늘의 새로 외울 핵심 은 지난 시간처럼 세 단어 예요 — TranscriptionModel / TextToSpeechModel / 5 단 파이프라인. 그리고 결정적인 한 가지.
Day 2 의 프로바이더 추상화가 그대로 살아있어요. 새 프레임워크가 아니에요. 같은 Spring AI 위에서 빈 두 개가 한 자리에 더 놓이는 그림이에요. 지난 시간 ChatModel 옆에 Media 가 추가됐다면, 오늘은 ChatModel 옆에 두 빈 (TranscriptionModel · TextToSpeechModel) 이 나란히 놓이에요.
둘째, 마이크 녹음 같은 프론트엔드 일 은 — 본 강의는 백엔드 중심 모습이에요. 브라우저의 MediaRecorder API 시연 스니펫만 Step 2 에서 짧게 다루고, 핵심은 백엔드의 두 컨트롤러 (/api/voice/transcribe · /api/voice/speak) 예요.
오디오 파일을 멀티파트로 받고 → STT 호출 → 텍스트 응답 / 텍스트 받고 → TTS 호출 → 이진 응답 두에 손이 들어와 있으면, 프론트엔드는 Step 7 에서 통합 시연 으로 한 번 흘려보면 끝이에요.
셋째, 비용 감각 은 — 솔직히 Vision 보다 더 비쌀 수 있어요. 음성은 길이 가 결정 변수라서, 1 분짜리 오디오 한 번 = 텍스트 호출의 3~5 배 정도.
그래서 본 강의 디폴트는 Gemini 2.5 Flash 무료 티어 (음성 인식 가능) + 무료 TTS 옵션 (edge-tts · 브라우저 SpeechSynthesis Web API) 으로 갑니다.
유료 라인 (OpenAI gpt-4o-transcribe STT · OpenAI TTS) 은 비교 매트릭스에서만 다루고, 학생 실습은 완전 무료 옵션 으로 흐르게 정리할게요.
요약하자면 오늘 새로 외울 건 세 가지 단어 예요 — TranscriptionModel / TextToSpeechModel / 5 단 파이프라인.
그리고 세 가지 결정 — 어느 프로바이더로 갈지 (Gemini · OpenAI · 로컬) / 오디오 포맷을 어떻게 정할지 (mp3 · wav · webm) / 비용을 어떻게 가둘지. 지난 시간과 같은 호흡으로 풀면 됩니다.
학습 목표
TranscriptionModel/TextToSpeechModel자매 추상화 를 익히고, Day 2 의 프로바이더 추상화와 Day 7 의 모달리티 자매 가 세 번째 모달리티 (청각) 에서 다시 등장됨을 체득합니다.- 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인을 한 자리에서 흘려보면서, 세 추상화가 손을 잡는 그림을 익혀둡니다.
- Gemini 무료 티어 vs OpenAI STT (
gpt-4o-transcribe디폴트,whisper-1레거시) vs 로컬 무료 (whisper.cpp·edge-tts) 세 옵션의 비용 · 지연 · 품질 트레이드오프 매트릭스를 익혀둡니다. MultipartFile→Resource변환 으로 사용자 업로드 오디오를 STT 모델에 흘리는,byte[]이진 응답 으로 TTS 결과를 브라우저에 그대로 재생시키는 진행 — 두 가지 입출력 패턴을 익힙니다.- 음성 호출 비용 가드 를 지난 시간과 같은 방식으로 (단순 일일 쿼터 + 길이 제한) 정리해두고, 언제 음성을 호출하지 않을지 의 정책을 한 줄로 정리합니다.
Step 1. 음성 모델 선택 가이드 — 어느 라인업으로 갈까
자, 본 Step 으로 들어가기 전에 — 왜 또 모델 선택부터 시작하는가 를 한 번 더 짚고 갈게요. Day 7 (이미지) · Day 8 (Vision) 두 번 모두 Step 1 은 모델 라인업 비교 였죠. 오늘 Day 9 도 같은 이에요. 이유는 한 가지. 모달리티가 새로 등장할 때마다 프로바이더의 능력 (capability) 이 다시 한 번 갈라지기 때문이에요. 텍스트는 대부분의 모델이 다 잘하지만, Vision 은 일부 모델만 가능 했고, 음성은 — 또 다시 주제가 갈려요.
그리고 한 가지가 더 붙어요 — 음성은 텍스트 LLM 호출보다 훨씬 비싼 도시락 지점이에요. 1 분짜리 음성 한 번 인식이 텍스트 호출 30~50 번 분량 의 비용 (OpenAI gpt-4o-transcribe $0.006/분 기준). TTS 한 번이 5~10 번 분량. 그래서 어느 라인업으로 가느냐 의 결정이.
비용을 가둘 수 있느냐 와 학습 진입장벽이 어디까지 낮아지느냐 두 축에서 동시에 묶여요.
본 강의는 완전 무료 라인업 으로 흐르되 — 유료 라인업 의 도 한 곳에 정리해둬요. 학생이 졸업 후 실무에서 OpenAI STT 라인 (gpt-4o-transcribe · whisper-1) 을 만났을 때 — 첫 호흡이 흐트러지지 않게. 그게 오늘 Step 1 의 감각이에요.
1. 첫 주제 — 음성은 STT · TTS 두 가지로 쪼개진다
음성 도메인은 텍스트 / 이미지와 한 가지 다른 게 있어요.
입력과 출력이 — 서로 다른 모델 로 처리돼요. Vision 은 한 ChatModel 이 그림과 텍스트를 동시에 받아냈지만, 음성은 듣는 모델 (STT, Speech-to-Text) 과 말하는 모델 (TTS, Text-to-Speech) 이 분리 돼 있어요.
그래서 오늘 비교 매트릭스도. STT 표 한 장 + TTS 표 한 장 두 부분로 나눠 펼쳐요.
기억해둘 한 줄은 이거예요.
"음성 = 듣는 모델 (STT) + 말하는 모델 (TTS) 두 개. 같은 프로바이더가 둘 다 잘할 수도 있고, 한쪽만 잘할 수도 있어요. 그래서 서로 다른 프로바이더를 STT/TTS 에 섞어 쓰는 단계도 흔해요."
예를 들어 — STT 는 Gemini 무료 티어로, TTS 는 브라우저 SpeechSynthesis 로 갈 수 있어요.
듣는 부분 와 말하는 부분 가 완전히 분리된 빈 으로 들어가 있으니까요.
이 방식이 들어오면 — Step 2 에서 보게 될 TranscriptionModel · TextToSpeechModel 두 자매 추상화가 자연스럽게 받아들여져요.
2. STT (Speech-to-Text) 라인업 비교 — 듣는 모델 👂
자, 첫 주제 — 듣는 모델 의 라인업을 펼쳐볼게요. 2026-04 시점, 사용할 수 있는 옵션은 네 가지 예요.
(1) OpenAI gpt-4o-transcribe — 2026-04 시점 OpenAI 의 음성 인식 디폴트 라인 모습이에요. 분당 $0.006 (1 분 음성 한 번 ≈ 텍스트 호출 30~50 회 분량)
가격은 whisper-1 과 동일 한데 한국어 인식 정확도는 더 높은 모델이에요. GPT-4o 가 음성 인식까지 확장된 라인이라 잡음·억양·코드 스위칭 같은 까다로운 자루를 더 단단하게 받아요. 유료 운영 배포의 1 순위 + Spring AI 1.1.x OpenAI 스타터에 자동 등록 되는 모델.
본 강의 실습 코드의 디폴트 가 여기예요 (.env 의 OPENAI_STT_MODEL=gpt-4o-transcribe).
(2) OpenAI gpt-4o-mini-transcribe — 동일 라인의 저단가 버전. 분당 $0.003.
gpt-4o-transcribe 의 반값. 정확도가 한 단계 낮지만 학생 실습·대량 호출 자리엔 매력적이에요.
본 강의는 디폴트는 gpt-4o-transcribe 로 두되, 비용 절감이 필요하면 .env 한 줄 (OPENAI_STT_MODEL=gpt-4o-mini-transcribe) 로 갈아끼우면 끝 자리이에요.
(2-b) OpenAI whisper-1 — 레거시 옵션 — 음성 인식 분야의 고전 정답. 가격은 gpt-4o-transcribe 와 완전히 동일 ($0.006/분) 한데 모델 자체는 2022 년 라인.
OpenAI 가 하위 호환 (deprecated 가 아닌 legacy compatibility) 상태로 유지 하고 있어요.
기존에 whisper-1 로 박혀 있던 시스템의 단순 호환 이 아니라면 — 오늘 새로 시작하는 코드는 gpt-4o-transcribe 로 가는 방식이 맞아요. 본 강의에서는 과거 코드베이스를 만났을 때를 위해 라인업에만 정리 해두는 정도예요.
(3) Gemini 2.5 Flash (audio input) — 학생 실습 무료 라인 부분이에요. Gemini 의 audio input 모달리티는 일일 무료 쿼터 안 에 들어가요. 1 분짜리 음성을 input 토큰으로 카운트하긴 하는데.
무료 티어 RPD 가 학생 실습엔 충분히 들어가 있어요. 한국어 인식 품질도 상 수준이고, Day 8 까지 정리해둔 GEMINI_API_KEY 한 줄로 그대로 호출 가능. 학생 진입장벽이 가장 낮은. 🌟
(4) 로컬 whisper.cpp — 완전 무료 + 오프라인 곳이에요. CPP 로 포팅된 Whisper 모델을 노트북 안에서 직접 돌려요. 한국어도 medium 모델 이상이면 OK. 단점은 첫 호출 모델 로딩에 5~30 초 + GPU 가 없으면 응답 자체가 느려요.
그리고 모델 다운로드 (medium ≈ 1.5GB) 가 첫 시작 비용. 프라이버시가 핵심인 부분 또는 완전 오프라인 환경 에서만 1 순위.
| 옵션 | 비용 | 한국어 음질 | 응답 시간 (1 분 음성) | 세팅 | 추천 곳 |
|---|---|---|---|---|---|
OpenAI gpt-4o-transcribe |
🔴 분당 $0.006 | 🟢 최상 | 🟢 3~5 초 | 🟢 API 키 한 줄 | 🌟 본 강의 실습 디폴트 / 운영 배포 1 순위 |
OpenAI gpt-4o-mini-transcribe |
🟡 분당 $0.003 | 🟢 상 | 🟢 3~5 초 | 🟢 API 키 한 줄 | 저단가 라인 |
OpenAI whisper-1 (레거시) |
🔴 분당 $0.006 | 🟢 최상 | 🟢 3~7 초 | 🟢 API 키 한 줄 | 레거시 코드 호환 |
| Gemini 2.5 Flash (audio input) | 🟢 무료 (일일 쿼터) | 🟢 상 | 🟢 2~5 초 | 🟢 API 키 한 줄 | 🌟 학생 무료 실습 라인 |
로컬 whisper.cpp (medium) |
🟢 완전 무료 | 🟡 중~상 | 🔴 10~60 초 (CPU) | 🔴 모델 다운로드 ~1.5GB | 오프라인 / 보안망 |
🟢 = 우수 / 🟡 = 보통 / 🔴 = 약점
여기서 Spring AI 1.1.x 의 추상화를 한 줄 정리할게요. STT 의 표준 인터페이스는 org.springframework.ai.audio.transcription.TranscriptionModel 이에요 (Step 2 에서 시그니처를 펼칠 자리). 그리고.
OpenAI 스타터에 OpenAiAudioTranscriptionAutoConfiguration 이 들어가 있어요.
그래서 gpt-4o-transcribe / gpt-4o-mini-transcribe / whisper-1 같은 OpenAI 라인업은 모델 식별자 한 줄 (OPENAI_STT_MODEL) 만 바꿔도 동일한 빈으로 자동 등록 돼요.
추상화 학습이 가장 매끄러운 자리는 OpenAI 스타터 예요. 이게 본 강의의 교육용 코드 가 OpenAI 추상화 위에서 흐르는 이유예요.
3. TTS (Text-to-Speech) 라인업 비교 — 말하는 모델 🗣️
이제 두 번째 주제 — 말하는 모델 의 라인업이에요. TTS 는 STT 보다 옵션이 더 다양 해요. 완전 무료에 가까운 부분 가 두 부분나 있거든요 (브라우저 내장 + Microsoft Edge 의 unofficial).
(1) OpenAI TTS (tts-1 / tts-1-hd) — Spring AI 1.1.x 시점의 유료 1 순위. tts-1 은 $0.015 / 1K chars (한국어 짧은 답변 한 번 ≈ 텍스트 호출 5~10 번 분량), tts-1-hd 는 두 배 가격으로 오디오 품질이 한 단계 더 또렷 해요.
한국어 자연스러움도 상위권. 운영 배포에서 유료를 감수할 수 있는 부분 의 첫 번째 후보.
(2) OpenAI 신모델 (gpt-4o-mini-tts) — 1.1.x 신라인. 음성 캐릭터 (voice) 가 다양 하고 — instructions 옵션 으로 "차분한 톤으로 읽어줘" 같은 방식을 직접 지시할 수 있는 부분. ai-friends 의 캐릭터별 음성 차별화 시나리오에 매력적 모습이에요.
(선택 실습.)
(3) Gemini TTS — Gemini Audio 출력. 일일 무료 쿼터 안에서 — 텍스트 응답을 음성으로 받아올 수 있어요. 한국어 음성 품질은 상 수준. Gemini 키 한 줄로 시작.
(4) edge-tts — Microsoft Edge 의 TTS Web Service 를 unofficial 로 호출하는 Python CLI 예요. 완전 무료 + 한국어 음성 다양 (남성/여성 여러 보이스) — 매력이 큰 옵션이에요. 단.
unofficial 이라 API 변경 위험 이 항상 따라와요. Microsoft 가 어느 날 갑자기 막아버리면 거기에 있던 모든 시스템이 멈춰요. 그래서 본 강의에선 시연 옵션 으로만 정리해두고, 프로덕션 의존은 비추천.
(5) 브라우저 SpeechSynthesis Web API — 백엔드 비용 0 원. 브라우저가 기기 안에서 직접 합성 해요. 한국어 음성은 OS 의존 이라.
맥은 자연스럽고, 윈도우는 조금 기계적 이고, 모바일은 또 다른. 그래도 학습용 폴백 (fallback) 으로 가장 가벼운. <button onclick="speechSynthesis.speak(new SpeechSynthesisUtterance('안녕하세요'))"> 같은 한 줄짜리 자바스크립트로 끝.
(6) ElevenLabs (eleven_multilingual_v2) — 캐릭터 보이스 라인업이 가장 두꺼운 프로바이더예요. prebuilt voice 수백 종 + voice cloning 까지.
미연시·오디오북·게임 보이스 시장의 디폴트 라인업 자리이에요. OpenAI TTS 가 narrator + 자연스러운 한국어 발음 에 강하다면, ElevenLabs 는 반말 + 끝에 "ㅎㅎ" 이 묻는 톤 같은 캐릭터 연기 가 살아나는. 단점은 두 자루.
한국어 억양은 voice 운에 가까워서 (특정 voice 만 자연스럽고 전부 잘 되진 않아요), voice 식별자가 이름이 아닌 UUID 라 들고 다녀야 해요. Spring AI 1.1.x 의 OpenAI 와 함께 공식 starter 가 박혀 있는 두 번째 TTS 프로바이더 부분이에요 (spring-ai-starter-model-elevenlabs).
비용은 무료 티어 月 10,000 글자, 그 이후 100만자당 $30 정도 — OpenAI gpt-4o-mini-tts 의 ~50배. 오늘 본 강의는 OpenAI 디폴트로 가되, 마무리 섹션에서 .env 한 줄로 ElevenLabs swap 되는 토대 가 들어가 있어요.
| 옵션 | 비용 | 한국어 음질 | 음성 다양성 | 안정성 | 추천 모습 |
|---|---|---|---|---|---|
브라우저 SpeechSynthesis |
🟢 백엔드 0 원 | 🟡 중 (OS 의존) | 🟡 OS 의존 | 🟢 표준 Web API | 학습용 폴백 |
| Gemini TTS (audio output) | 🟢 무료 (일일 쿼터) | 🟢 상 | 🟡 보통 | 🟢 안정 | 🌟 무료 + 품질 |
OpenAI tts-1 / tts-1-hd |
🔴 $0.015~0.030 / 1K | 🟢 상~최상 | 🟢 다양 | 🟢 안정 | 운영 배포 |
OpenAI gpt-4o-mini-tts |
🟡 합리적 | 🟢 상 | 🟢 매우 다양 + instructions | 🟢 안정 | 운영 배포 (신라인) |
edge-tts (CLI) |
🟢 완전 무료 | 🟢 상 | 🟢 다양 | 🔴 unofficial 변경 위험 | 시연 / 사이드 프로젝트 |
ElevenLabs eleven_multilingual_v2 |
🔴 $$$ (무료 月 1만 자) | 🟡 voice 따라 다름 | 🟢🟢 캐릭터 보이스 최강 (수백 종 + cloning) | 🟢 안정 (공식 starter) | 미연시 캐릭터 보이스 / 마무리 섹션 swap |
Spring AI 1.1.x 의 TTS 표준 인터페이스는 org.springframework.ai.audio.tts.TextToSpeechModel 또는 OpenAI 한정의 OpenAiAudioSpeechModel 이에요 (정확한 클래스명은 Step 2 에서 코드베이스의 build 의존성과 함께 펼칠 부분). STT 와 마찬가지로.
OpenAI 스타터에 가장 잘 들어가 있고, Gemini · edge-tts 는 직접 RestClient 로 우회 하는 진행이 2026-04 시점의 보편 곳이에요.
4. 비교 매트릭스 한 화면에 — 4 가지 라인업의 합본
자, STT/TTS 두 표를 한 화면 에 묶어볼게요. 학생이 익히고 다닐 수 있는 합본 매트릭스예요.
| 옵션 | STT | TTS | 비용 | 한국어 음질 | 세팅 | 추천 자리 |
|---|---|---|---|---|---|---|
| Gemini 2.5 Flash | ✅ (audio input) | ✅ (audio output) | 🟢 무료 (일일 쿼터) | 🟢 상 | API 키 한 줄 | 🌟 본 강의 디폴트 / 처음 배우기 |
| OpenAI STT + TTS | ✅ (gpt-4o-transcribe / gpt-4o-mini-transcribe / whisper-1 레거시) |
✅ (gpt-4o-mini-tts / tts-1 / tts-1-hd) |
🔴 $$ | 🟢 최상 | API 키 한 줄 | 본 강의 실습 디폴트 + 운영 배포 |
로컬 whisper.cpp + edge-tts |
✅ (whisper.cpp) |
✅ (edge-tts CLI) |
🟢 완전 무료 | 🟡 중~상 | 🔴 도구 설치 (~1GB+) | 오프라인 / 보안 망 |
브라우저 SpeechSynthesis |
❌ (브라우저는 STT 전용 X) | ✅ (Web API) | 🟢 무료 | 🟡 중 (OS 의존) | 🟢 0 (브라우저 내장) | 학습용 / 폴백 |
이 매트릭스가 들어오면 — 왜 본 강의가 Gemini 디폴트 + 브라우저 SpeechSynthesis 폴백의 조합으로 흐르는지가 자연스럽게 풀려요. 비용 0 원에서 시작 + 학생 진입장벽 최소화 + 추상화 학습 가능 세 축이 동시에 잡히는 부분니까요.
5. 본 강의 디폴트 — Gemini 디폴트 + 추상화는 OpenAI 스타터 🌟
여기서 솔직한 결정 한 줄 을 정리해두고 갈게요. 본 강의의 디폴트 결정은 조금 묘한 방식 지점이에요.
- STT: 호출 자체는 Gemini 2.5 Flash (audio input) 무료 티어 로 가지만, 추상화 학습은 OpenAI 스타터의
TranscriptionModel인터페이스 (OpenAiAudioTranscriptionModel) 위에서 흘러요. - TTS: 학습용으론 브라우저
SpeechSynthesisWeb API 로 0 원에 시작하고, 실습 후반부 (선택) 로 OpenAI TTS 또는edge-tts로 갈아끼우는.
솔직한 트레이드오프 한 박스
Spring AI 1.1.x 의 STT/TTS 자매 추상화는 — 현 시점 OpenAI 스타터에 가장 잘 들어가 있어요.
OpenAiAudioTranscriptionAutoConfiguration·OpenAiAudioSpeechAutoConfiguration이 빈을 자동 등록해 주는 부분은 OpenAI 가 1 순위. Gemini 의 audio input 은 Spring AI 1.1.x 에서ChatModel의 multimodal 입력 으로 들어가는 진행이 더 자연스럽고, 별도TranscriptionModel인터페이스에 빈으로 자동 등록되지는 않아요.그래서 본 강의는 — 교육 목표인 추상화 감각 (TranscriptionModel · TextToSpeechModel) 은 OpenAI 스타터의 인터페이스로 잡고, 실제 호출은 학생이 무료로 흘릴 수 있는 옵션 (Gemini · 브라우저 · 모킹) 으로 분리해요. 추상화 학습 vs 실제 호출 의 두 방식을 다른 빈 으로 풀어두는 결정이에요. 운영 배포 단계에서는 거기에 OpenAI 키만 정리하면 — 같은 컨트롤러가 그대로 흘러가는 그림이 되고요.
6. 🙋 한 학생의 날카로운 질문
"튜터님, 그럼 Gemini 무료 티어로 STT/TTS 가 다 되는데 — 왜 OpenAI 스타터의 추상화를 굳이 쓰나요? Gemini 그냥 RestClient 로 직접 부르면 안 되나요?"
🔥 정말 좋은 질문이에요. 이 질문이 — 이 강의의 결정이 묘한 이유 를 정확히 짚었어요. 세 가지 방식으로 풀어드릴게요.
- Spring AI 의 교육 목표는 추상화 자체예요.
TranscriptionModel.call(prompt)한 줄 — 어느 프로바이더든 같은 방식으로 부른다는 감각이 오늘의 진짜 학습 목표예요.- 익혀야 할은 프로바이더 호출 코드의 디테일이 아니라, Spring AI 가 어떻게 음성 도메인을 추상화했는가 예요.
- RestClient 로 Gemini 를 직접 부르는 코드는 Day 1 이전의 ai-friends 코드베이스에 이미 들어가 있던 흐름. 그 방식을 한 단계 추상화한 부분이 오늘 학습할 감각이고요.
- 운영 배포 시 프로바이더 스위칭의 자유가 살아있어요. 학습 시점엔 Gemini 무료 티어로 흐르더라도, 실서비스에서 OpenAI
gpt-4o-transcribe로 갈아끼울 곳이 자연스럽게 열려 있어야 해요.- 추상화 위에서 흐르는 컨트롤러 코드는 프로바이더가 바뀌어도 변하지 않는 부분.
- 학습 시점부터 그 방식을 손에 정리해두면 — 졸업 후 실무에서 같은 호흡으로 옮겨갈 수 있어요. Day 2 의 프로바이더 추상화가 세 번째 모달리티에서 다시 등장하는 자리고요.
- 모킹 + 통합 테스트가 매끄러워져요. 추상화 위에서 컨트롤러를 짜두면 —
@MockBean TranscriptionModel한 줄로 호출을 모킹하고 컨트롤러만 통합 테스트가 가능해요. RestClient 로 직접 부르는 진행은 모킹이 훨씬 흐려져요. 본 강의 코드의 일부 테스트가 모킹된 빈 위에서 흐르는 이유예요.
요약하자면 — 추상화 감각 (교육 목표) + 프로바이더 자유 (운영) + 테스트 용이성 (품질) 세 축에서 추상화 위에서 흐르는 진행 이 우세해요. 호출 자체는 무료 옵션으로 가두고, 추상화는 OpenAI 스타터의 인터페이스로 잡는 결정이 — 그 세 축을 동시에 잡는 모습이에요.
7. 비용 감각 한 줄 — 음성은 텍스트 30 배 비싼 도시락
마지막 부분은 비용 감각 자리이에요. 지난 시간 Day 8 마무리에서 Vision 호출은 텍스트의 1.5~2 배 라고 정리해뒀던, 기억나시죠? 음성은 — 그보다 훨씬 더 비싼 도시락 부분이에요. 한 줄로 손에 정리할게요.
- 1 분짜리 음성 STT 1 번 ≈ 텍스트 LLM 호출 30~50 번 분량 (OpenAI
gpt-4o-transcribe·whisper-1모두 동일가 $0.006/분 기준). - 짧은 답변 TTS 1 번 ≈ 텍스트 LLM 호출 5~10 번 분량 (
tts-1기준).
Vision 보다 한 단계 더 비싸요. 그래서 본 강의의 비용 가드 (VoiceDailyQuotaGuard) 는 지난 시간 Vision 가드의 결과 같지만 — 길이 제한 (예: 30 초 이상 음성은 거절) 한 줄이 더 들어가요. 언제 음성을 호출하지 않을지 의 정책이 — 지난 시간보다 한 줄 더 깊어요. 단.
오늘의 Step 7 에선 의도적으로 비워두고, 학생 손으로 직접 정리하는 과제 로 미뤄둘게요. 5 단 파이프라인의 골격을 가장 선명하게 보이기 위함이고, 가드 패턴 세 번째 등장의 진짜 감각은 직접 정리해보는 데에 있어요.
한 가지 짚어둘 — 학생 실습 부분 에서는 Gemini 무료 티어 + 브라우저
SpeechSynthesis로 가니까 비용 0 원 곳이에요. 위 비용 감각은 졸업 후 OpenAI 라인업으로 운영 배포할 때 모습이고요. 오늘은 감각만 무료로 익히고, 비용은 머리에 정리해두는 호흡으로 가요.
8. 💡 튜터의 결론 — Step 1 한 줄
"음성은 STT · TTS 두 개로 쪼개진다. 본 강의는 Gemini 2.5 Flash 무료 티어 (STT) + 브라우저 SpeechSynthesis (TTS) 학습용 폴백 으로 비용 0 원에 흐르되, 추상화 감각은 OpenAI 스타터의
TranscriptionModel·TextToSpeechModel인터페이스 위에서 정리한다. 추상화 vs 실제 호출의 두 방식이 — 다른 빈 으로 분리되어 운영 배포 시 프로바이더 스위칭의 자유 가 살아있는 그림. Day 2 의 프로바이더 추상화가 세 번째 모달리티에서 다시 등장 되는 자리이다."
자, 어느 라인업으로 갈지 가 익히셨으니 — 이제 그 라인업을 어떤 인터페이스로 부를 것인가 가 다음 주제예요. Spring AI 1.1.x 가 우리한테 던져주는 두 자매 인터페이스 — TranscriptionModel · TextToSpeechModel. 그리고.
브라우저에서 마이크로 음성을 어떻게 녹음해서 백엔드로 흘릴지 의 첫 단계인 MediaRecorder API 시연. Step 2 에서 자매 추상화의 시그니처와 마이크 녹음의 첫 입자 부터 펼쳐봅시다.
Step 2. 자매 추상화 두 빈 + 마이크 녹음 감각 (약 25분)
자, Step 1 에서 어느 라인업으로 갈지 의 결정이 들어왔으니 — 이제 그 라인업을 어떤 모양으로 부를지가 본 Step 의 미션이에요. 결론부터 한 줄로 정리할게요.
"Spring AI 1.1.x 는 음성 도메인을 두 빈 으로 쪼개 추상화했어요.
TranscriptionModel(듣는 빈) 과TextToSpeechModel(말하는 빈) 이 나란히 들어가요. Day 2 의ChatModel옆에, Day 7 의ImageModel옆에 — 세 번째로 자매 추상화가 한 번 더 등장하는 지점이에요."
오늘 펼칠 감각은 두 가지예요. 첫째는 — Spring AI 의 두 자매 인터페이스 시그니처를 머릿속에 정리해두는. 둘째는 — 브라우저의 마이크 녹음 (MediaRecorder) 으로 첫 음성 입자를 익히어보는. 둘 다 Step 3 이후의 자바 코드 가 흐를 단계을 위에서 내려다보며 미리 그려두는 호흡이에요.
1. 세 번째 등장 — 자매 추상화 라는 진행
본 Step 의 진짜 호흡 을 이해하려면 — 익혀둔 지난 시간까지의 자매 추상화를 한 번 다시 떠올려봐야 해요. 우리는 지난 8 일 동안 같은 모양의 추상화 를 세 번 만났어요.
(1) Day 2 — ChatModel 의 첫 등장
기억나시죠? Day 2 첫날 "OpenAI 든 Gemini 든 Ollama 든, 코드 한 줄도 안 바꾸고 .env 한 줄로 프로바이더를 갈아끼울 수 있다" 는 감각이 들어갔어요. ChatModel 인터페이스 하나로 — 어느 프로바이더가 떨어져도 컨트롤러는 그대로 흐르는. 프로바이더 추상화 의 첫 회수였어요.
(2) Day 7 — ImageModel 의 자매 추상화
Day 7 에선 모달리티가 한 부분 더 추가됐어요.
그림 생성 이라는 다른 모달리티에서도 — ImageModel.call(imagePrompt) → ImageResponse 의 같은 방식 이 등장했죠.
ChatModel.call(prompt) → ChatResponse 이 그대로 복제 된 자매.
모달리티 자매 추상화 의 두 번째 등장였어요.
(3) Day 9 — TranscriptionModel · TextToSpeechModel 두 자매 한꺼번에
그리고 오늘 — 세 번째 등장 가 와요.
이번엔 두 자매가 동시에 등장해요.
TranscriptionModel (듣는 빈) 과 TextToSpeechModel (말하는 빈).
이 둘은 입력 모달리티 (음성 → 텍스트) 와 출력 모달리티 (텍스트 → 음성) 가 — 완전히 반대 방향 인 자매예요.
그런데 Spring AI 의 인터페이스 모양은
둘 다 chatModel.call(prompt) 의을 그대로 따르고 있어요.
이 방식이 들어오면 — Step 3 부터 흐를 자바 코드의 낯섦 이 사라져요. 완전히 새로 외우는 게 아니라, Day 2 의 감각이 음성 도메인에 한 번 더 복제된 부분니까요.
2. TranscriptionModel — 듣는 빈 의 시그니처 👂
자, 첫 번째 자매 — 듣는 빈 모습이에요. Spring AI 1.1.x 의 표준 인터페이스 풀네임은 이거예요.
org.springframework.ai.audio.transcription.TranscriptionModel
핵심 메서드는 한 줄이에요.
AudioTranscriptionResponse call(AudioTranscriptionPrompt prompt)
ChatModel.call(Prompt) 와 완전히 같은 모양 이죠? 입력은 Prompt 자루 하나, 출력은 Response 자루 하나. 자매 추상화가 그대로 살아 있어요.
세부 구조를 한 단계 더 펼쳐볼게요.
- 입력 (
AudioTranscriptionPrompt) — Spring core 의Resource한 개를 받아요.new AudioTranscriptionPrompt(audioResource)같은 생성자.- 오디오 바이트 를 어떤 모양으로든
Resource로 감싸기만 하면 STT 모델에 던질 준비 끝. - 파일이면
FileSystemResource, 메모리 바이트면ByteArrayResource, 클래스패스 안의 샘플이면ClassPathResource— 어느 모양이든 같은 인터페이스 예요.
- 오디오 바이트 를 어떤 모양으로든
- 출력 (
AudioTranscriptionResponse) —.getResult().getOutput()한 줄로 텍스트 String 이 떨어져요.ChatResponse.getResult().getOutput().getText()와 같은 방식 의 추출 패턴. 손이 한 번에 들어와요.
빈 등록은 — Step 1 에서 정리해둔 그 방식 그대로예요. OpenAI 스타터의 OpenAiAudioTranscriptionAutoConfiguration 이 자동으로 TranscriptionModel 빈을 등록해줘요. 컨트롤러에선 그냥 생성자 주입 한 줄.
// Step 3 에서 본격 펼칠 흐름 — 미리 머리에 박아두는 시그니처
private final TranscriptionModel transcriptionModel;
public String transcribe(Resource audioResource) {
AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(audioResource);
AudioTranscriptionResponse response = transcriptionModel.call(prompt);
return response.getResult().getOutput();
}
위 스니펫은 Step 3 에서 실제로 코드베이스에 박을 곳의 미리보기예요. Spring AI 1.1.x 의 정확한 시그니처 (특히
TextToSpeechModel쪽) 는 Step 5 의 TDD 검증 단계에서 코드베이스의 build.gradle 의존성과 함께 한 번 더 확인 할 거예요. 학습 호흡상 시그니처를 미리 머릿속에 그려두는 부분라고 생각해주세요.
3. TextToSpeechModel — 말하는 빈 의 시그니처 🗣️
두 번째 자매 — 말하는 빈 자리이에요. Spring AI 1.1.x 의 공통 추상화 패키지 에 들어간 인터페이스가 풀네임은 이거예요.
org.springframework.ai.audio.tts.TextToSpeechModel(1.1.x 공통 추상화)OpenAI 구현체는
org.springframework.ai.openai.OpenAiAudioSpeechModel—OpenAiAudioSpeechAutoConfiguration이 위 인터페이스의 빈으로 자동 등록해줘요. 컨트롤러 / 서비스 부분에선 인터페이스 (TextToSpeechModel) 로만 받으면 — 어느 구현체가 떨어져도 같은 방식으로 흐릅니다 (본 강의의 프로바이더 추상화 원칙).
핵심 메서드도 같은 이에요.
TextToSpeechResponse call(TextToSpeechPrompt prompt)
세부 구조를 한 단계 더 펼쳐볼게요.
- 입력 (
TextToSpeechPrompt) — 합성할 텍스트 String 한 줄을 받아요.new TextToSpeechPrompt("안녕하세요, 저는 쿠로미예요!")같은 모양.- 옵션을 더 박고 싶으면
new TextToSpeechPrompt(text, OpenAiAudioSpeechOptions.builder().voice("alloy").build())처럼 음성 캐릭터·포맷·속도 를 추가로 지정할 수 있어요.
- 옵션을 더 박고 싶으면
- 출력 (
TextToSpeechResponse) —.getResult()로Speech하나가 떨어지고,.getOutput()한 줄로 byte[] 가 떨어져요. 바이트 배열 자체 가 음성 데이터예요 (디폴트로 mp3 인코딩). 그대로 컨트롤러의 응답 본문에 흘려보내면 — 브라우저의<audio>태그가 받아서 재생해요.
// Step 5 에서 본격 펼칠 흐름 — 미리 머리에 박아두는 시그니처
private final TextToSpeechModel textToSpeechModel;
public byte[] synthesize(String text) {
TextToSpeechResponse response = textToSpeechModel.call(new TextToSpeechPrompt(text));
return response.getResult().getOutput();
}
빈 등록은 — OpenAI 스타터의 OpenAiAudioSpeechAutoConfiguration 이 자동으로 TextToSpeechModel 빈(구현체는 OpenAiAudioSpeechModel) 을 등록해줘요. 듣는 빈 과 말하는 빈
두 자매가 같은 스타터 안에서 나란히 들어가 있는 그림이에요. 부엌의 칼 과 도마 가 한 부분에 놓이듯, 서로을 보완하는 두 도구가 한 모습에 들어와 있죠.
4. 왜 두 빈 으로 쪼개졌을까
자, 여기서 결정적인 질문 한 줄을 던져볼게요.
"튜터님, 음성 모달리티는 — 한 빈 (
AudioModel같은 자루) 안에 듣기 + 말하기 를 다 묶을 수도 있었잖아요? 왜 굳이 두 빈 으로 쪼갰나요?"
이 질문이 익히어지면 — Spring AI 의 추상화가 왜 이렇게 들어갔는가 라는 깊은에 닿아요. 세 가지 방식으로 풀어드릴게요.
(1) 입력 모달리티 ↔ 출력 모달리티의 처리이 완전히 달라요
STT 호출은 오디오 바이트 → 텍스트 의 변환이에요. 입력 payload 가 수 MB ~ 수십 MB, 처리 시간이 3~10 초, 비용이 분당 과금 (시간 기반) 의. TTS 호출은 정반대예요. 텍스트 String → 오디오 바이트 입력 payload 가 수 KB 이하, 처리 시간이 0.5~3 초, 비용이 글자 수 기반 (1K chars 단위) 의. latency · 비용 모델 · 호출 패턴 이 — 세 축 모두에서 다른 곳이에요. 한 빈으로 묶으면 — 추상화가 흐려져요.
(2) 한 모델이 한쪽만 잘하는 자리이 흔해요
Step 1 의 라인업 매트릭스를 다시 떠올려보세요. Whisper 는 STT 전용 이에요 — TTS 는 안 해요. 반대로 tts-1 · gpt-4o-mini-tts 는 TTS 전용 이에요 — STT 는 안 해요. 그리고.
브라우저 SpeechSynthesis 는 TTS 만 가능, whisper.cpp 는 STT 만 가능. 어느 프로바이더가 어느 자루를 들고 있는가 가 — 모델 단위로 다른 지점이에요. 한 빈에 묶어두면 — 반쪽만 구현된 빈 이라는 어색한 방식이 자꾸 등장해요. 두 빈으로 쪼개두면 —
각 빈이 자기 자루만 책임지면 끝.
(3) 5 단 파이프라인이 자연스럽게 풀려요
오늘 우리가 만들 부분은 — 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인이에요.
대화의 핵심 자루 는 결국 텍스트 예요.
ChatModel 이 텍스트를 입력 받아 텍스트를 출력 하니까요.
STT 와 TTS 는 그 텍스트 자루의 입출력 양쪽 끝 에 어댑터 처럼 박히는.
두 빈이 완전히 분리 되어 있으니. 어댑터처럼 끼우고 빼는 자유가 살아있어요. STT 만 끼우고 TTS 는 안 끼우면 → 음성 입력 + 텍스트 출력. TTS 만 끼우고 STT 는 안 끼우면 → 텍스트 입력 + 음성 출력. 두 빈을 모두 끼우면 → 5 단 풀 파이프라인. 추상화가 모듈러 한 모습예요.
🙋 한 학생의 날카로운 질문
"튜터님,
ChatModel처럼 이것도.env한 줄로 프로바이더 갈아끼울 수 있나요? OpenAIgpt-4o-transcribe에서 — 다른 프로바이더로 옮기는 진행이 진짜 한 줄 수정으로 끝나나요?"정확히 핵심을 짚으셨어요. 답은 — "Day 2 의 감각이 음성 도메인에서도 그대로 살아있어요." Spring AI 가 정리해둔 인터페이스 —
TranscriptionModel·TextToSpeechModel— 위에서 흐르는 컨트롤러 코드는 프로바이더가 바뀌어도 변하지 않아요. OpenAI 스타터를 빼고 다른 음성 스타터를 정리해 넣으면 — 같은 인터페이스에 다른 빈이 등록 되고, 컨트롤러는 그 빈이 누구든 신경 안 써요.단, 솔직한 방식 한 가지 — 2026-04 시점, 음성 도메인의 자동 빈 등록 은 OpenAI 스타터에 가장 잘 들어가 있어요. Gemini 의 audio input 은 — 별도
TranscriptionModel빈으로 등록되기보단ChatModel의 multimodal 입력 으로 흐르는 진행이 더 자연스러워요. 그래서 완벽한.env 한 줄 스위칭 은 OpenAI ↔ 다른 OpenAI 호환 프로바이더 (Groq Whisper 같은 자리) 사이에선 깔끔하게 살아있고, Gemini 로 갈 때는 호출 코드가 한 단계 우회 해야 해요. 추상화의 감각은 살아있되, 프로바이더 생태계의 성숙도가 텍스트 도메인보단 한 박자 늦은 부분이라고 정리해두면 정확해요.
5. 마이크 녹음 — 브라우저의 MediaRecorder API
자, 자매 추상화의 시그니처를 머리에 정리했으니 — 이제 백엔드로 흘려보낼 첫 음성 입자 를 어디서 따올지 한 번 만져봐요. 브라우저의 마이크 녹음 단계이에요. 본 강의는 백엔드 중심 이지만, 이 곳은 5 단 파이프라인의 첫 단 이라 — 짧게라도 익히어둘 가치가 있어요.
브라우저가 우리한테 던져주는 도구는 — Web API 표준 의 두 개예요.
navigator.mediaDevices.getUserMedia({ audio: true })— 사용자에게 마이크 권한 을 요청해요. 첫 호출 시 브라우저가 "이 사이트가 마이크 사용을 요청합니다" 팝업을 띄우고 — 사용자가 허용하면MediaStream이 떨어져요. 오디오 스트림의 핸들 부분이에요.MediaRecorder—MediaStream을 받아서 바이너리 오디오 청크 를 토해내는 녹음기예요.recorder.start()로 녹음 시작,recorder.stop()으로 끝, 그 사이에ondataavailable콜백이 청크 단위로 데이터를 넘겨줘요.
// 마이크 녹음 → Blob 변환 (Step 7 통합 시연에서 사용 예정)
async function recordAudio(maxSeconds = 10) {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.start();
return new Promise((resolve) => {
setTimeout(() => recorder.stop(), maxSeconds * 1000);
recorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
resolve(new Blob(chunks, { type: 'audio/webm' }));
};
});
}
// 녹음한 Blob 을 백엔드로 업로드 (Step 4 의 /api/voice/transcribe 와 연결)
async function uploadAudio(blob) {
const form = new FormData();
form.append('audio', blob, 'recording.webm');
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form });
return await res.json();
}
이 스니펫의 감각을 한 줄씩 짚어볼게요.
{ mimeType: 'audio/webm' }— 녹음 포맷을 명시적으로 정리해요.- 안 정리하면 브라우저 디폴트가 Chrome 은
audio/webm;codecs=opus, Safari 는audio/mp4로 서로 달라요. - 백엔드에서 어떤 포맷을 받아도 처리할 수 있게 해두든가, 프론트에서 미리 한 포맷으로 강제 해두든가 둘 중 하나가 필요해요. 본 강의는 후자 (강제) + 백엔드 검증 의 안전이에요.
- 안 정리하면 브라우저 디폴트가 Chrome 은
chunks.push(e.data)— 녹음 중에 청크가 조각조각 떨어져요.Blob으로 모아 두에요.stream.getTracks().forEach(t => t.stop())— 마이크 권한을 명시적으로 닫아주는 한 줄. 안 닫아두면 브라우저 탭의 마이크 표시 빨간 점이 계속 켜진 채 로 남아요. 사용자 신뢰를 위해 반드시 정리해두에요.new FormData()+multipart/form-data— Blob 을 멀티파트 업로드 로 백엔드에 흘려보내요. 백엔드 컨트롤러는 — Step 4 에서 박을@RequestParam("audio") MultipartFile audio한 줄로 받아내요. 여기가 자바 코드의 첫 발 이 들어올 부분고요.
6. 포맷 한 줄 — OpenAI STT API 가 받는 자루들
여기서 포맷 결정 한 줄을 정리해두고 갈게요. OpenAI Audio Transcription API 가 받아주는 오디오 포맷은 — 제한된 자루 예요 (gpt-4o-transcribe · gpt-4o-mini-transcribe · whisper-1 모두 같은 엔드포인트 라 동일 목록).
mp3,mp4,mpeg,mpga,m4a,wav,webm— 이 7 가지가 OpenAI STT 가 받는 표준 자루예요.
브라우저 MediaRecorder 가 디폴트로 떨어뜨리는 포맷 은 Chrome 은 webm/opus, Safari 는 mp4/aac 라고 위에 정리해뒀죠? 둘 다 OpenAI STT 의 허용 자루 안 에 들어가요.
그래서 프론트에서 굳이 변환하지 않아도 백엔드가 그대로 받아서 흘려보낼 수 있어요.
호환성이 잘 들어가 있는 부분 라. 학생 실습 단계에선 추가 변환 코드 없이 바로 흘려도 OK.
단 한 가지 정리해둘 — 모바일 브라우저 (특히 iOS Safari) 는
MediaRecorder지원이 2024 년 이후에 합류 한 모습예요. 본 강의 실습은 데스크톱 Chrome / Edge / Firefox 를 1 순위로 안내하고, 모바일은 Step 7 통합 시연 시 별도 검증 하는 방식으로 갑니다.
7. <audio> 태그 — TTS 응답을 재생하는 진행
자매의 반대쪽 — TTS 응답을 브라우저가 받아 재생하는 도 한 줄 정리할게요. 이게 5 단 파이프라인의 마지막 단 자리이에요.
// /api/voice/speak 의 byte[] 응답을 받아 재생 (Step 6 컨트롤러 + Step 7 통합 시연에서 사용)
async function playTts(text) {
const res = await fetch('/api/voice/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const audioBlob = await res.blob();
const url = URL.createObjectURL(audioBlob);
new Audio(url).play();
}
감각을 한 줄씩 짚어볼게요.
await res.blob()— 백엔드가 byte[] 로 던진 응답을 — 브라우저가 Blob 으로 받아내요. 이진 응답 이라res.json()이 아니라res.blob()이에요.URL.createObjectURL(audioBlob)— 메모리 안의 Blob 에 임시 URL 을 발급하는 한 줄. 이 URL 은 현재 페이지가 살아있는 동안만 유효해요. 페이지가 닫히면 자동으로 가비지 컬렉트.new Audio(url).play()— HTML5 의<audio>태그를 자바스크립트로 즉석에서 만들어 재생. 사용자에게 보이는 UI 는 없고, 소리만 흘러요.
8. 💡 튜터의 결론 — Step 2 한 줄
"Spring AI 1.1.x 는 음성 도메인을 두 빈 으로 쪼갰다 —
TranscriptionModel(듣는 빈) 과TextToSpeechModel(말하는 빈). Day 2 의ChatModel· Day 7 의ImageModel에 이은 세 번째 자매 추상화 이고, 같은 모양의.call(prompt) → response시그니처가 그대로 살아있다. 두 빈으로 쪼개진 이유는 — 입출력 처리이 완전히 다르고, 프로바이더가 한쪽만 잘하는 곳이 흔하며, 5 단 파이프라인이 어댑터처럼 모듈러하게 풀리기 때문. Day 2 의 .env 한 줄 스위칭 감각이 — 음성 도메인에서도 (OpenAI 스타터 위에선) 그대로 살아있다."
이론은 여기까지. 실제로 익숙해질 자매 추상화의 첫 빈 — TranscriptionModel 을 Step 3 에서 직접 호출해봅니다. 오디오 하나를 Resource 로 감싸서 — STT 모델에 던지는 첫 자바 코드가 흐를 부분이에요.
Step 3. `VoiceTranscriptionService` — 첫 자매 빈을 손으로 잡는 모습 (약 25분)
자, Step 2 에서 손으로 그렸던 자매 추상화 — 그 시그니처가 진짜 빈으로 주입되는 부분 가 바로 여기예요.
머리 속에서만 굴러다니던 TranscriptionModel.call(AudioTranscriptionPrompt) 의 한 줄이 — 실제 @Service 클래스의 한 메서드 안에서 흐르는.
Day 8 에서 VisionChatService 를 손에 정리해뒀던 결과 완전히 같은 호흡 으로 갑니다.
본 Step 의 미션은 한 줄로 정리해두면 — "음성 파일(Resource) 하나를 받아 STT 모델에 던지고 → 텍스트 하나로 돌려주는 서비스 빈" 을 익히는 거예요.
코드 자체는 Day 8 의 VisionChatService 와 흐름이 똑같이 흐르는데, 모달리티 한 자리만 음성으로 바뀐.
지난 시간까지의 감각이 한 모달리티만 옆으로 슬쩍 옮겨가는이라 — 손이 가볍게 들어와요.
1. VoiceTranscriptionService — 전체 코드 하나
먼저 완성된 방식 을 한 번에 들여다볼게요. Day 8 의 VisionChatService 와 같은 호흡이라 — 낯선 부분은 두 줄도 안 돼요.
package kr.spartaclub.aifriends.voice.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt;
import org.springframework.ai.audio.transcription.AudioTranscriptionResponse;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
/**
* Day 9 Step 3 — Voice (STT, Speech-to-Text) 도메인 진입점.
*
* <p>음성 파일({@link Resource}) 한 개를 받아 {@link TranscriptionModel} 에 전달하고
* 텍스트 결과를 그대로 돌려준다. {@link TranscriptionModel} 은 인터페이스로만 주입받으므로
* (본 강의의 프로바이더 추상화 원칙), 빈은 OpenAI `gpt-4o-transcribe` (디폴트) / `whisper-1` (레거시) · 추후 다른 STT 프로바이더 어떤 것이든
* 될 수 있다 — 호출자는 모른다. 활성 STT 프로바이더는 {@code application.yml} 의
* {@code spring.ai.model.audio.transcription} 프로퍼티로만 결정된다.</p>
*
* <p>응답 텍스트가 null/빈 문자열이면 빈 문자열을 반환한다 (Day 8 {@code VisionChatService}
* 와 동일한 방어 패턴). null 입력 등 명백한 입력 오류는 {@link VoiceException} 으로
* 래핑해 {@code GlobalExceptionHandler} 가 ApiResponse.fail 형태로 변환하게 한다.</p>
*
* <p>실제 STT 호출은 텍스트 LLM 대비 토큰 비용이 크다 (음성/이미지/비디오 비용 급등 경고). 단위 테스트는
* Mockito 모킹으로만 검증하고, 실제 호출 smoke 는 강사가 수동으로 한다.</p>
*/
@Slf4j
@Service
public class VoiceTranscriptionService {
private final TranscriptionModel transcriptionModel;
public VoiceTranscriptionService(TranscriptionModel transcriptionModel) {
this.transcriptionModel = transcriptionModel;
}
/**
* 음성 파일을 STT 모델에 보내 인식된 텍스트를 반환한다.
*
* @param audio 음성 파일 리소스 (MultipartFile 의 Resource 변환 또는 ClassPathResource)
* @return 인식된 텍스트. 응답이 비어 있으면 빈 문자열.
* @throws VoiceException audio 가 null 인 경우 (VOICE_AUDIO_REQUIRED)
*/
public String transcribe(Resource audio) {
if (audio == null) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
}
AudioTranscriptionResponse response = transcriptionModel.call(new AudioTranscriptionPrompt(audio));
if (response == null || response.getResult() == null) {
return "";
}
String text = response.getResult().getOutput();
return text == null ? "" : text;
}
}
자, 이 코드가 왜 이렇게 들어갔는지 한 줄씩 짚어볼게요.
(1) @Slf4j + @Service — 평범한 빈 등록
가장 위 두 줄은 — Spring 의 보편적인 이에요. @Service 로 빈 자동 등록, @Slf4j 로 Lombok 의 SLF4J 로거를 빈 안에 정리해둬요. 본 코드는 호출 결과를 굳이 INFO 로 찍진 않지만, 추후 가드/예외에서 log.warn(...) 한 줄을 박을 부분 를 미리 열어두에요.
(2) 생성자 주입 — Lombok 없이 명시 생성자
Day 8 의 VisionChatService 와 같은 방식 이에요. @RequiredArgsConstructor 도 한 줄 박을 수 있는데 — 명시 생성자가 한 줄 이라 오히려 가독성이 더 또렷 해요. 그리고 — 이 빈이 어떤 의존성을 주입받는지 가 클래스 모양에서 한눈에 보이는 결이라.
학생 학습용 코드에선 명시 생성자 가 1 순위. Day 8 → Day 9 의 톤 통일 도 같이 가져가는 셈이고요.
private final TranscriptionModel transcriptionModel;
public VoiceTranscriptionService(TranscriptionModel transcriptionModel) {
this.transcriptionModel = transcriptionModel;
}
여기서 결정적인 한 줄 은 — 주입받는 타입이 TranscriptionModel 인터페이스 라는 점이에요. OpenAiAudioTranscriptionModel 같은 구현체 타입으로 박지 않아요. 본 강의의 프로바이더 추상화 원칙이 — 세 번째 모달리티에서도 그대로 살아있는 곳이에요.
호출자(이 서비스) 는 활성 STT 프로바이더가 누구인지 모른다. application.yml 의 spring.ai.model.audio.transcription 한 줄이 그 결정을 쥐고 있고, 이 빈은 그 결정을 신경 안 써도 흐르는 지점이에요.
(3) transcribe(Resource audio) — 핵심 메서드의 시그니처 👂
메서드 하나의 모양을 보세요. 입력은 Resource 한 개, 출력은 String 한 줄. Spring core 의 Resource 인터페이스 위에 완전히 추상화 돼요. 이게 왜 중요 한지는 — 다음 박스에서 한 학생이 바로 그 부분를 찔러줄 거예요.
(4) null 가드 — VoiceException(VOICE_AUDIO_REQUIRED)
if (audio == null) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
}
여기에 본 강의의 예외 처리 규약 이 또 다시 만나요 — IllegalArgumentException 직접 던지지 않는다 는. 도메인별 커스텀 예외(VoiceException) 로 래핑해서.
GlobalExceptionHandler 가 자동으로 ApiResponse.fail(ErrorResponse) 형태로 변환하게 만들어요. Day 8 의 VisionException (V001~V005) 과 완전히 같은 방식 모습이에요. 세 번째로 다시 등장하는이고요.
(5) transcriptionModel.call(new AudioTranscriptionPrompt(audio)) — 자매 추상화의 진짜 한 줄
AudioTranscriptionResponse response = transcriptionModel.call(new AudioTranscriptionPrompt(audio));
이 한 줄이 — Step 2 에서 머리에 그려둔 시그니처가 진짜로 흐르는 자리이에요. Resource 하나를 — AudioTranscriptionPrompt 라는 자루에 한 번 더 포장 하고, TranscriptionModel.call() 한 줄에 던져요. 출력은 AudioTranscriptionResponse
Day 2 의 ChatModel.call(prompt) → ChatResponse · Day 7 의 ImageModel.call(imagePrompt) → ImageResponse 와 완전히 같은 모양의 시그니처 가 — 세 번째로 익히셨어요.
(6) 응답 추출 — null 안전 빈 문자열 폴백
if (response == null || response.getResult() == null) {
return "";
}
String text = response.getResult().getOutput();
return text == null ? "" : text;
세 단계 방어막이 들어가 있어요.
response 가 null 인 경우, response.getResult() 가 null 인 경우, 마지막으로 getOutput() 이 null 인 경우 — 셋 다 빈 문자열로 폴백.
이게 Day 8 VisionChatService 의 동일한 방어 패턴 단계이에요.
프로바이더가 (간헐적으로) 빈 응답을 흘릴 때 NullPointerException 으로 호출 체인이 무너지지 않도록 막아두는 결.
💡 튜터의 결론 — Step 3 의 호흡 한 줄
Step 2 에서 머리로 그렸던 자매 추상화의 시그니처가 — 한 메서드 안에 그림처럼 응축되는.
Resource → AudioTranscriptionPrompt → TranscriptionModel.call → AudioTranscriptionResponse → String의 5 단 변환이 — 단 한 줄 의 호출로 묶이고, 호출자는 프로바이더가 누구인지 모른다. Day 8VisionChatService의 감각이 음성 도메인으로 그대로 옮겨온.
2. VoiceException — 도메인별 예외의 세 번째 등장
VoiceTranscriptionService 가 던지는 예외 — VoiceException 의 본문을 하나 펼쳐볼게요. 이 방식이 왜 굳이 도메인별로 쪼개졌는가 가 본 Step 의 또 다른 곳이에요.
package kr.spartaclub.aifriends.voice.exception;
import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
/**
* Day 9 Step 3 — Voice (STT/TTS) 도메인 커스텀 예외.
*
* <p>{@code IllegalArgumentException} / {@code RuntimeException} 직접 throw 금지 규칙을 따라,
* 음성 파일 업로드 검증 실패 · STT 호출 실패 · TTS 합성 실패 등은 모두 이 예외로 래핑한다.
* {@link ErrorCode} 의 {@code VOICE_*} 항목과 함께 던지면 {@code GlobalExceptionHandler}
* 가 ApiResponse.fail 형태로 자동 변환해준다.</p>
*/
public class VoiceException extends BusinessException {
public VoiceException(ErrorCode errorCode) {
super(errorCode);
}
}
본문은 놀랍도록 짧아요. 부모 클래스 BusinessException 의을 그대로 상속받아 — 생성자 한 줄만 추가.
그런데 바로 이 짧음이 — 패턴을 또렷하게 만들어요.
Day 7 의 ImageException, Day 8 의 VisionException, 그리고 오늘의 VoiceException — 세 도메인의 예외가 모두 같은 모양의 그릇 인 부분이에요.
도메인별로 예외 패키지를 왜 굳이 쪼개는가 — 한 줄로 짚어두면 추후 도메인이 늘어날 때를 위한 포석이에요. Vision 의 예외와 Voice 의 예외가 한 패키지에 섞여 있으면 — Day 9 시점엔 두 도메인뿐이라 별 차이 없는 듯 보이지만, Day 11 에 Tool, Day 15 에 RAG, Day 17 에 MCP 가 들어오면. 예외 클래스 한 패키지에 30 개씩 쌓여요. "이 예외가 어느 도메인의 책임인가" 가 흐려지고, 수정 시 영향 범위 도 같이 흐려져요. 지금부터 도메인별로 쪼개두는 결이 — 나중에 감각이 살아남는 비결입니다.
3. ErrorCode.VOICE_AUDIO_REQUIRED — 음성 도메인의 첫 코드
VoiceException 이 들고 다니는 ErrorCode 의 도 한 줄 정리할게요. Day 7 (Image, I001~I005) · Day 8 (Vision, V001~V005) 에 이어 Day 9 (Voice) 가 세 번째로 자기 prefix 를 받는 모습이에요.
// kr.spartaclub.aifriends.common.exception.ErrorCode (Day 9 Step 3 시점 발췌)
// Voice / STT (Day 9)
VOICE_AUDIO_REQUIRED(HttpStatus.BAD_REQUEST, "VC001", "음성 파일을 첨부해 주세요."),
코드베이스의 ErrorCode 에는 이미 VC001~VC007 이 한꺼번에 들어가 있어요. 본 Step 은 서비스 부분의 입력 가드 (
null체크) 만 다루기 때문에, 오늘 컨트롤러 단계에서 다시 다룰 VC002~VC004 (Step 4) · TTS 쪽 VC005~VC007 (Step 5~6) 는 각 Step 의 코드 블록에서 점진적으로 펼칠게요. 한 번에 7 개를 다 보여드리지 않는 이유는 — 각 코드가 「어느에서 던져지는지」 를 Step 단위로 또렷하게 박기 위함 자리이에요.
VC001 — Voice 도메인의 첫 코드. prefix 가 V 가 아니라 VC 인이 묘하죠? Day 8 의 Vision 이 이미 V prefix 를 점유했기 때문에 — 충돌을 피하기 위한 선택 모습이에요. 음성(Voice/Audio) 의을 살리되, Vision 과 한 글자 어긋나게 —
VC (Voice Chat / Voice Code 의 약자 흐름) 로 정리해뒀어요.
HttpStatus.BAD_REQUEST (400) — 클라이언트의 잘못 임을 명시. 음성 파일을 안 보내놓고 STT 를 요청한 그림이라 — 서버가 처리할 수 없는 요청 이라는 진행을 status 코드로 신호.
메시지 — 사용자에게 그대로 보여줄 한 줄 — "음성 파일을 첨부해 주세요." 학생이 아니라 최종 사용자 가 읽는 곳이에요. 짧고 명료 하게.
VC004 (VOICE_TRANSCRIPTION_FAILED) 는 Step 4 의 컨트롤러 지점 또는 운영 흐름 에서 다시 등장할 코드예요.
STT 호출 자체가 실패할 때 (네트워크 단절 · 프로바이더 5xx) 의.
오늘 Step 3 은 입력 가드 한 자리만 박고, 멀티파트 검증 (VC002·VC003) 과 호출 실패 가드 (VC004) 는 Step 4 에서 한 번 더 펼칠게요.
4. Resource vs MultipartFile — 서비스가 받는 자루의 정체
서비스의 시그니처가 왜 Resource 인지가 — Day 8 에서 MultipartFile 을 받았던 컨트롤러와 다른 모습 예요. 한 번 손에 짚어두면 Step 4 에서 변환 부분 가 깔끔하게 보입니다.
🙋 한 학생의 날카로운 질문
"튜터님,
Resource가 뭐예요? 지난 시간 Day 8 에서 컨트롤러에MultipartFile받았던 거랑 같은 건가요?"정확히 핵심을 찔러주셨어요. 두 개는 — 완전히 다른 추상화 층 자리이에요. 짧게 풀어드릴게요.
Resource는 — Spring core 의 인터페이스 예요 (org.springframework.core.io.Resource). 바이트 하나를 어디서 어떻게 읽을지에 무관하게 추상화한 부분. 파일이면FileSystemResource, 클래스패스 안의 샘플이면ClassPathResource, 메모리 바이트면ByteArrayResource, URL 이면UrlResource— 어느 출처든 같은 인터페이스 로 다뤄요. Spring AI 의AudioTranscriptionPrompt가 받는 자루의 모양 이 바로 이Resource예요.
MultipartFile은 — Spring Web 의 멀티파트 업로드 추상화 예요 (org.springframework.web.multipart.MultipartFile). HTTP 요청의multipart/form-data한 칸에 들어온 파일 을 다루는. 컨트롤러 진입 부분 에 한정돼 있어요.두 개를 한 줄로 정리하면 —
MultipartFile은 HTTP 입구의 자루,Resource는 서비스/비즈니스 로직 안의 자루. 그래서 — 컨트롤러에서MultipartFile을 받아 → 서비스로 넘기기 전에Resource로 변환 하는 단계이 반드시 한 부분 등장해요. 그 변환 부분 가 — Step 4 의VoiceTranscriptionController에서 만나게 될 이에요. 오늘 서비스 부분 는 이미 변환된 Resource 만 받는 방식으로 깔끔하게 정리해두고, 변환의 책임은 컨트롤러에 두는 분리예요.
💡 서비스가 자매 추상화대로 도는지 (Resource 가 통째로 모델에 전달되는지 · 응답 텍스트가 그대로 흘러나오는지 · null 응답에서 빈 문자열로 폴백하는지 · 입력 가드가 VoiceException 을 던지는지) 는 코드베이스의
VoiceTranscriptionServiceTest가 4 시나리오로 검증해뒀어요.
5. 다음 부분로
자, 서비스 빈 은 익히셨어요. Resource 하나를 받아 — 자매 추상화의 한 줄을 통과시키고 — 텍스트 한 줄을 반환 하는 그림. 이게 5 단 파이프라인의 가장 핵심 노드 예요.
다음 부분은 — 사용자가 마이크로 녹음한 webm Blob 이 → MultipartFile 로 백엔드에 도착하고 → Resource 로 변환되어 → 이 서비스에 흘러들어가는. 컨트롤러 곳이에요. 오늘 정리한 서비스가 — HTTP 입구와 어떻게 만나는가 이 Step 4 의 감각입니다.
Step 4. `VoiceTranscriptionController` — 마이크 음성을 받아내는 첫 엔드포인트 (약 25분)
자, Step 3 에서 서비스 빈 이 들어왔으니 — 이제 그 빈 앞에 HTTP 입구를 다는 모습이에요. 지난 시간 (Day 8) 우리는 VisionUploadController에서 이미지 MultipartFile 을 받아 정적 URL 로 박는 그림을 손에 정리했죠. 오늘은.
오디오 MultipartFile 을 받아 STT 모델로 흘려보내는으로 한 모달리티만 옆으로 슬쩍 옮긴 자리이에요. 지난 시간과 같은 호흡 의 세 번째 등장 예요.
본 Step 의 미션을 한 줄로 정리해두면 — "브라우저 MediaRecorder 가 흘려보낸 오디오 멀티파트 업로드 하나를 받아, 검증 → Resource 변환 → 서비스 위임 → ApiResponse 래핑 의 4 단 파이프라인을 한 메서드 안에 응축한다." 지난 시간 익숙해진이 —
오디오 도메인에서 한 박자 더 또렷해지는 곳이에요.
1. VoiceTranscriptionController — 전체 코드 하나
먼저 완성된 방식 을 한 번에 들여다볼게요. Day 8 의 VisionUploadController 와 호흡이 같아요 — 검증 4 단 + Resource 변환 + 서비스 위임 + ApiResponse 래핑.
package kr.spartaclub.aifriends.voice.controller;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.voice.dto.VoiceTranscriptionResponse;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import kr.spartaclub.aifriends.voice.service.VoiceTranscriptionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
import java.util.Set;
/**
* Day 9 Step 4 — 음성 파일 업로드 → STT 변환 엔드포인트.
*
* <p>{@code multipart/form-data} 의 {@code audio} 파트로 받은 음성 파일을 검증하고
* {@link VoiceTranscriptionService} 에 흘려보낸다. {@link MultipartFile#getResource()} 가
* Spring 내장 {@code MultipartFileResource} 를 돌려주므로, Spring AI 의
* {@code AudioTranscriptionPrompt(Resource)} 와 자연스럽게 연결된다.</p>
*
* <p>응답은 {@link ApiResponse} 로 래핑한다 (본 강의의 ApiResponse 표준 패턴). 검증 실패는 {@link VoiceException}
* 으로만 던지고 {@code GlobalExceptionHandler} 가 표준 에러 응답으로 변환한다.</p>
*
* <p>허용 확장자는 OpenAI Audio Transcription API 의 공식 지원 목록을 그대로 따른다
* (mp3, mp4, mpeg, mpga, m4a, wav, webm — gpt-4o-transcribe · gpt-4o-mini-transcribe · whisper-1
* 모두 동일 엔드포인트라 같은 자루를 받는다). 학습 단순도를 위해 파일명 확장자만 보고 거른다.
* 최대 크기는 10MB — OpenAI 의 25MB 한도보다 보수적으로 잡아 학생 실습 비용을 억제한다.</p>
*/
@Slf4j
@RestController
@RequestMapping("/api/voice")
public class VoiceTranscriptionController {
/** OpenAI Audio Transcription API 가 공식적으로 받아들이는 오디오 확장자. */
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"
);
/** 학습용 최대 업로드 크기 — 10MB. OpenAI 한도(25MB) 보다 보수적. */
private static final long MAX_BYTES = 10L * 1024 * 1024;
private final VoiceTranscriptionService voiceTranscriptionService;
public VoiceTranscriptionController(VoiceTranscriptionService voiceTranscriptionService) {
this.voiceTranscriptionService = voiceTranscriptionService;
}
@PostMapping("/transcribe")
public ResponseEntity<ApiResponse<VoiceTranscriptionResponse>> transcribe(
@RequestParam("audio") MultipartFile audio) {
validate(audio);
Resource resource = audio.getResource();
String text = voiceTranscriptionService.transcribe(resource);
log.info("[VoiceTranscription] success: filename={}, size={}, textLength={}",
audio.getOriginalFilename(), audio.getSize(), text.length());
return ResponseEntity.ok(ApiResponse.success(new VoiceTranscriptionResponse(text)));
}
/**
* 업로드 검증.
*
* <ol>
* <li>비어 있으면 {@link ErrorCode#VOICE_AUDIO_REQUIRED}</li>
* <li>확장자 화이트리스트에 없으면 {@link ErrorCode#VOICE_AUDIO_FORMAT_INVALID}</li>
* <li>10MB 초과면 {@link ErrorCode#VOICE_AUDIO_TOO_LARGE}</li>
* </ol>
*/
private void validate(MultipartFile audio) {
if (audio == null || audio.isEmpty()) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
}
String extension = extractExtension(audio.getOriginalFilename());
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
}
if (audio.getSize() > MAX_BYTES) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
}
}
/**
* 파일명에서 확장자만 떼어내 소문자로 돌려준다. 점이 없거나 마지막 점 뒤가 비어 있으면 null.
*/
private String extractExtension(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) return null;
return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}
자, 이 코드가 왜 이렇게 들어갔는지 한 부분씩 짚어볼게요.
2. 두 상수 — 학습용 화이트리스트 의 흐름
가장 위에 들어간 두 상수가 본 컨트롤러의 정책 결정 부분 예요.
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"
);
private static final long MAX_BYTES = 10L * 1024 * 1024;
(1) ALLOWED_EXTENSIONS — OpenAI STT 공식 7 자루 그대로
Step 2 의 포맷 한 줄 에서 정리해둔을 기억하시죠? — mp3, mp4, mpeg, mpga, m4a, wav, webm 7 가지가 OpenAI Audio Transcription API 가 받아주는 표준 자루였어요 (gpt-4o-transcribe · whisper-1 모두 동일).
그 공식 목록 을 그대로 코드 상수로 정리했어요. 우리가 임의로 줄이지도, 늘리지도 않았다는 곳이에요. 프로바이더의 한계가 — 우리 도메인의 한계로 그대로 옮겨오는.
Set.of(...) 한 줄로 불변 Set 을 만들었어요. 런타임에 누군가 add 하지 못하게 정리해둔이고요. 상수의 본질이 「변하지 않는 값」 이라는 진행을 자료구조 자체로 신호하는 부분이에요.
(2) MAX_BYTES = 10L * 1024 * 1024 — 학습 비용 억제 의 보수적 결
OpenAI Audio Transcription API 의 공식 한도는 25MB. 그런데 우리는 10MB 로 잘라뒀어요. 공식 한도보다 60% 더 보수적인 모습이에요. 왜 이렇게 좁게 잘랐는가 — 이게 본 Step 의 결정적 정책 한 줄 자리이에요.
1 분짜리 mp3 음성 ≈ 약 1MB, 10 분짜리 ≈ 약 10MB. 즉 우리 한도는 대략 10 분 분량 의 음성을 받아낼 수 있어요. 학생 실습용으로는 충분히 넉넉한 모습이에요. 그런데 — 25MB 까지 열어두면 학생 한 명이 실수로 30 분짜리 회의 녹음 을 던지는 진행이 한 번이라도 생기면. 수업 한 번에 수천 원이 빠지는 사고가 박힐 수 있어요. 학습 비용을 가두는 게 — 학생을 보호하는 진행 곳이에요.
🙋 한 학생의 날카로운 질문
"튜터님, 왜 25MB 가 아니라 10MB 로 잘랐어요? OpenAI 공식 한도까지 열어두면 더 자연스러울 것 같은데요."
정확히 핵심을 찔러주셨어요. 답은 두에 들어가 있어요.
첫째 — 학습 비용 억제 의 관점. 위에서 짚었듯, 10MB 면 약 10 분 분량 이에요. 학생 실습에서 한 번에 던질 음성은 길어야 수십 초 라 — 10MB 도 사실 굉장히 넉넉한 한도예요. 공식 한도까지 열어두는 건 — 학습용으론 과도한 신뢰 예요. 실수로 큰 파일을 던졌을 때 — 검증 한 줄이 미리 막아주는 방식이 비용을 가두는 자리고요.
둘째 — 운영 환경에선 더 보수적이어야 한다는 점. 실무 코드라면 — 10MB 도 큰 편 이에요. 일반적으로 Voice Memo 하나는 1~3MB 가 평균. 운영 환경에선 3~5MB 로 더 좁히는 방식이 흔해요. 프로바이더의 공식 한도는 — 실무의 한도가 아니에요. 공식 한도는 프로바이더가 받아줄 수 있는 최대치, 우리 한도는 우리 도메인이 받아낼 적정치 — 두 자리는 다른 결정 이에요. 본 강의는 학습용 + 운영 감각 의 방식으로 — OpenAI 공식 한도의 절반 이하인 10MB 를 정리해뒀어요.
한 줄로 정리하면 — 프로바이더가 받아줄 수 있다고 우리가 받아야 하는 건 아니다. 우리 도메인의 한도는 우리가 다시 결정 해요.
3. transcribe 메서드 — 4 단 파이프라인의 한 응축
이 컨트롤러의 핵심 하나 가 바로 이 메서드예요. 4 단 파이프라인 이 5 줄 안에 응축돼 있어요.
@PostMapping("/transcribe")
public ResponseEntity<ApiResponse<VoiceTranscriptionResponse>> transcribe(
@RequestParam("audio") MultipartFile audio) {
validate(audio);
Resource resource = audio.getResource();
String text = voiceTranscriptionService.transcribe(resource);
log.info("[VoiceTranscription] success: filename={}, size={}, textLength={}",
audio.getOriginalFilename(), audio.getSize(), text.length());
return ResponseEntity.ok(ApiResponse.success(new VoiceTranscriptionResponse(text)));
}
(1) @PostMapping("/transcribe") + @RequestParam("audio") MultipartFile audio
URL 은 — /api/voice/transcribe (클래스 위 @RequestMapping("/api/voice") 와 합쳐져요). HTTP 메서드는 POST (멀티파트 업로드는 항상 POST 흐름). 파라미터 이름이 audio 라는 진행이 중요해요.
Step 2 의 마이크 녹음 스니펫 끝부분의 form.append('audio', blob, 'recording.webm'); 한 줄과 정확히 일치 하는 키예요. 프론트와 백엔드의 멀티파트 키가 같은 단어 로 묶여야 — MultipartFile 이 자루를 받아낼 수 있거든요.
(2) validate(audio) — 검증 한 줄
검증 로직 자체는 다음 절에서 펼치고요. 컨트롤러 본문에서 봐야 할 흐름 은 — 검증이 가장 첫 줄에 들어가 있다 는 자리이에요. 어떤 비즈니스 로직보다 먼저 검증을 흘려서, 부적합 자루는 서비스 빈에 들어가지도 않게 막아두는.
(3) audio.getResource() — HTTP 자루 → 비즈니스 자루 변환의 한 줄
Resource resource = audio.getResource();
이 한 줄이 Step 3 의 학생 질문 의 답이에요.
MultipartFile 은 HTTP 입구의 자루, Resource 는 서비스 안의 자루 — 두 개를 잇는 변환 이 바로 이 한 줄이에요.
Spring 이 내부적으로 MultipartFileResource 라는 내장 어댑터 를 만들어 돌려줘요.
우리가 손으로 변환 코드를 짤 필요가 없는 결이 Spring 의 감각 이에요.
이 한 줄이 또 결정적인 부분은 — Spring AI 의 AudioTranscriptionPrompt(Resource) 와 자연스럽게 잡히는 점이에요. 컨트롤러 → 서비스 → Spring AI 의 세 층을 같은 Resource 인터페이스 가 흘러 — 변환 손실 없이 자루가 흐르는 그림이에요.
(4) voiceTranscriptionService.transcribe(resource) — 서비스 위임 한 줄
Step 3 에서 정리해둔 빈에 그대로 위임 해요. 컨트롤러는 비즈니스 로직을 직접 들고 있지 않아요. HTTP 입구의 검증 + 응답 래핑만 책임지고, 진짜 일 은 서비스가 한다는. 책임 분리 (Layered Architecture) 의 감각이에요.
(5) log.info(...) — 관찰성 한 줄
log.info("[VoiceTranscription] success: filename={}, size={}, textLength={}",
audio.getOriginalFilename(), audio.getSize(), text.length());
성공에 세 가지 자루 를 한 줄 찍어요 — 원본 파일명, 업로드 크기, 인식된 텍스트 길이. 비용 추적용 자료 예요.
운영 환경에서 — 어느 사용자가 얼마나 큰 음성을 던졌고, 얼마나 긴 텍스트가 떨어졌는가 를 시계열로 쌓아두면 비용 패턴 분석 에 그대로 쓸 수 있어요.
내용 (transcription text) 자체는 찍지 않는 도 — PII (개인정보) 보호 흐름의 감각이에요.
(6) ApiResponse.success(new VoiceTranscriptionResponse(text)) — ApiResponse 표준 패턴의 세 번째 등장
return ResponseEntity.ok(ApiResponse.success(new VoiceTranscriptionResponse(text)));
여기가 본 강의의 표준 응답 패턴이 또 한 번 회수 되는 모습이에요.
Day 4-1 에서 들어간 "새로 만드는 컨트롤러의 정상 응답은 반드시 ApiResponse<T> 로 래핑한다" 는 규약 — Day 7 (이미지) → Day 8 (Vision) → Day 9 (Voice) 까지 세 번째로 다시 만나는 부분예요.
raw 객체나 record 직접 반환 금지 의.
왜 이 규약이 결정적 인지 한 줄 더 정리해두면 — GlobalExceptionHandler 가 에러 응답을 자동으로 ApiResponse.fail(ErrorResponse) 로 감싸기 때문에, 정상 응답을 raw 로 두면 같은 엔드포인트의 정상/에러 응답 형태가 비대칭 이 돼요.
학생들이 응답 표준을 학습할 때 일관된 패턴이 보여야 하거든요.
4. VoiceTranscriptionResponse — 한 필드 record 의 흐름
응답 DTO 는 놀랍도록 짧아요.
package kr.spartaclub.aifriends.voice.dto;
/**
* Day 9 Step 4 — STT 응답 DTO.
*
* <p>{@code POST /api/voice/transcribe} 의 정상 응답 본문에 들어가는 record. 학생이 처음 마주치는
* "음성 → 텍스트" 변환 결과이므로 일부러 필드를 한 개({@code text})만 둔다. 추후 confidence/segments
* 같은 부가 정보가 필요해지면 필드를 추가해 확장한다.</p>
*
* @param text STT 모델이 인식한 텍스트. 비어 있는 음성이거나 모델이 아무것도 인식하지 못하면 빈 문자열.
*/
public record VoiceTranscriptionResponse(
String text
) {
}
record 한 줄에 필드 하나만. 이 짧음 이 의도예요. 학생이 처음 마주치는 「음성 → 텍스트」 변환 결과 라 — 일부러 필드를 한 개로 두고, 추후 confidence (신뢰도) · segments (구간 분리) 같은 부가 정보 가 필요해지면 그때 필드를 추가 하는 방식으로 정리해뒀어요. 기본은 단순하게, 확장은 필요할 때 —
의 감각이에요.
학생 입장에선 — $.data.text 하나로 모든 게 풀린다는 진행이 응답 구조의 결정적 이정표 예요. 프론트의 await res.json().data.text 한 줄이면 — STT 결과가 들어와요.
5. validate(MultipartFile) — 4 단 검증
transcribe 메서드의 첫 줄 validate(audio) 가 호출하는 진짜 일 자리이에요. 4 가지 시나리오를 순차로 흘려 검증해요.
private void validate(MultipartFile audio) {
if (audio == null || audio.isEmpty()) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
}
String extension = extractExtension(audio.getOriginalFilename());
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
}
if (audio.getSize() > MAX_BYTES) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
}
}
(1) null / empty — VOICE_AUDIO_REQUIRED (VC001)
가장 먼저 — 자루가 없거나 비어 있는지 검증.
audio == null 은 멀티파트 파라미터 자체가 없는 부분, audio.isEmpty() 는 자루는 왔는데 안에 0 byte 인.
두 개 모두 같은 ErrorCode 로 묶었어요 — 사용자 입장에선 어느 쪽이든 "음성 파일을 첨부해 주세요" 라는 같은 메시지를 받는 게 자연스러우니까요.
이 코드는 — Step 3 에서 정리한 VC001 의 컨트롤러 지점에서의 회수 예요.
(2) 확장자 화이트리스트 — VOICE_AUDIO_FORMAT_INVALID (VC002)
String extension = extractExtension(audio.getOriginalFilename());
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
}
파일명의 확장자만 떼어 ALLOWED_EXTENSIONS Set 과 매칭. .txt, .pdf, .png 같은 자루는 여기서 컷. extractExtension 은 클래스 맨 아래 에 들어간 작은 헬퍼예요 —
마지막 점 뒤의 글자를 소문자로 떼어내요. 대소문자 구분 없이 (.MP3 도 OK) 매칭되도록 Locale.ROOT 로 lowercase 정리해둔이고요.
솔직한 방식 한 줄 — 파일명 확장자만 보고 거르는 게 완벽한 검증은 아니에요. 누군가
evil.exe의 이름을evil.mp3로 바꿔 던지면 — 이 검증은 통과해요. 진짜 컨텐츠 타입 검증 까지 하려면 — 파일 매직 넘버 (magic bytes) 를 봐야 하고, 그건 Apache Tika 같은 라이브러리 가 들어가야 해요. 본 강의는 학습 단순도 를 위해 확장자만 보고 — 프로바이더 (OpenAI STT) 가 알아서 거르는 방식을 신뢰해요. 운영 환경에선 — Tika 하나를 추가 하는 진행이 자연스러운 다음 단계예요.
(3) 크기 — VOICE_AUDIO_TOO_LARGE (VC003)
if (audio.getSize() > MAX_BYTES) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
}
마지막 — 10MB 초과면 컷. MultipartFile.getSize() 가 byte 단위 long 을 돌려줘요. 이 검증이 위에서 정리해둔 「학습 비용 억제」 의 진짜 실행 부분 예요.
(4) VC004 (VOICE_TRANSCRIPTION_FAILED) 의 부분은 — Step 5 또는 Step 7 에서 회수
ErrorCode 에 정리해둔 4 종 중 — 오늘 컨트롤러는 3 종 (VC001, VC002, VC003) 만 회수하고, VC004 (음성 인식 실패) 는 STT 호출 자체가 실패할 때 의 부분라 — Step 5 의 STT 통합 시연 또는 Step 7 의 운영 흐름 에서 본격적으로 다시 다룰게요.
오늘 컨트롤러는 입력 검증 부분 만 깔끔하게 정리해두에요.
6. 진입점 — 프론트 ↔ 컨트롤러 ↔ 검증 키워드 한 줄 정리
컨트롤러를 닫기 전에 한 줄짜리 정리 만 정리할게요. 멀티파트 키 "audio" 는 세에 똑같이 들어가야이 닫혀요.
프론트 (form.append('audio', ...)) → 컨트롤러 (@RequestParam("audio")) → 통합 테스트의 MockMultipartFile("audio", ...) 의 세. 키 한 글자가 어긋나면 멀티파트 매칭이 깨져 400 도 아닌 지저분한 5xx 가 떨어져요.
💡 컨트롤러가 약속대로 흐르는지 (정상 mp3·wav 업로드 → 200 +
$.data.text· 빈 파일 → VC001 · 미지원 확장자 → VC002 · 10MB+1 byte 경계값 → VC003) 는 코드베이스의VoiceTranscriptionControllerTest가 5 시나리오로 검증해뒀어요. Day 8VisionUploadControllerTest와 같은 호흡 으로 슬라이스 부팅 + GlobalExceptionHandler import 패턴이 들어가 있어 예외 응답이ApiResponse.fail로 자동 변환되는 진행 까지 함께 검증돼요.
💡 튜터의 결론 — Step 4 한 줄
MultipartFile.getResource()의 한 줄 이 — HTTP 멀티파트 자루를 Spring AI 의AudioTranscriptionPrompt(Resource)와 자연스럽게 잇는 다리예요. 컨트롤러는 검증과 응답 래핑만, 비즈니스 로직은 서비스에 — 책임 분리 이 지난 시간 (Day 8VisionUploadController) 와 완전히 같은 호흡 으로 흐르고, ApiResponse 표준 패턴 + 도메인별 ErrorCode + WebMvcTest + GlobalExceptionHandler import 의 네 감각이 세 번째로 다시 만나는 부분. 마이크 음성 → 백엔드의 첫 진입 단계이 들어갔다.
7. 다음으로 — 듣는 빈 의 백엔드 부분이 닫혔다
자, 정리해보면 — 오늘 4 개 Step 을 거쳐 듣는 빈 (TranscriptionModel) 의 백엔드 곳이 완전히 닫혔어요.
- Step 1 — 라인업 매트릭스 (어느 프로바이더로 갈지)
- Step 2 — 자매 추상화 시그니처 (
TranscriptionModel·TextToSpeechModel의 모양) - Step 3 —
VoiceTranscriptionService(Resource → 텍스트의 비즈니스 로직) - Step 4 —
VoiceTranscriptionController(HTTP 입구의 검증 + Resource 변환 + 응답 래핑)
5 단 파이프라인 중 왼쪽 절반 (마이크 → STT → 텍스트) 이 익히셨어요.
다음은 — 오른쪽 절반 부분이에요. 말하는 빈 (TextToSpeechModel) 의. 텍스트 한 줄을 던져서 → 음성 byte[] 가 떨어지고 → 브라우저의 <audio> 태그가 받아 재생하는. 5 단 파이프라인의 오른쪽이 닫히는 곳이에요.
Step 5 에서 말하는 빈 의 시그니처를 손에 잡고, 텍스트를 음성 byte[] 로 풀어내는 자리를 펼쳐봅시다.
Step 5. `VoiceSynthesisService` — 말하는 빈, 거울처럼 대칭의 모습 (약 25분)
자, 이제 오른쪽 절반 의 첫 자루를 익힐 차례예요.
Step 2 에서 우리는 자매 추상화 두 개 를 한 번에 머리에 정리했죠.
왼쪽이 듣는 빈 (TranscriptionModel), 오른쪽이 말하는 빈 (TextToSpeechModel).
그 오른쪽 빈 의 1.1.x 정확한 이름이 TextToSpeechModel 이에요.
그리고 — 그 빈을 직접 호출하는 부분 가 바로 오늘 이 Step 이에요.
Step 3 의 VoiceTranscriptionService 와 비교하면 입출력의 방향이 정반대 예요.
그쪽은 오디오 byte[] → 텍스트 였고, 이쪽은 텍스트 → 오디오 byte[].
그런데 코드의 모양은 거의 거울처럼 대칭 자리이에요.
같은 자매 추상화 패턴 이 두 번째로 박히는 지점이기 때문에 — 손이 한 번에 들어옵니다.
1. 말하는 빈 의 정확한 시그니처 — TextToSpeechModel
먼저 우리가 호출할 빈 의 모양부터 한 번 더 짚을게요.
Spring AI 1.1.x 의 음성 합성 추상화는 org.springframework.ai.audio.tts.TextToSpeechModel 이에요.
내부적으론 Model<TextToSpeechPrompt, TextToSpeechResponse> 의을 그대로 따르고, 추가로 StreamingTextToSpeechModel 까지 확장한 인터페이스예요.
Day 2 의 Model<P, R> 자매 추상화가 — 세 번째 모달리티 (청각 출력) 에서 다시 등장되는 모습이에요.
호출 진행은 이래요.
| 단계 | 타입 |
|---|---|
| 입력 | TextToSpeechPrompt(String text) — 내부적으로 TextToSpeechMessage(text) 로 감싸짐 |
| 호출 | textToSpeechModel.call(prompt) |
| 응답 | TextToSpeechResponse |
| 결과 1단계 추출 | response.getResult() → Speech |
| 결과 2단계 추출 | speech.getOutput() → byte[] |
총 5단 변환 자리이에요. 문자열 한 줄이 → 프롬프트로 감싸지고 → 모델 호출로 흘러가고 → 응답이 떨어지고 → Speech 객체에서 byte[] 가 빠져나오는. 한 메서드 안에서 5단 변환이 직선으로 흐르는 단계이에요.
그리고 자동 등록 은 — Spring AI OpenAI 스타터에 들어가 있는 OpenAiAudioSpeechAutoConfiguration 이 처리해줘요.
우리가 따로 빈을 등록할 필요 없이, 클래스패스에 OpenAI 스타터만 있으면 기본 모델 tts-1 · 포맷 mp3 · 보이스 alloy 로 빈이 자동으로 떠 있어요.
Day 2 의 자동 구성 패턴 그대로이에요.
2. VoiceSynthesisService — 거울처럼 대칭의 한 클래스
자, 이제 서비스 본체 를 한 번에 펼쳐 봅시다. 코드를 먼저 흘려보고 아래에서 한 줄씩 풀어볼게요.
package kr.spartaclub.aifriends.voice.service;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.audio.tts.Speech;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.audio.tts.TextToSpeechResponse;
import org.springframework.stereotype.Service;
/**
* Day 9 Step 5 — Voice (TTS, Text-to-Speech) 도메인 진입점.
*
* <p>텍스트 한 문장을 받아 {@link TextToSpeechModel} 에 전달하고 합성된 오디오 바이트를
* 그대로 돌려준다. {@link TextToSpeechModel} 은 인터페이스로만 주입받으므로
* (프로바이더 추상화 원칙), 빈은 OpenAI tts-1 · 추후 다른 TTS 프로바이더 어떤 것이든
* 될 수 있다 — 호출자는 모른다. 활성 TTS 프로바이더는 {@code application.yml} 의
* {@code spring.ai.model.audio.speech} 프로퍼티로만 결정된다.</p>
*
* <p>응답 byte[] 가 null 이면 빈 byte[] 를 반환한다 (Step 3 {@code VoiceTranscriptionService}
* 와 동일한 방어 패턴). null/빈 텍스트 등 명백한 입력 오류는 {@link VoiceException} 으로
* 래핑해 {@code GlobalExceptionHandler} 가 ApiResponse.fail 형태로 변환하게 한다.</p>
*
* <p>실제 TTS 호출은 텍스트 LLM 대비 토큰 비용이 크다 (음성/이미지/비디오 비용 급등 경고). 단위 테스트는
* Mockito 모킹으로만 검증하고, 실제 호출 smoke 는 강사가 수동으로 한다.</p>
*/
@Slf4j
@Service
public class VoiceSynthesisService {
private final TextToSpeechModel textToSpeechModel;
public VoiceSynthesisService(TextToSpeechModel textToSpeechModel) {
this.textToSpeechModel = textToSpeechModel;
}
/**
* 텍스트를 TTS 모델에 보내 합성된 오디오 바이트를 반환한다.
*
* @param text 합성할 텍스트 (null/빈 문자열 금지)
* @return 합성된 오디오 byte[] (응답이 비어 있으면 빈 byte[])
* @throws VoiceException text 가 null 또는 공백뿐인 경우 (VOICE_TEXT_REQUIRED)
*/
public byte[] synthesize(String text) {
if (text == null || text.isBlank()) {
throw new VoiceException(ErrorCode.VOICE_TEXT_REQUIRED);
}
TextToSpeechResponse response = textToSpeechModel.call(new TextToSpeechPrompt(text));
if (response == null || response.getResult() == null) {
return new byte[0];
}
Speech speech = response.getResult();
byte[] audio = speech.getOutput();
return audio == null ? new byte[0] : audio;
}
}
잠깐 — 교안 본문은 학습 단순도 를 위해 핵심 5 단 변환만 남긴 압축본 입니다. 여러분이 받아 가신 코드베이스의 실제
VoiceSynthesisService에는 한 겹 더 가 박혀 있어요 —. 생성자에@Value("${spring.ai.model.audio.speech}")로 활성 프로바이더 식별자를 같이 주입 + mood key (bright/warm/calm/cheerful) → voice 식별자 매핑 표 두 자루 (OpenAI 용 / ElevenLabs 용) +synthesizeWithMeta(text, voice)가 합성 결과 + 사용된 프로바이더/voice 메타를 한 record 로 반환. 이게 — 오늘 마무리 섹션에서.env한 줄로 OpenAI ↔ ElevenLabs swap 되는 토대 가 됩니다. 여기 본문에서는 코어 한 줄 만 익히고, swap 토대의 전체 풍경 은 마무리에서 한 번에 펼칠게요.
첫째 박자 — 의존성 주입의. 클래스 위에는 @Service 만 들어가 있고, 생성자는 명시 생성자 로 TextToSpeechModel 하나를 받아요. Lombok 의 @RequiredArgsConstructor 가 아니라 명시 생성자 인 이유는.
학생이 한눈에 「이 빈은 TextToSpeechModel 하나에만 의존한다」 는 그림을 보게 하기 위함 곳이에요. Day 2 의 프로바이더 추상화 원칙 이 여기서도 그대로 살아있어요. 구체 타입 OpenAiAudioSpeechModel 로 주입받지 않고 인터페이스로만 —
그래서 프로바이더는 .env 한 줄로 스위칭 되는 진행이 유지돼요.
둘째 박자 — 가드 한 줄. 메서드 진입 직후 text == null || text.isBlank() 한 줄이 들어가 있어요. null 도, 빈 문자열도, 공백뿐인 문자열 (" ") 도 한꺼번에 걸러내는. 그리고.
VoiceException(ErrorCode.VOICE_TEXT_REQUIRED) 한 줄로 던지면 끝이에요.
Step 6 컨트롤러까지 흘려보내지 않고 서비스 진입점에서 컷 부분이에요.
Day 8 의 VisionService.describe 가드 와 Step 3 의 VoiceTranscriptionService.transcribe 가드 와 — 완전히 같은 호흡이에요.
셋째 박자 — 5단 변환 한 줄씩.
TextToSpeechResponse response = textToSpeechModel.call(new TextToSpeechPrompt(text));
이 한 줄이 — 5단 변환의 가운데 3단을 한 번에 묶어요. 텍스트를 TextToSpeechPrompt 로 감싸고, call 로 호출하고, 응답을 받아 변수에 담는. Spring AI 의 자매 추상화가 가장 빛나는 한 줄 이에요. 프로바이더가 OpenAI 든 다른 무엇이든 — 이 한 줄은 안 바뀌어요.
if (response == null || response.getResult() == null) {
return new byte[0];
}
Speech speech = response.getResult();
byte[] audio = speech.getOutput();
return audio == null ? new byte[0] : audio;
뒤의 두 줄짜리 추출 이 — response.getResult() 로 Speech 를 빼내고, speech.getOutput() 으로 오디오 byte[] 를 꺼내요. 그리고 세 군데에 null 안전 폴백 이 들어가 있어요.
response 자체가 null 이거나, result 가 null 이거나, audio byte[] 가 null 이거나. 어느에서 비어 있어도 빈 byte[] 로 폴백 해요. 예외를 던지지 않고 빈 자루를 반환 하에요. 컨트롤러가 Content-Length: 0 으로 흘려보내면 그만 이고, 상위 레이어가 별도 분기 없이 똑같이 처리 할 수 있어요.
Step 3 의 text == null ? "" : text 와 완전히 같은 방어 패턴 모습이에요.
💡 튜터의 결론 — 거울처럼 대칭의 모양
Step 3 의
VoiceTranscriptionService와 이 Step 5 의VoiceSynthesisService를 나란히 놓고 보면 코드가 거울처럼 대칭 자리이에요.입력/출력의 방향만 정반대일 뿐, 의존성 주입 · 가드 · 호출 한 줄 · null 폴백 의 네 가지 감각이 완전히 같은 방식으로 들어 있죠. 같은 자매 추상화 패턴이 두 번째로 들어간 곳 — 손이 한 번에 들어와요.
3. 듣는 빈 ↔ 말하는 빈 — 한 표로 정리하는 대칭
자, 얼마나 거울처럼 대칭인지 를 — 한 표로 펼쳐 볼게요.
| 항목 | VoiceTranscriptionService (Step 3) |
VoiceSynthesisService (Step 5) |
|---|---|---|
| 인터페이스 | TranscriptionModel |
TextToSpeechModel |
| 입력 타입 | Resource (audio bytes) |
String (text) |
| 호출 한 줄 | model.call(new AudioTranscriptionPrompt(audio)) |
model.call(new TextToSpeechPrompt(text)) |
| 응답 결과 | String (transcribed text) |
byte[] (audio bytes) |
| 가드 ErrorCode | VC001 VOICE_AUDIO_REQUIRED |
VC005 VOICE_TEXT_REQUIRED |
| 자동 등록 | OpenAiAudioTranscriptionAutoConfiguration |
OpenAiAudioSpeechAutoConfiguration |
| null 폴백 | text == null ? "" : text |
audio == null ? new byte[0] : audio |
일곱 줄의 모양 이 — 완벽히 좌우 대칭 부분이에요. Spring AI 가 자매 추상화를 얼마나 균형 있게 정리해뒀는지 가 한눈에 보이죠. 입력만 「오디오 ↔ 텍스트」 로 뒤집고, 출력만 「텍스트 ↔ 오디오」 로 뒤집으면 — 나머지는 같은 방식 곳이에요.
4. ErrorCode 추가분 — VC005 · VC006 두 개
서비스가 던지는 VOICE_TEXT_REQUIRED 가 어디 사는 지 보여드릴게요. kr.spartaclub.aifriends.common.exception.ErrorCode 의 Voice 섹션을 보면 — Step 3 의 VC001~VC004 뒤에 VC005 · VC006 두 줄이 새로 들어가 있어요.
// Voice / STT (Day 9)
VOICE_AUDIO_REQUIRED(HttpStatus.BAD_REQUEST, "VC001", "음성 파일을 첨부해 주세요."),
VOICE_AUDIO_FORMAT_INVALID(HttpStatus.BAD_REQUEST, "VC002", "지원하지 않는 음성 파일 형식입니다. (mp3, mp4, mpeg, mpga, m4a, wav, webm)"),
VOICE_AUDIO_TOO_LARGE(HttpStatus.BAD_REQUEST, "VC003", "음성 파일 크기가 허용 한도(10MB)를 초과했습니다."),
VOICE_TRANSCRIPTION_FAILED(HttpStatus.BAD_GATEWAY, "VC004", "음성 인식에 실패했습니다."),
VOICE_TEXT_REQUIRED(HttpStatus.BAD_REQUEST, "VC005", "음성 합성을 위한 텍스트를 입력해 주세요."),
VOICE_SYNTHESIS_FAILED(HttpStatus.BAD_GATEWAY, "VC006", "음성 합성에 실패했습니다.");
두 개이 달라요.
VC005 — VOICE_TEXT_REQUIRED. 상태 400 BAD_REQUEST. 명백한 입력 오류 (텍스트 없음) 모습이에요. 클라이언트의 잘못이니 클라이언트한테 돌려보내는. 방금 본 synthesize 의 첫 가드 에서 던지고 있죠.
VC006 — VOICE_SYNTHESIS_FAILED. 상태 502 BAD_GATEWAY. 외부 TTS 프로바이더가 실패했을 때 모습이에요. 우리 잘못이 아니라 외부 서비스 측 문제 라는. 지금 이 Service 코드에서는 — 안 던져요. 그럼 왜 미리 정리해뒀냐? —
Step 6 컨트롤러에서 다시 다룰 부분 이기 때문이에요.
Step 3 의 VC004 VOICE_TRANSCRIPTION_FAILED 가 컨트롤러까지 흘러간 흐름 그대로 — VC006 도 Step 6 컨트롤러에서 다시 등장 돼요. 서비스가 비어 있는 byte[] 를 반환했을 때 → 컨트롤러가 「합성 실패」 로 분기 하는. 그 부분은 Step 6 에서 만나요.
지금은 「ErrorCode 자루 두 개가 미리 들어가 있다」 는 그림만 머리에 두면 됩니다.
5. TextToSpeechPrompt 의 instructions 자루 — Step 3 와 정확히 같은 패턴
TextToSpeechPrompt 의 instructions 는 내부적으로 TextToSpeechMessage 인데, 거기서 .getText() 로 원본 문자열을 빼내요.
Step 3 의 AudioTranscriptionPrompt.getInstructions() 가 Resource 를 빼내는 결과 — 정확히 같은 패턴 이에요. 듣는 빈은 Resource 자루를, 말하는 빈은 String 자루를 같은 시그니처로 감싸요.
💡 서비스가 약속대로 흐르는지 (텍스트가 통째로
TextToSpeechPrompt에 전달되는지 · 응답 byte[] 가 null 이면 빈 byte[] 폴백 · null 입력은VOICE_TEXT_REQUIRED· 공백만 있는 문자열도isBlank()로 잡혀 같은 코드) 는 코드베이스의VoiceSynthesisServiceTest가 4 시나리오로 검증해뒀어요. 학생이isEmpty()와isBlank()를 헷갈려 한 글자만 잘못 정리하면 그 테스트가 깨져요 — 공백 가드의 감각이 거기서 들어가요.
6. 솔직한 트레이드오프 — 추상화는 OpenAI 스타터, 실제 호출은 무료 대안
자, 솔직한 트레이드오프 를 한 박스로 짚고 넘어갈게요. Step 1 의 「추상화는 OpenAI 스타터 / 실제 호출은 무료 옵션」 결정 이 — 오늘 이 Step 에서 세 번째로 회수 되는 자리이에요. (Step 1 — 라인업 매트릭스, Step 3 — STT, Step 5 — TTS.)
우리가 방금 정리한 코드 는 — TextToSpeechModel 인터페이스로 추상화돼 있고, 자동 구성에 의해 기본 빈은 OpenAI tts-1 으로 떠요.
이대로 실제 호출하면 — 돈이 듭니다. OpenAI tts-1 기준으로 1,000 자당 약 $0.015 정도. 한 학생이 짧은 문장 100 번 만 시연해도 $1.5 ~ $3 정도가 사라지는 자리이에요.
그래서 본 강의의 디폴트 호흡 은 이래요.
테스트는 모킹으로 0 원. 방금 본 4 개 시나리오 가 전부 Mockito 모킹 곳이에요. 실제 OpenAI 호출이 한 번도 일어나지 않아요. 그래서 학생이 ./gradlew test 를 백번 천번 돌려도 — 비용이 0 원 이에요.
시연은 강사 사전 녹화. 실제 합성된 음성 결과물은 강사가 사전에 한 번 호출해서 녹화한 클립 으로 보여드릴게요. 학생이 자기 손으로 OpenAI TTS 를 호출하지 않아도 — 결과물은 완전히 같은 형태 으로 들어와요.
자기 손으로 돌려보고 싶다면 — 무료 대안. 두 개를 추천해요.
edge-tts Python CLI — Microsoft Edge 의 TTS 엔진을 무료로 호출하는 OSS.
pip install edge-tts → edge-tts --voice ko-KR-SunHiNeural --text "안녕하세요" --write-media out.mp3 한 줄로 한국어 자연스러운 음성 이 떨어져요.
2. 브라우저 SpeechSynthesis Web API — 프론트엔드에서 new SpeechSynthesisUtterance("안녕하세요") 한 줄로 호출 비용 0 원 의 음성 합성. 백엔드를 거치지 않고 브라우저가 직접 말함 이라는. Step 7 의 통합 시연 에서 한 번 흘려볼 자루예요.
🚨 잊지 말 한 줄
추상화 (
TextToSpeechModel) 는 OpenAI 스타터를 그대로 쓰되, 실제 호출만 무료 대안으로 우회한다 — 이 방식이 본 강의의 비용 가이드 핵심이에요. 코드의 모양은 그대로 두고, 비용만 가두는. 이미지 · 음성 · 비디오 비용 급등 경고 가 한 박스로 다시 만나는 부분예요.
7. 🙋 학생 질문 박스
"튜터님, 솔직히 한 가지 의문이 들어요. 왜 byte[] 를 그대로 반환 해요?
Resource로 감싸지 않고요? 지난 시간 (Day 8)MultipartFile.getResource()로 감싼을 봤더니 — 같은 패턴으로 가야 하지 않나 싶어서요. "
아주 좋은 의문 이에요! 이 질문은 Step 3 → Step 5 의 대칭이 살짝 흐트러져 보이는 부분 에서 정확히 짚어낸 거예요. 진짜로 「Resource 로 감싸야 하는 이유」 가 있는지 를 한 번 따져봅시다.
결론부터 — 안 감싸요. 이유는 두 가지예요.
첫째, 컨트롤러가 그대로 응답 body 에 흘려보내면 끝이기 때문이에요. Step 6 에서 만들 컨트롤러는.
ResponseEntity<byte[]> 를 반환하면서 Content-Type: audio/mpeg 헤더만 정리하면 그만 이에요. Spring MVC 의 ByteArrayHttpMessageConverter 가 byte[] 를 그대로 응답 스트림에 흘려보내는 방식이에요.
Resource 로 감싸도 결국 ByteArrayResource → byte[] 로 다시 풀리는 한 단 거꾸로 가는 일이 돼요.
둘째, Resource 추상화가 빛나는 부분은 「입력」 쪽이에요. Day 8 VisionUploadController 와 Step 4 VoiceTranscriptionController 가 멀티파트 → Resource 변환 으로 감싼 건.
Spring AI 의 입력 API 가 Resource 를 요구 하기 때문이었어요. (AudioTranscriptionPrompt(Resource)) 지금은 출력이고, 출력의 소비자 (HTTP 응답) 는 byte[] 를 그대로 좋아해요. 그래서 추상화를 한 겹 더 입히면 — 오버엔지니어링 지점이에요.
요약하자면 — Resource 는 「외부에서 들어오는 입력 자루」 의 추상화, byte[] 는 「외부로 나가는 출력 자루」 의 가장 단순한. 두 개를 목적에 맞게 다르게 쓰는 게 옳아요.
8. 다음 주제로 — 말하는 빈 도 들어왔다
자, 정리하면 — 오늘 5 번째 Step 에서 말하는 빈 (TextToSpeechModel) 도 백엔드 비즈니스 로직 단계이 완전히 익히셨어요.
- 듣는 빈 (Step 3
VoiceTranscriptionService) ↔ 말하는 빈 (Step 5VoiceSynthesisService) — 거울처럼 대칭의 두 개. - 같은 자매 추상화 패턴이 두 번째로 박힘 — 의존성 주입 · 가드 · 호출 한 줄 · null 폴백 의 네 감각.
- VC005 · VC006 두 개의 ErrorCode — VC005 는 방금 재등장, VC006 은 Step 6 컨트롤러에서 재등장.
이로써 말하는 빈 도 익히셨어요. 다음 모습은 — byte[] 음성 데이터를 HTTP 응답으로 그대로 흘려보내는 컨트롤러 예요. 5 단 파이프라인의 오른쪽 절반 (텍스트 → TTS → 스피커) 의 백엔드 입출구가 닫히는. Step 6 에서 만나요.
이때 — ApiResponse 표준 패턴 이 처음으로 「예외 부분」 를 만나는 그림이 펼쳐져요.
binary 응답 (audio/mpeg) 은 ApiResponse JSON 으로 감쌀 수 없는 자루이기 때문에, 정상 응답은 raw byte[] 로 흘리고 / 에러 응답만 GlobalExceptionHandler 가 ApiResponse.fail JSON 으로 잡아내는 비대칭이 처음 등장해요. Step 6 에서 그 예외의 부분 를 펼쳐봅시다.
Step 6. `VoiceSpeechController` — 바이너리 응답의 첫 자리 + ApiResponse 표준 패턴의 **예외 절** (약 25분)
자, 이제 말하는 빈 의 byte[] 결과물이 — HTTP 응답으로 흘러나가는 부분 가 펼쳐질 차례예요.
Step 5 에서 손에 정리한 방식을 한 번 다시 떠올려 봅시다.
voiceSynthesisService.synthesize("안녕하세요") 한 줄이 떨어지면 — 모델이 합성한 mp3 byte[] 가 들어왔죠.
그 byte[] 가 — 오늘 이 Step 에서 JSON 래핑 없이, 추가 변환 없이, 모델이 만들어준 그대로 — 사용자에게 도착하는 그림이에요.
날 것 그대로 흘러나가는 자루의.
그리고 — 본 Step 에는 Day 1~8 동안 한 번도 안 다뤘던 흐름 이 들어가 있어요. 바이너리 응답의 첫 부분 예요. Day 1~8 의 모든 컨트롤러는 JSON 응답 을 돌려줬고, 그래서 ApiResponse 표준 패턴 으로 한 방식으로 묶여 있었죠. 그런데 오늘 이 컨트롤러는. JSON 이 아닌 binary (audio/mpeg) 를 돌려줘요. 표준 패턴의 명시된 예외 절 이 처음으로 발동 되는 곳이에요.
이게 오늘 Step 의 진짜 학습 포인트 예요. 컨트롤러 코드 자체 보다도 — "왜 어떤 컨트롤러는 ApiResponse 로 감싸고, 어떤 컨트롤러는 안 감싸지?" 라는 분류 기준 이 익혀히는.
1. VoiceSpeechController — 한 클래스를 한 번에 펼치기
자, 컨트롤러 본체 를 먼저 한 번에 펼쳐볼게요. 코드를 흘려보고 아래에서 한 줄씩 풀어요.
package kr.spartaclub.aifriends.voice.controller;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.dto.VoiceSpeechRequest;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import kr.spartaclub.aifriends.voice.service.VoiceSynthesisService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
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.RestController;
/**
* Day 9 Step 6 — 텍스트 → TTS → audio/mpeg 바이너리 응답 엔드포인트.
*
* <p>JSON 본문 {@code {"text": "..."}} 를 받아 {@link VoiceSynthesisService} 로 합성하고
* 합성된 mp3 바이트를 그대로 응답 바디로 흘려보낸다. {@code Content-Type} 은
* {@code audio/mpeg} 이고 응답은 byte[] — 브라우저의 {@code <audio>} 태그가 그대로 재생할 수 있다.</p>
*
* <p><b>ApiResponse 표준 패턴의 예외 결정</b>: 본 컨트롤러의 정상 응답은 {@code ApiResponse<T>}
* 로 래핑하지 않는다. 응답이 바이너리(audio/mpeg)라 JSON 래핑이 부적합하기 때문이다 — 표준 패턴의
* "디버그 전용 평문 출력" 예외 절의 바이너리 버전이다. 다만 <b>에러 응답은
* {@code GlobalExceptionHandler} 가 자동으로 ApiResponse JSON 으로 래핑</b>하므로,
* 같은 엔드포인트의 정상/에러 응답 형태가 비대칭(정상=binary, 에러=JSON)이다 — 이는 의도된 흐름.</p>
*/
@Slf4j
@RestController
@RequestMapping("/api/voice")
public class VoiceSpeechController {
/** OpenAI TTS API 가 한 번의 호출로 받아들이는 텍스트 길이 권장 한도. */
private static final int MAX_TEXT_LENGTH = 4000;
private final VoiceSynthesisService voiceSynthesisService;
public VoiceSpeechController(VoiceSynthesisService voiceSynthesisService) {
this.voiceSynthesisService = voiceSynthesisService;
}
@PostMapping(value = "/speak", produces = "audio/mpeg")
public ResponseEntity<byte[]> speak(@RequestBody VoiceSpeechRequest request) {
validate(request);
byte[] audio = voiceSynthesisService.synthesize(request.text());
log.info("[VoiceSpeech] success: textLength={}, audioBytes={}",
request.text().length(), audio.length);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("audio/mpeg"))
.header("Content-Disposition", "inline; filename=\"speech.mp3\"")
.contentLength(audio.length)
.body(audio);
}
/**
* 요청 검증.
*
* <ol>
* <li>request 또는 text 가 null/공백이면 {@link ErrorCode#VOICE_TEXT_REQUIRED}</li>
* <li>4000자 초과면 {@link ErrorCode#VOICE_TEXT_TOO_LONG}</li>
* </ol>
*/
private void validate(VoiceSpeechRequest request) {
if (request == null || request.text() == null || request.text().isBlank()) {
throw new VoiceException(ErrorCode.VOICE_TEXT_REQUIRED);
}
if (request.text().length() > MAX_TEXT_LENGTH) {
throw new VoiceException(ErrorCode.VOICE_TEXT_TOO_LONG);
}
}
}
자, 이 한 클래스가 왜 이런 모양으로 들어갔는지 를 — 클래스 javadoc 부터 한 줄씩 풀어볼게요.
2. 클래스 javadoc 의 ApiResponse 표준 패턴 예외 결정 — 본 Step 의 진짜 핵심
/**
* <p><b>ApiResponse 표준 패턴의 예외 결정</b>: 본 컨트롤러의 정상 응답은 {@code ApiResponse<T>}
* 로 래핑하지 않는다. 응답이 바이너리(audio/mpeg)라 JSON 래핑이 부적합하기 때문이다 — 표준 패턴의
* "디버그 전용 평문 출력" 예외 절의 바이너리 버전이다. 다만 <b>에러 응답은
* {@code GlobalExceptionHandler} 가 자동으로 ApiResponse JSON 으로 래핑</b>하므로,
* 같은 엔드포인트의 정상/에러 응답 형태가 비대칭(정상=binary, 에러=JSON)이다 — 이는 의도된 흐름.</p>
*/
자, javadoc 한 단락이 — 본 Step 의 학습 포인트를 한 부분에 모아둔 부분 예요. 세 줄로 풀어요.
첫째 — 정상 응답은 ApiResponse 미사용. Day 7 (이미지) · Day 8 (Vision) · 직전 Step 4 (STT) 까지 — 모든 컨트롤러의 정상 응답은 ApiResponse<T> 로 감쌌어요. 그게 ApiResponse 표준 패턴의 디폴트 흐름 이었죠. 그런데 오늘은.
감싸지 않아요. ResponseEntity<byte[]> 그대로 흘려보내요. 왜 그러냐 — 응답이 binary (audio/mpeg) 라 JSON 래핑이 부적합 하기 때문이에요. byte[] 를 ApiResponse 로 감싸려면 Base64 인코딩 → JSON 문자열 필드에 박기 → 클라이언트가 다시 디코드 의 세 단 우회 가 들어가야 해요.
바이너리의 본질을 흐트리는 모습이에요.
둘째 — 표준 패턴의 「디버그 전용 평문 출력」 예외 절의 바이너리 버전. Day 4 의 ApiResponse 표준 패턴 을 처음 박을 때 — 예외 한 줄 이 같이 들어갔어요. "학습용 디버그 엔드포인트 (예: /api/structured/quote/format-debug) 는 text/plain raw 로 둔다" 는 절.
그게.
정상 응답을 ApiResponse 로 안 감싸는 첫 부분 였어요. 오늘 이 binary 응답이 — 그 예외 절의 바이너리 버전 자리이에요. 플레인 텍스트 (text/plain) 와 바이너리 (audio/mpeg) 두 개가 — 같은 「ApiResponse 미사용」 예외 절에 묶이는 구조예요.
셋째 — 에러 응답은 여전히 ApiResponse JSON. 결정적인 한 줄 이 여기 들어가 있어요. 정상 응답은 binary 인데, 에러 응답은 JSON 곳이에요. 왜 그러냐.
GlobalExceptionHandler 가 모든 예외를 가로채서 ApiResponse.fail(ErrorResponse) 로 변환 하기 때문이에요.
우리가 컨트롤러에서 binary 를 흘리든 말든 — 예외가 터지는 순간 GlobalExceptionHandler 가 끼어들어 JSON 응답으로 바꿔치기 해요.
그래서 같은 엔드포인트의 정상/에러 응답 형태가 비대칭 인 거예요.
이게 의도된 결 이에요.
🙋 한 학생의 날카로운 질문
"튜터님, 그럼 ApiResponse 로 감싸야 하는지 말아야 하는지 어떻게 결정하나요? 컨트롤러 만들 때마다 헷갈릴 것 같아요. "
정확히 핵심을 찔러주셨어요. 분류 기준 한 줄 로 정리해드릴게요.
응답 종류 ApiResponse 사용 여부 예시 JSON 응답 (디폴트) ✅ 사용 (감싼다) Day 7 ImageGen · Day 8 Vision · Step 4 VoiceTranscription · 일반 CRUD 바이너리 응답 ❌ 미사용 (예외 절) 오늘의 audio/mpeg · 추후 image/png 다운로드 · video/mp4 등 디버그 전용 평문 출력 ❌ 미사용 (예외 절) Day 4 /api/structured/quote/format-debug(text/plain)학습용 raw 출력 ❌ 미사용 (예외 절) 학생에게 raw 형태를 그대로 보여주는 곳 디폴트는 JSON + ApiResponse 예요. 예외 두 모습 — 바이너리 / 디버그 평문 / 학습용 raw — 만 미사용 부분이고요. "이 응답이 JSON 인가? 그럼 ApiResponse 로 감싸" 한 줄로 결정 끝이에요.
그리고 — 예외 절을 쓸 때조차도 에러 응답은 GlobalExceptionHandler 가 자동으로 ApiResponse JSON 으로 감싼다 는 점은 변하지 않아요. 정상은 자루의 본질에 맞게, 에러는 항상 일관된 JSON 으로 — 이 두 방식이 ApiResponse 표준 패턴의 진짜 모양 이에요.
3. MAX_TEXT_LENGTH = 4000 — 학습 비용 억제의, 한 번 더
private static final int MAX_TEXT_LENGTH = 4000;
상수 한 줄이 클래스 맨 위에 들어가 있어요. 4000 자가 한도 라는.
왜 4000 이냐 — OpenAI TTS API 의 권장 한도 가 한 번의 호출당 약 4096 토큰 이고, 그걸 문자 수 기준으로 안전하게 잘라낸 흐름 이 4000 자예요. 이 방식은 — Step 4 의 MAX_BYTES = 10MB (학습 비용 억제) 와 완전히 같은 호흡이에요. 프로바이더가 받아줄 수 있는 한계 ≠ 우리 도메인이 받아낼 적정치 —
두 모습는 다른 결정 이라는.
4000 자 는 대략 A4 두 페이지 분량 자리이에요. 학생이 하나의 캐릭터 답변을 음성으로 합성 할 때 — 충분히 넉넉하나 예요. 그런데 — 학생이 실수로 「소설 한 챕터」 를 던지는 진행 이 한 번이라도 박히면 —
수업 한 번에 수천 원이 빠지는 사고가 생길 수 있어요. Step 4 의 10MB 결정과 정확히 같은 보호 흐름 자리이에요.
4. @PostMapping(value = "/speak", produces = "audio/mpeg") — Content-Type 미리 박기
@PostMapping(value = "/speak", produces = "audio/mpeg")
public ResponseEntity<byte[]> speak(@RequestBody VoiceSpeechRequest request) {
어노테이션의 두 개 가 — 결정적인 방식 곳이에요.
produces = "audio/mpeg". Content-Type 을 어노테이션에 미리 박는 지점이에요. Spring MVC 가 디스패치 시점에 「이 핸들러가 audio/mpeg 응답을 약속한다」 는 시그널을 받아두는 모습이에요.
클라이언트가 Accept 헤더로 다른 자루 (예: application/json) 를 요청 하면.
Spring 이 자동으로 406 Not Acceptable 을 돌려줘요. 컨트롤러 본문이 실행되기도 전에 컷. 그리고 — 나중에 OpenAPI / Swagger UI 가 이 컨트롤러를 문서화할 때 — produces 값을 그대로 응답 스키마에 정리해넣어요. 문서 자동 생성에도 한 번에 흐르는 자리이에요.
@RequestBody VoiceSpeechRequest request. 입력은 JSON 단계이에요. Step 4 의 VoiceTranscriptionController 가 멀티파트 입력 (@RequestParam("audio") MultipartFile) 이었던 것과 대조적인 방식 곳이에요.
입력은 JSON, 출력은 binary 입출력의 자루 형태가 비대칭 인. 텍스트는 문자열 한 줄 이라 JSON 으로 받는 게 자연스럽고, 음성은 바이트 묶음 이라 binary 로 흘리는 게 자연스러워요. 각 자루의 본질에 맞게 다른 방식으로 들어간 부분이에요.
5. ResponseEntity 빌더 체인 — 4 단 응답 조립
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("audio/mpeg"))
.header("Content-Disposition", "inline; filename=\"speech.mp3\"")
.contentLength(audio.length)
.body(audio);
응답 하나를 조립하는 4 단 체인 모습이에요. 한 줄씩 풀어요.
(1) .contentType(MediaType.parseMediaType("audio/mpeg"))
Content-Type 헤더 한 줄. 어노테이션의 produces 와 같은 값을 한 번 더 정리하에요. 어노테이션 부분은 디스패치 시점의 약속, 빌더 부분은 실제 응답에 박히는 헤더 — 두에 같은 값이 들어가 일관성을 만드는 자리이에요.
(2) .header("Content-Disposition", "inline; filename=\"speech.mp3\"")
결정적인 한 줄 곳이에요. 이 한 줄이 — 브라우저의 동작을 결정하는 부분 예요.
inline— 브라우저 내에서 그대로 재생 하라는 신호. 브라우저가 받자마자<audio>태그가 자동 재생 그림이 펼쳐져요.- 만약
attachment였다면 — 다운로드 다이얼로그가 떴을 거예요. 사용자가 파일을 저장 하는 방식으로 흘렀겠죠. filename="speech.mp3"— 브라우저가 다운로드할 때 파일명을 무엇으로 저장할지 의 힌트.inline이라도 사용자가 우클릭 → 저장 하면 — speech.mp3 이름으로 저장 돼요.
이 한 줄이 — 우리 캐릭터의 음성을 「자동 재생」 으로 흘릴지 「다운로드」 로 흘릴지 를 결정하는 곳이에요. 대화의 자연스러움 을 위해선 자동 재생 (inline) 이 맞는이고요.
(3) .contentLength(audio.length)
Content-Length 헤더 한 줄. byte[] 의 길이를 그대로 정리해요. 왜 명시적으로 박냐 — 클라이언트가 다운로드 진행률을 정확히 표시할 수 있게 하기 위함이에요. 진행률 바가 0%에서 100%까지 부드럽게 흐르는 결이 이 한 줄에 달려 있어요.
없으면 — 브라우저가 「전송이 끝났는가」 를 추측하게 되고, 진행률 바가 「알 수 없음」 으로 떠요. 작은 한 줄이지만. 사용자 경험의 결정적 자리 예요.
(4) .body(audio)
마지막 한 줄 — byte[] 를 그대로 응답 바디에 정리해요. Spring MVC 의 ByteArrayHttpMessageConverter 가 byte[] 를 추가 변환 없이 응답 스트림에 흘려보내는 부분이에요. 모델이 만든 mp3 byte 가 — 한 번의 변환도 없이 — 사용자 브라우저에 도착하는.
본문은 4 단 체인 만 — 코드베이스에는 헤더 두 줄이 더 박혀 있어요 💡
Step 5 의 복선과 짝패예요. 받아 가신 코드베이스의 실제
VoiceSpeechController.speak(...)에는 응답 헤더 두 자루 가 추가로 박혀 있어요.// 코드베이스의 실제 컨트롤러 (본문 압축본에선 생략) — 마무리 섹션에서 회수 .header("X-TTS-Provider", result.provider()) // 예: "openai" / "elevenlabs" .header("X-TTS-Voice", result.voice()) // 예: "marin" / UUID그리고 — 형제 엔드포인트 한 자루가 더 있어요.
GET /api/voice/info— 현재 활성 TTS 프로바이더만 단독 확인 하는 한 줄짜리 디버그 엔드포인트예요. DevTools Network 탭 에서 방금 부른 합성이 어떤 프로바이더 / 어떤 voice 로 떨어졌는지 즉시 보이고,.env의TTS_PROVIDER를 갈아끼고 재기동한 뒤/api/voice/info한 번 찍으면 — swap 이 살아 있는지 단번에 확인돼요. 오늘 본문 컨트롤러의 모양 은 — 이 학습 시연 메타데이터 두 자루 를 덜어낸 압축본 모습이에요. 본격 swap 시연은 마무리에서.
6. validate(VoiceSpeechRequest) — 2 단 검증
private void validate(VoiceSpeechRequest request) {
if (request == null || request.text() == null || request.text().isBlank()) {
throw new VoiceException(ErrorCode.VOICE_TEXT_REQUIRED);
}
if (request.text().length() > MAX_TEXT_LENGTH) {
throw new VoiceException(ErrorCode.VOICE_TEXT_TOO_LONG);
}
}
두 가지 시나리오 가 순차로 흘러요.
**(1) null / null text / blank text — VOICE_TEXT_REQUIRED (VC005) **
세 개가 같은 ErrorCode 로 묶였어요. request == null 은 @RequestBody 가 빈 자루로 들어올 때, request.text() == null 은 JSON 에 text 필드가 없거나 null 일 때,.
request.text().isBlank() 는 공백뿐일 때. 사용자 입장에선 어느 쪽이든 "음성 합성을 위한 텍스트를 입력해 주세요" 라는 같은 메시지가 자연스러우니까요. — Step 5 의 VoiceSynthesisService.synthesize 가드 와 완전히 같은 호흡이에요.
사실 — 서비스에도 같은 가드가 들어가 있어요. 컨트롤러가 한 번, 서비스가 한 번 — 두에 같은 가드 가 들어간 부분예요. 방어 깊이 (defense in depth) 의 감각이에요.
**(2) 4000 자 초과 — VOICE_TEXT_TOO_LONG (VC007) **
길이 컷. 위에서 정리해둔 학습 비용 억제 의 진짜 실행 자리이에요. 4001 자부터 컷 — 이 경계값 (boundary) 이 Step 4 의 10MB 컷 과 같은 방식 모습이에요.
7. VoiceSpeechRequest — 한 필드 record 의 짧음
요청 DTO 도 놀랍도록 짧아요.
package kr.spartaclub.aifriends.voice.dto;
/**
* Day 9 Step 6 — TTS 요청 DTO.
*
* <p>{@code POST /api/voice/speak} 의 JSON 본문을 매핑한다.
* {@code text} 필드 한 개만 받는 단순한 record. 검증(null/blank, 최대 길이) 은
* 컨트롤러에서 {@link kr.spartaclub.aifriends.voice.exception.VoiceException}
* 으로 던져 표준 에러 응답으로 변환된다.</p>
*/
public record VoiceSpeechRequest(String text) {
}
record 한 줄, 필드 하나만. — Day 4 의 Structured Output 에서 정리한 record 의 짧음 이 세 번째로 회수 되는 곳이에요. Quote (Day 4) → VoiceTranscriptionResponse (Step 4) → VoiceSpeechRequest (Step 6). DTO 의 본질이 「변하지 않는 자루」 라는 진행이 한 줄로 압축 되는. getter / equals / hashCode / toString 이 자동으로 박히는 결정적 감각이에요.
입력은 단순하게, 확장은 필요할 때 — 추후 보이스 (alloy / nova / shimmer) 나 속도 (speed) 같은 옵션이 필요해지면 — 필드를 추가하면 그만 자리이에요. 기본은 가장 단순하게 정리해두에요.
8. ErrorCode 추가분 — VC005 (회수) · VC007 (신규) · VC006 (앞으로의 부분)
ErrorCode 의 Voice 섹션을 다시 보면 — Step 5 에서 들어간 VC005 · VC006 뒤로 VC007 한 줄이 새로 들어가 있어요.
// Voice / STT (Day 9)
VOICE_AUDIO_REQUIRED(HttpStatus.BAD_REQUEST, "VC001", "음성 파일을 첨부해 주세요."),
VOICE_AUDIO_FORMAT_INVALID(HttpStatus.BAD_REQUEST, "VC002", "지원하지 않는 음성 파일 형식입니다. (mp3, mp4, mpeg, mpga, m4a, wav, webm)"),
VOICE_AUDIO_TOO_LARGE(HttpStatus.BAD_REQUEST, "VC003", "음성 파일 크기가 허용 한도(10MB)를 초과했습니다."),
VOICE_TRANSCRIPTION_FAILED(HttpStatus.BAD_GATEWAY, "VC004", "음성 인식에 실패했습니다."),
VOICE_TEXT_REQUIRED(HttpStatus.BAD_REQUEST, "VC005", "음성 합성을 위한 텍스트를 입력해 주세요."),
VOICE_SYNTHESIS_FAILED(HttpStatus.BAD_GATEWAY, "VC006", "음성 합성에 실패했습니다."),
VOICE_TEXT_TOO_LONG(HttpStatus.BAD_REQUEST, "VC007", "음성 합성 텍스트가 허용 길이(4000자)를 초과했습니다.");
세 개를 짚어요.
VC005 — VOICE_TEXT_REQUIRED 의 재등장. Step 5 에서 미리 정리한 코드 가 — 오늘 컨트롤러에서 다시 등장 돼요. 400 BAD_REQUEST. 명백한 입력 오류의.
VC007 — VOICE_TEXT_TOO_LONG 신규. 오늘 새로 들어간 자루. 400 BAD_REQUEST. 4000 자 한도 의을 ErrorCode으로 끌어올린 자루예요. 학습 비용 억제 → 검증 한 줄 → ErrorCode 의 세에 같은 정책이 들어간.
VC006 — VOICE_SYNTHESIS_FAILED 는 오늘은 직접 사용 안 함. 502 BAD_GATEWAY. 외부 TTS 프로바이더가 실패할 때 의 자리.
그런데 — 지금 컨트롤러 코드에서는 안 던져요. 그럼 어디서 회수 되냐 — Spring AI 가 OpenAI / Gemini TTS 호출 시 던지는 예외를 VoiceException 으로 래핑하는 부분 가 앞으로 모습이에요.
일반적으로 Spring AI 의 retry 프로퍼티(spring.ai.retry.**) · Fallback 같은 운영 흐름 에서 자연스럽게 박히는 지점이고, 본 강의 범위는 Day 9 의 핵심 학습 (자매 추상화 + 검증 한 줄 + 5 단 파이프라인) 에 집중하기 위해.
VC006 은 ErrorCode 자루로만 정리해두고, 실사용은 학습 외 방식으로 두에요.
9. 정상 vs 에러 응답의 의도적인 비대칭 — binary ↔ JSON
오늘 학습의 핵심을 한 줄 더 정리할게요. 정상 응답은 audio/mpeg byte[], 에러 응답은 ApiResponse.fail(...) JSON
같은 엔드포인트의 응답 형태가 비대칭 자리이에요. 이게 컨트롤러 javadoc 의 「의도적인 비대칭」 이 진짜로 살아 있는 부분 예요. 바이너리 본질을 흐트리지 않으면서 (정상 응답을 Base64 우회 안 시키면서), 에러 응답은 GlobalExceptionHandler 의 자동 변환으로 표준 JSON 을 유지 하는 ApiResponse 표준 패턴의 예외 절 그대로의.
💡 컨트롤러가 약속대로 흐르는지 (정상 텍스트 → 200 +
Content-Type: audio/mpeg+ byte[] 그대로 · 빈 텍스트/null → 400 + VC005 JSON · 4001 자 → 400 + VC007 JSON) 는 코드베이스의VoiceSpeechControllerTest가 4 시나리오로 검증해뒀어요. 정상 응답은content().bytes(...)로, 에러 응답은jsonPath("$.error.code")로 — 테스트의 단언 방식까지 비대칭 으로 들어있어요. 경계값"가".repeat(4001)한 줄이 Step 4 의new byte[10 * 1024 ** 1024 + 1]와 같은 호흡 으로 들어가, 비교 연산자 한 글자 (>↔>=) 만 잘못 정리해도 깨지는.
10. Day 6 텍스트 스트리밍 vs Day 9 binary 응답 — 한 표로 펼치기
자, 본 Step 마지막에 결정적 비교 한 부분를 정리할게요. Day 6 의 텍스트 토큰 스트리밍 (SSE) 와 Day 9 의 binary 응답 이 — 둘 다 「표준 JSON 응답이 아닌」 자루이지만,은 정반대 예요.
| 항목 | Day 6 텍스트 스트리밍 | Day 9 음성 binary 응답 |
|---|---|---|
| 응답 타입 | 토큰 단위 (SSE / Flux<String>) |
전체 byte[] 한 번에 (ResponseEntity<byte[]>) |
| 사용자 체감 | 점진적 출력 — 첫 글자가 빨리 나옴 | 전체 합성 후 재생 — 합성 끝날 때까지 대기 |
| Spring 패턴 | Flux<String> / SseEmitter / text/event-stream |
ResponseEntity<byte[]> / audio/mpeg |
| 장점 | 빠른 첫 토큰 — UX 가 살아 있음 | 단순 + 캐시 가능 — CDN 결합 쉬움 |
| 단점 | 클라이언트 복잡 — SSE 파싱 필요 | 합성 완료까지 대기 — 첫 음 떨어지기까지 시간 |
같은 「긴 응답」 자루를 — 둘은 정반대 방식으로 푼 부분 예요. Day 6 은 흘려보내며 (점진적), Day 9 는 모아서 한 번에. 어느 쪽이 옳다 그르다는 진행이 아니에요. 콘텐츠의 본질에 맞는 진행을 고른 단계이고, 둘 다 ApiResponse 표준 패턴의 예외 절 에 묶여 있는 그림이에요.
💡 튜터의 결론 — Step 6 한 줄
바이너리 응답 (
audio/mpeg) 은 — ApiResponse 표준 패턴의 명시된 예외 절 이에요. 디폴트는 JSON + ApiResponse 이고, 예외 두 부분 (바이너리 / 디버그 평문) 만 미사용. 그리고 — 이 예외 절에 묶여도 에러 응답은 여전히 ApiResponse JSON 으로 자동 변환 돼요. 정상은 자루의 본질에 맞게, 에러는 항상 일관된 JSON 으로 — 같은 엔드포인트의 정상/에러 응답 형태가 비대칭인 게 의도된 진행.produces = "audio/mpeg"+Content-Disposition: inline+ResponseEntity<byte[]>의 세 줄 이 — 모델의 mp3 byte 를 사용자 브라우저까지 한 번의 변환도 없이 흘려보내는 곳이에요.그리고 한 줄 더 — Spring AI 1.1.x 에는
StreamingTextToSpeechModel도 있어요. TTS 를 토큰 단위 (정확히는 오디오 청크 단위) 로 스트리밍 으로 흘리는. 본 강의는 학습 단순도 를 우선해 binary 한 번에 의 방식으로 정리해두고, 스트리밍 TTS 는 심화 레퍼런스 로만 짚고 넘어가요. Day 6 에서 손에 넣은 SSE을 — 추후 학생이 직접 TTS 에도 적용해볼 수 있는으로 열어둡니다.
11. 다음으로 — 5 단 파이프라인의 마지막 곳이 남았다
자, 정리해보면 — Step 6 까지 거쳐 온 부분 는 이래요.
- Step 1~2 — 라인업 매트릭스 + 자매 추상화 시그니처
- Step 3~4 — 듣는 빈 (
VoiceTranscriptionService+VoiceTranscriptionController) - Step 5~6 — 말하는 빈 (
VoiceSynthesisService+VoiceSpeechController)
이로써 — 듣는 빈 과 말하는 빈 두 컨트롤러 가 모두 익히셨어요. 그런데 — 각자 떨어져 있을 뿐 부분이에요. POST /api/voice/transcribe 와 POST /api/voice/speak 두 개가 나란히 놓여 있지만, 서로 손을 잡지 않은 상태예요.
사용자가 마이크에 대고 말하면 — 캐릭터의 답이 음성으로 돌아오는. 그게 5 단 파이프라인의 진짜 모양 이에요 — 마이크 → STT → ChatClient → TTS → 스피커. 그런데 지금 우리 손에는 — STT 와 TTS 두 개만, ChatClient 가 가운데에 끼어 있지 않아요.
다음 Step 에서 — 세 추상화가 손을 잡는 부분 가 펼쳐져요. TranscriptionModel · ChatModel · TextToSpeechModel 세 자매 가 한 서비스 빈 안에서 차례로 호출되는. Day 5 의 ChatMemory 도 이때 대화의 맥락이 음성에서도 유지되는 부분로로 다시 등장돼요.
Step 7 에서 마지막 부분 를 짓습니다.
Step 7. 5 단 파이프라인 통합 — 세 추상화가 한 모습에서 손을 잡는 피날레 (약 25분)
자, 드디어 오늘의 피날레에 왔어요. Step 1 부터 Step 6 까지 — 듣는 빈 (VoiceTranscriptionService) 과 말하는 빈 (VoiceSynthesisService) 을 각자의 컨트롤러 와 함께 손에 정리했죠. 그런데 — 두 개가 아직 서로 손을 잡지 않은 상태였어요.
오늘 손에 정리한 듣는 빈 (TranscriptionModel 자매 추상화) 과 말하는 빈 (TextToSpeechModel 자매 추상화), 그리고 Day 1 부터 함께 걸어온 생각하는 빈 (ChatModel) — 세 추상화가 처음으로 한 줄 위에서 손을 잡는 부분 예요.
사용자가 마이크에 대고 말하면 — 캐릭터가 자기 목소리로 답하는 그림이 진짜로 살아나는.
1. 도입 — 세 추상화가 손을 잡는 진행
"오늘 손에 정리한 듣는 빈 (
TranscriptionModel) + 말하는 빈 (TextToSpeechModel), 그리고 Day 1 부터 함께한 생각하는 빈 (ChatModel) — 세 추상화가 처음으로 한 줄 위에서 손을 잡는 자리이에요."
5 단 파이프라인의 전체 모양을 한 번 더 정리할게요. Step 2 의 도식과 같은 결인데 — 진짜로 코드 한 줄 위에서 펼쳐지는 부분라는 진행이 다른 곳이에요.
사용자 마이크 → STT → 페르소나 + 사용자 텍스트 → ChatModel → AI 텍스트 → TTS → 캐릭터 음성
(1) (2~3) (4) (5)
다섯 단계예요.
(1) 음성 → 텍스트 / (2~3) 페르소나 + 사용자 텍스트로 Prompt 조립 / (4) AI 응답 / (5) 텍스트 → 음성. Day 8 의 CharacterVisionController.introduce 가 vision (Media) + chat 두 추상화 를 한 모습에 모았다면 —
오늘은 세 추상화가 모이는 한 단계 더 깊은 자리이에요.
2. CharacterVoiceService — 5 단 파이프라인의 응축
자, 오늘의 핵심 빈 자리이에요. 4 개의 의존성 이 한 지점에 모이고, 5 단 흐름 이 한 메서드 안에 응축 돼 있어요.
package kr.spartaclub.aifriends.voice.service;
import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.domain.Soulmate;
import kr.spartaclub.aifriends.repository.SoulmateRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
public class CharacterVoiceService {
private final SoulmateRepository soulmateRepository;
private final VoiceTranscriptionService voiceTranscriptionService;
private final VoiceSynthesisService voiceSynthesisService;
private final ChatModel chatModel;
public CharacterVoiceService(SoulmateRepository soulmateRepository,
VoiceTranscriptionService voiceTranscriptionService,
VoiceSynthesisService voiceSynthesisService,
ChatModel chatModel) {
this.soulmateRepository = soulmateRepository;
this.voiceTranscriptionService = voiceTranscriptionService;
this.voiceSynthesisService = voiceSynthesisService;
this.chatModel = chatModel;
}
@Transactional(readOnly = true)
public byte[] converse(Long soulmateId, Resource audio) {
Soulmate soulmate = soulmateRepository.findById(soulmateId)
.orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));
// 1) STT
String userText = voiceTranscriptionService.transcribe(audio);
if (userText == null || userText.isBlank()) {
log.info("[CharacterVoice] STT empty — skip Chat/TTS, soulmateId={}", soulmateId);
return new byte[0];
}
// 2~3) 페르소나 + 사용자 텍스트 → ChatModel
Prompt prompt = new Prompt(List.of(
new SystemMessage(buildPersonaSystemMessage(soulmate)),
new UserMessage(userText)
));
ChatResponse response = chatModel.call(prompt);
String aiText = (response == null || response.getResult() == null
|| response.getResult().getOutput() == null
|| response.getResult().getOutput().getText() == null)
? ""
: response.getResult().getOutput().getText();
if (aiText.isBlank()) {
log.info("[CharacterVoice] ChatModel empty response — skip TTS, soulmateId={}", soulmateId);
return new byte[0];
}
log.info("[CharacterVoice] converse: soulmateId={}, name={}, userTextLen={}, aiTextLen={}",
soulmate.getId(), soulmate.getName(), userText.length(), aiText.length());
// 4~5) TTS
return voiceSynthesisService.synthesize(aiText);
}
private String buildPersonaSystemMessage(Soulmate soulmate) {
return "당신은 '" + soulmate.getName() + "' 라는 이름의 캐릭터예요. "
+ "성격: " + soulmate.getPersonalityKeywords() + ". "
+ "취미: " + soulmate.getHobbies() + ". "
+ "말투: " + soulmate.getSpeechStyles() + ". "
+ "사용자의 말에 한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요.";
}
}
자, 이 빈을 한 겹씩 펼쳐볼게요.
3. 의존성 4 자루 — 세 모달리티의 빈이 한 생성자에 모이는 진행
private final SoulmateRepository soulmateRepository;
private final VoiceTranscriptionService voiceTranscriptionService;
private final VoiceSynthesisService voiceSynthesisService;
private final ChatModel chatModel;
네 자루의 의존성 곳이에요. 하나씩을 짚어볼게요.
(1) SoulmateRepository — 대화 상대 캐릭터를 조회하는 자루. Day 8 의 CharacterVisionService 와 완전히 같은 방식 단계이에요. 어떤 캐릭터로 대화할지 의 정체성을 먼저 잡아내는.
(2) VoiceTranscriptionService — Step 3 에서 손에 정리한 듣는 빈. 오디오 자루를 받아 텍스트를 돌려주는.
(3) VoiceSynthesisService — Step 5 에서 손에 정리한 말하는 빈. 텍스트 자루를 받아 byte[] 음성을 돌려주는.
(4) ChatModel — Day 1 부터 함께한 생각하는 빈. import org.springframework.ai.chat.model.ChatModel; 인터페이스 타입 으로 주입받았다는 진행이 결정적이에요. Day 2 의 프로바이더 추상화 원칙 이 오늘도 그대로 살아있는.
OpenAiChatModel · OllamaChatModel 같은 구체 타입을 박지 않은.
세 추상화가 한 생성자에서 잡히는 진행
Day 2 의 프로바이더 추상화 (
ChatModel) → Day 7 의 자매 추상화 (ImageModel) → Day 9 의 두 자매 (TranscriptionModel기반의VoiceTranscriptionService+TextToSpeechModel기반의VoiceSynthesisService) — 세 추상화가 한 자리에서 다 모이는 진행 모습이에요.
chatModel.call(prompt)/voiceTranscriptionService.transcribe(audio)/voiceSynthesisService.synthesize(text)— 세 호출이 같은 모양의 인터페이스 위에서 일렬로 잡혀요. 프레임워크 하나, 감각 하나, 모달리티는 셋.
4. converse(Long soulmateId, Resource audio) — 5 단 한 메서드
이 메서드의 결정적 모양 곳이에요. 5 단계 가 한 부분에 응축 돼 있어요. 한 단계씩 풀어볼게요.
(1) 캐릭터 조회 + 가드 한 줄
Soulmate soulmate = soulmateRepository.findById(soulmateId)
.orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));
가장 먼저 — 대화 상대가 진짜 존재하는지 검증.
Optional.orElseThrow 한 줄로 없으면 즉시 BusinessException 을 던져요.
Day 8 의 CharacterVisionService.introduce 와 완전히 같은 호흡이에요.
ErrorCode 까지 — SOULMATE_NOT_FOUND 그대로 재등장.
이 한 줄이 결정적인 이유는 — 나머지 4 단계가 모두 외부 프로바이더 호출 (STT · ChatModel · TTS) 인데, 존재하지 않는 캐릭터로 5 단을 흘리면 — 비용만 빠지고 의미 있는 결과는 0 곳이에요. 가장 싼 검증 (DB 한 번) 을 가장 먼저 정리하에요.
(2) STT 한 줄 + 빈 폴백
String userText = voiceTranscriptionService.transcribe(audio);
if (userText == null || userText.isBlank()) {
log.info("[CharacterVoice] STT empty — skip Chat/TTS, soulmateId={}", soulmateId);
return new byte[0];
}
Step 3 에서 정리한 빈을 한 줄 호출. audio 라는 Resource 자루가 — 텍스트 한 줄로 변신. 그리고 — 결정적인 폴백 한 부분 가 들어가 있어요.
STT 결과가 null 이거나 공백뿐이면 — ChatModel 도 TTS 도 호출하지 않고 빈 byte[] 를 즉시 반환 해요. 왜 — 무성/노이즈만 들어온 자루 일 때 비용만 빠지는 호출을 막는 부분이에요. 조용히 들어주기 의 감각이에요. 학생이 마이크를 잘못 켜뒀거나, 아무 말도 안 하고 멈췄을 때 수업 한 번에 ChatModel + TTS 두 개의 비용이 빠지는 사고 를 입구에서 막는 모습이에요.
(3) Prompt 조립 — 페르소나 + 사용자 텍스트 두 메시지
Prompt prompt = new Prompt(List.of(
new SystemMessage(buildPersonaSystemMessage(soulmate)),
new UserMessage(userText)
));
여기가 — Day 1~3 의 패턴이 세 번째로 다시 등장하는 자리이에요. Prompt(List.of(SystemMessage, UserMessage)) 의 두 메시지 구성. Day 1 의 ChatModel.call(prompt) → Day 3 의 PromptTemplate → 오늘 세 번째 재등장.
SystemMessage 부분에는 — 캐릭터의 정체성 (이름 · 성격 · 취미 · 말투) 을 자연어 한 덩어리로 정리해요. UserMessage 부분에는 — 방금 STT 가 인식한 사용자 텍스트 를 그대로 정리해요. 두 메시지가 분리된 결정적 이유 는. 페르소나는 시스템의 약속 (변하지 않는 자루), 사용자 발화는 매 호출마다 다른 자루. 프로바이더가 두 개의 가중치를 다르게 다룰 수 있게 분리해두에요.
(4) ChatModel 호출 + null-safe 추출
ChatResponse response = chatModel.call(prompt);
String aiText = (response == null || response.getResult() == null
|| response.getResult().getOutput() == null
|| response.getResult().getOutput().getText() == null)
? ""
: response.getResult().getOutput().getText();
if (aiText.isBlank()) {
log.info("[CharacterVoice] ChatModel empty response — skip TTS, soulmateId={}", soulmateId);
return new byte[0];
}
ChatResponse 자루의 4 단 null 체크 가 들어가 있어요. 왜 이렇게까지 방어적 이냐 — 프로바이더가 빈 응답을 돌려주는 케이스 (rate limit · safety filter · 모델 오작동) 가 실제로 발생하기 때문이에요.
그럴 때 NPE 가 떨어지면 — 5 단 파이프라인 전체가 죽어 버려요. 명시적 null 체크 → 빈 문자열 폴백 이 더이 자연스럽고요.
그리고 — AI 텍스트가 빈 자루이면 TTS 도 건너뛰어요. 빈 텍스트로 TTS 를 호출하면 — 빈 음성 byte[] 를 받기 위해 비용을 지불 하는 진행이 들어가요. 조용히 끝내고 빈 byte[] 를 돌려주는 진행이 비용 + 사용자 경험 둘 다 자연스러운 모습이에요.
(5) TTS 호출 — 마지막 한 줄
return voiceSynthesisService.synthesize(aiText);
마지막 한 줄. AI 가 만든 텍스트 응답 이 — byte[] 음성 자루 로 변신해서 그대로 반환돼요. 컨트롤러 → 서비스 → 컨트롤러 의 자루가 한 번의 변환도 없이 흘러요.
@Transactional(readOnly = true) 가 클래스가 아닌 메서드 위에 들어간 도 짚고 갈게요. Soulmate 조회만 있는 부분 라 읽기 전용 트랜잭션 으로 충분해요. 외부 프로바이더 호출 (STT · ChatModel · TTS) 은 트랜잭션 밖에서 의미 없는 자루 이지만.
DB 조회 자리만 명시적으로 readOnly 로 정리해두에요. Day 8 의 CharacterVisionService 와 같은 호흡 이고요.
5. buildPersonaSystemMessage(Soulmate) — 페르소나 자연어 한 덩어리
private String buildPersonaSystemMessage(Soulmate soulmate) {
return "당신은 '" + soulmate.getName() + "' 라는 이름의 캐릭터예요. "
+ "성격: " + soulmate.getPersonalityKeywords() + ". "
+ "취미: " + soulmate.getHobbies() + ". "
+ "말투: " + soulmate.getSpeechStyles() + ". "
+ "사용자의 말에 한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요.";
}
캐릭터의 정체성 4 자루 (이름 · 성격 · 취미 · 말투) 를 자연어 한 덩어리 로 정리하는 헬퍼예요. 한 가지을 짚어두고 갈게요.
왜 PromptTemplate 을 안 썼는가 — 학습 단순도 곳이에요. Day 3 에서 손에 정리한 PromptTemplate("...{name}...{personality}...") 는 변수 치환에 더 정교한 제어가 필요할 때 의 도구예요. 오늘은.
4 자루를 한 줄로 자연어로 정리하면 그만 인 부분라, 문자열 연결 (+) 만으로 충분 한 방식으로 두었어요. 기본은 단순하게, 필요할 때 PromptTemplate 으로 승격 — 의 감각이에요.
마지막 한 줄 — "한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요." — 여기가 결정적 가드 예요. 없으면 — 모델이 5~10 문장의 긴 답 을 돌려줄 수 있고, 그게 그대로 TTS에 흘러서 비용이 5~10 배로 부풀어 요.
프롬프트 한 줄로 답변 길이를 가두는 진행 이 비용 가드의 가장 저렴한 부분 예요.
6. 세 추상화가 한 메서드에 들어간 — 진화의 한 발 더
결정적 한 박스
Day 2 의 프로바이더 추상화 → Day 7 의 자매 추상화 → Day 9 의 두 자매가 한 지점에서 다 모이는 그림.
chatModel.call(prompt) ← Day 1~3 의 자루 voiceTranscriptionService.transcribe(audio) ← Day 9 의 자루 (TranscriptionModel 기반) voiceSynthesisService.synthesize(aiText) ← Day 9 의 자루 (TextToSpeechModel 기반)세 호출이 — 같은 모양의 인터페이스 위에서 일렬로 잡혀요. 프레임워크 하나, 감각 하나, 모달리티는 셋.
Day 8 의
CharacterVisionController.introduce가 vision (Media) + chat 두 추상화 를 한 모습에 모았다면 — 오늘은 세 추상화가 모이는 진화의 한 발 더 들어간 부분예요.
7. Day 5 ChatMemory 의 의도된 미적용
자, 한 가지 — 눈치 빠른 학생은 이미 발견했을 단계을 짚고 갈게요. Day 5 에서 손에 정리한 JdbcChatMemoryRepository + ChatMemory — 오늘 코드 어디에도 없어요.
솔직한 방식 한 박스 — 학습 단순도 vs 실제 운영 의 트레이드오프
본 Step 은 ChatMemory 를 일부러 안 정리했어요. 한 번 호출 = 한 번 대화 의 방식으로 두면, 5 단 파이프라인의 골격 이 더 선명하게 보여요. 세 추상화가 손을 잡는 부분 가 대화 맥락이라는 또 하나의 복잡도 에 묻히지 않는이고요.
그런데 — 운영 환경에선 이 방식이 그대로 가면 안 돼요. 사용자가 이전 대화의 맥락을 기억하는 캐릭터 가 AI 친구 의 진짜 모양 이거든요. "오늘 뭐했어?" → "책 읽었어요." → "어떤 책?" → "..." — 이 세 번째 발화가 — 직전 대화를 기억할 때만 자연스럽게 흘러요.
학습 단순도와 실제 운영이 다른 자리 가 — 오늘의 솔직한 부분 예요. 5 단 파이프라인의 골격은 오늘 박고, ChatMemory 결합은 다음 부분 — 이게 본 Step 의 정직한 결정이에요.
🙋 한 학생의 날카로운 질문
"튜터님, 그럼 Day 5 의 ChatMemory 는 언제 다시 등장 하나요? 오늘 안 정리하면 — 캐릭터가 매번 사용자를 처음 만난 듯이 답하는 건 좀 어색한 장면 같은데요... "
정확히 흐름의 핵심을 찔러주셨어요. 두 곳 에서 다시 만나요.
첫째 — 오늘의 과제. 오늘 마무리 에서 만나게 될 과제 중 하나가 바로 그 부분 예요. 5 단 파이프라인에 ChatMemory 를 결합 하는 학생 손으로 한 번 정리해보는. ChatClient 위에
MessageChatMemoryAdvisor를 정리하는 진행 — Day 5 의 패턴 그대로요.둘째 — Day 11 (Tool Calling). 에이전트가 캐릭터의 과거 발화를 도구로 검색 하는 진행이 펼쳐질 거예요. ChatMemory 가 단순 기억 에서 도구 호출 진행 로 한 번 더 진화하는 부분고요.
한 줄로 정리하면 — 오늘은 5 단 파이프라인의 골격, 다음 두 모습에서 ChatMemory 가 자연스럽게 합류 해요. 학습은 한 번에 두 가지를 박지 않아요 — 하나씩, 그 다음에 두 자리이 만나는 부분이에요.
8. CharacterVoiceController — 진입점 한 컨트롤러
이제 — 컨트롤러 부분 예요. Step 4 의 검증 패턴 + Step 6 의 binary 응답 패턴 두 개가 한 곳에서 만나는 곳이에요.
package kr.spartaclub.aifriends.voice.controller;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import kr.spartaclub.aifriends.voice.service.CharacterVoiceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.Locale;
import java.util.Set;
@Slf4j
@RestController
@RequestMapping("/api/voice")
public class CharacterVoiceController {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"
);
private static final long MAX_BYTES = 10L * 1024 * 1024;
private final CharacterVoiceService characterVoiceService;
public CharacterVoiceController(CharacterVoiceService characterVoiceService) {
this.characterVoiceService = characterVoiceService;
}
@PostMapping(value = "/characters/{soulmateId}/converse", produces = "audio/mpeg")
public ResponseEntity<byte[]> converse(
@PathVariable Long soulmateId,
@RequestParam("audio") MultipartFile audio) {
validate(audio);
Resource resource = audio.getResource();
byte[] responseAudio = characterVoiceService.converse(soulmateId, resource);
log.info("[CharacterVoice] success: soulmateId={}, filename={}, inSize={}, outBytes={}",
soulmateId, audio.getOriginalFilename(), audio.getSize(), responseAudio.length);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("audio/mpeg"))
.header("Content-Disposition", "inline; filename=\"reply.mp3\"")
.contentLength(responseAudio.length)
.body(responseAudio);
}
private void validate(MultipartFile audio) {
if (audio == null || audio.isEmpty()) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
}
String extension = extractExtension(audio.getOriginalFilename());
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
}
if (audio.getSize() > MAX_BYTES) {
throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
}
}
private String extractExtension(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) return null;
return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
}
}
이 컨트롤러의 결정적인 결 4 자루 만 짚을게요.
(1) URL — /api/voice/characters/{soulmateId}/converse
Step 4 (/api/voice/transcribe) · Step 6 (/api/voice/speak) 와 같은 prefix 아래 세 번째 자루 가 들어갔어요. 그런데 — 경로 모양이 달라요. /characters/{soulmateId}/converse
캐릭터 도메인 자루가 가운데에 들어갔어요. Step 4·6 은 「음성 도메인의 변환 자루」, Step 7 은 「캐릭터와의 대화」 라는 진행의 차이를 URL 모양 으로도 신호하는 부분이에요.
(2) 입력 — 멀티파트 + PathVariable 두 개
@PathVariable Long soulmateId,
@RequestParam("audio") MultipartFile audio
PathVariable 로 캐릭터 정체성, RequestParam 으로 오디오 자루 — 두 개를 한 메서드에서 같이 받는 모습이에요. Day 8 의 CharacterVisionController 가 같은 호흡 이었고요. RESTful 하나의 모양 자리이에요.
(3) validate(audio) — Step 4 패턴의 답습
Step 4 의 VoiceTranscriptionController.validate 와 거의 같은 자루. null/empty → VC001, 확장자 화이트리스트 → VC002, 10MB 초과 → VC003. 공통 헬퍼로 추출하지 않고 — 컨트롤러마다 명시적으로 박는 모습이에요.
왜 헬퍼로 추출 안 하는가 — 학습 단순도 때문이에요. 학생이 컨트롤러 하나만 봐도 — 어떤 검증이 들어가 있는지 한눈에 보이는 결이 추상화를 한 겹 더 정리하는 결보다 학습엔 자연스러워요. 운영 환경에선 — BaseAudioValidator 같은 공통 헬퍼로 승격 하는 게 자연스러운 다음이고요.
(4) Content-Disposition: inline; filename="reply.mp3" — 답변 의 흐름
Step 6 의 speech.mp3 와 다른 파일명이에요. reply.mp3 — 캐릭터의 답변 이라는 진행을 파일명에 정리하는. 우클릭 → 저장 하면 —
reply.mp3 로 떨어져요. Step 6 (단순 TTS, 어떤 텍스트든) 과 Step 7 (캐릭터의 대답) 의 흐름의 차이 가 파일명 하나로 신호되는 그림이에요.
9. 5 단 흐름의 세 가지 약속 — 페르소나 박힘 · 가드 short-circuit · STT 폴백
5 단이 진짜로 약속대로 도는지는 세 개의 약속 으로 묶여요. 코드베이스의 CharacterVoiceServiceTest 가 3 시나리오로 각각 하나씩 검증해뒀어요.
첫째 — 페르소나 + 사용자 텍스트가 한 Prompt 자루에 같이 들어간다. ChatModel 에 들어간 Prompt 자루를 잡아내
그 안 메시지를 펼쳤을 때 (1) 캐릭터 이름 (예: "Alice"), (2) 성격 (예: "차분함"), (3) 사용자 텍스트 ("오늘 뭐했어?") 세 개가 한 자리에 다 들어있는지.
그리고 마지막 메시지는 반드시 UserMessage 여야 한다는 가드까지.
페르소나는 SystemMessage, 사용자 발화는 UserMessage 라는 역할 분리 가 깨지면 PROMPT 의 의미가 흐려지거든요.
둘째 — 캐릭터가 없으면 세 빈이 한 번도 안 호출된다. STT · ChatModel · TTS 세 빈이 BusinessException(SOULMATE_NOT_FOUND) 직전에 호출 0 회 인지. Step 4 의 「가장 싼 검증을 가장 먼저」 원칙이 테스트에서 다시 등장 되는 그림이에요.
비용이 들지 않는 검증이 — 정말로 비용이 안 들었는지까지 정리해두는.
셋째 — STT 가 빈 문자열을 돌려주면 ChatModel · TTS 도 호출 0 회 + 빈 byte[] 반환. 조용히 들어주기 의 폴백이 진짜로 비용 안 발생 으로 흐르는지를 검증해두는. 5 단 파이프라인이 「2 단에서 끊긴다」 는 short-circuit 의 약속 이 들어있어요.
💡 위 세 약속과
CharacterVoiceController의 3 시나리오 (정상 mp3 → 200 + audio/mpeg + byte[], 빈 파일 → 400 + VC001, 잘못된 확장자 → 400 + VC002) 는 코드베이스의CharacterVoiceServiceTest·CharacterVoiceControllerTest가 검증해뒀어요. 정상은 binary, 에러는 JSON — ApiResponse 표준 패턴의 의도적인 비대칭이 한 컨트롤러 테스트 안에서 다시 등장 돼요.
10. 💡 튜터의 결론 — Step 7 한 줄
💡 튜터의 결론 — 세 추상화가 한 곳에서 손을 잡는 피날레
Day 9 의 마지막 핵심은 — 듣는 빈 + 생각하는 빈 + 말하는 빈, 세 추상화가 한 메서드 안에서 차례로 손을 잡는 그림이에요. Day 2 의 프로바이더 추상화가 세 번째 모달리티 (청각) 에서 다시 등장되고, Day 1~3 의
Prompt(SystemMessage + UserMessage)패턴 이 페르소나의 부분 에서 다시 들어갔어요.학습 단순도를 위해 Day 5 의 ChatMemory 는 의도적으로 비워뒀어요. 5 단 파이프라인의 골격이 가장 선명하게 보이는 진행 을 우선했고, 대화 맥락 결합 은 오늘의 과제 와 Day 11 (Tool Calling) 두 지점에서 자연스럽게 다시 만나요.
프레임워크 하나, 감각 하나, 모달리티는 셋 — 같은 모양의 인터페이스 위에서 세 개가 일렬로 잡히는 그림이 Spring AI 의 진짜 모양 모습이에요.
이로써 Day 9 의 모든 빈이 들어갔고, 세 추상화가 한 자리에서 손을 잡는 주제까지 완성됐어요. 마지막 부분은 — 비용 가드 + 브랜치 세이브 + Day 10 (비디오) 복선.
마무리 — 오늘 익힌 · 브랜치 세이브 · Day 10 (비디오) 복선 (약 10분)
1. 오늘 손에 정리한 장면 — 일곱 가지 자루를 한 줄씩
Day 9 의 3 시간을 한 문장으로 요약하면 — "캐릭터에게 귀와 입 을 달아준 하루 — TranscriptionModel / TextToSpeechModel 자매 추상화가 세 번째로 등장해서 들어간, 그리고 세 추상화가 한 메서드 안에서 손을 잡는 5 단 파이프라인의 첫 자리" 였어요.
지난 시간 (Day 8) 캐릭터에게 눈 을 달아준 손이, 오늘은 귀와 입 두 부분를 한꺼번에 정리해 — 시각 입출력 (Day 7~8) → 청각 입출력 (Day 9) 의 모달리티 진화를 한 단계 더 끌어올렸어요. 그리고 결정적으로.
Step 7 에서 세 자매 추상화 (TranscriptionModel · ChatModel · TextToSpeechModel) 가 처음으로 한 줄 위에서 손을 잡는 단계이 펼쳐졌죠.
오늘 익힌 일곱 가지 그림 을 한 줄씩 묶을게요.
| Step | 한 줄 요약 | |
|---|---|---|
| Step 1 | 음성 모델 라인업 4 옵션 | "Gemini · OpenAI · 로컬 · 브라우저 — 추상화는 OpenAI 스타터 / 실제 호출은 무료 라는 트레이드오프 결정. 음성은 텍스트의 30~50배 비용 감각 첫 각인" |
| Step 2 | 자매 추상화 두 빈 + 마이크 녹음 | "TranscriptionModel / TextToSpeechModel 두 빈이 ChatModel 옆에 나란히 놓이는 — 자매 추상화의 세 번째 등장. 브라우저 MediaRecorder 시연" |
| Step 3 | VoiceTranscriptionService |
"Resource → AudioTranscriptionPrompt → call → String 의 5 단 변환. VoiceException + VC001 의 첫 곳" |
| Step 4 | VoiceTranscriptionController |
"MultipartFile.getResource() 한 줄 변환 + 화이트리스트 7 자루 + 10MB 가드. ApiResponse 표준 패턴의 세 번째 등장" |
| Step 5 | VoiceSynthesisService |
"Step 3 와 거울처럼 대칭 — String → TextToSpeechPrompt → call → byte[] 의 5 단. 듣는 빈 옆에 말하는 빈" |
| Step 6 | VoiceSpeechController |
"바이너리 응답의 첫 부분 — audio/mpeg + ResponseEntity<byte[]>. ApiResponse 표준 패턴의 명시된 예외 절 — 정상은 binary, 에러는 JSON 의 의도적인 비대칭" |
| Step 7 | 5 단 파이프라인 통합 | "세 자매 추상화가 한 메서드 안에서 차례로 손을 잡는 피날레. CharacterVoiceService.converse(...) 한 부분에 STT → ChatModel → TTS 의 5 단이 응축. ChatMemory 는 의도적 미적용" |
이 일곱 개를 다 외우라는 게 아니에요.
"TranscriptionModel 과 TextToSpeechModel 은 ChatModel 의 자매 추상화 — Day 2 의 프로바이더 추상화가 세 번째 모달리티 (청각) 에서 한 번 더 들어갔다"
그리고 "CharacterVoiceService.converse(...) 한 메서드 안에서 세 자매 추상화가 차례로 손을 잡는다 — 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인"
두 문장만 3 개월 뒤에도 기억하시면 오늘 수업은 성공이에요.
💡 Day 9 의 한 문장 요약
"Spring AI 의 자매 추상화는 —
ChatModel옆에 한 모달리티씩 늘어난다. Day 7ImageModel, Day 8Media(입력 모달리티), Day 9TranscriptionModel+TextToSpeechModel(청각 입출력). 세 추상화가 한 메서드 안에서 손을 잡는 5 단 파이프라인이 — 모달리티 추상화의 일관성을 가장 선명하게 보여주는 진행이다."
2. 비용 가드 — 음성은 텍스트의 30~50배 의 한 번 더
자, 솔직한 한 박자 정리할게요. Step 1 에서 처음 던진 비용 감각 — 1 분짜리 음성 STT 한 번 ≈ 텍스트 호출 30~50 회 분량, 짧은 TTS 한 번 ≈ 텍스트 호출 5~10 회 분량. Vision (Day 8) 이 텍스트의 1.5~2 배 였다면, 음성은 한 부분수 더 위 예요.
본 강의는 — 모킹 + 강사 사전 녹화 시연 의 방식으로 정리해뒀어요. 학생 비용은 0 원 으로 학습이 흐르도록 디폴트가 Mockito 위에서 펼쳐지고, 실제 프로바이더 호출은 강사 데모 영상 으로만 보여드렸어요. 들어간 패턴은 그대로지만, 지갑은 안전한.
솔직한 방식 — 이 코드 그대로 운영에 올리면 비용 폭발
오늘 정리한 5 단 파이프라인 은 학습용 골격 모습이에요. 운영 환경에 그대로 올리면 — 악의적 사용자가 4000 자 TTS 를 초당 10 회씩 던지면 하루 만에 청구서가 수십만 원 이 될 수 있어요. Day 7 의
ImageDailyQuotaGuard· Day 8 의VisionDailyQuotaGuard자매 패턴이 — 오늘의 주제에선 의도적으로 비워뒀어요. 5 단 파이프라인의 골격을 가장 선명하게 보이게 하기 위함이고,VoiceDailyQuotaGuard를 직접 정리하는 진행 은 — 오늘의 과제 부분 로 미뤄뒀어요. 학생 손에서 직접 한 번 정리해보는 게 가드 패턴 세 번째 등장 의 진짜 감각이에요.
운영 환경 체크리스트로 정리해둘 세 개 — (1) 일일 호출 횟수 제한 (사용자 단위 + 글로벌), (2) 단일 요청 최대 길이 (오늘은 4000 자, 운영은 더 짧게), (3) 비용 모니터링 알람 (월 예산의 70% 경계). 오늘의 과제 에서 한 부분, Day 19 Harness 에서 본격적으로 —
비용 가드의 모양 이 한 단계씩 펼쳐져요.
3. 🎤 더 미연시스러운 캐릭터 보이스 — voice 매트릭스 + ElevenLabs swap 토대
자, 여기서 학생들이 자주 던지는 진짜 한 마디 가 있어요.
🙋 "튜터님, OpenAI
gpt-4o-mini-tts로 합성된 캐릭터 답을 들어보면 — 깔끔한데 미연시 캐릭터 톤은 아니에요. 더 캐릭터답게 들리는 voice 가 없을까요?"
맞는 진단입니다.
OpenAI TTS 라인업(marin/cedar/coral/ash 등) 은 narrator + 자연스러운 한국어 발음 에 강하지, 캐릭터 연기 는 약해요.
미연시 같은 반말 + 끝에 "ㅎㅎ" + 살짝 들뜬 톤 을 살리려면 다른 라인업으로 갈아 끼우는 게 답이에요. 2026 시점의 voice 프로바이더 매트릭스 를 한 표로 펼쳐드릴게요. Step 1 의 라인업이 간략 버전 이었다면 — 이건 7 자루까지 펼쳐낸 완성 버전 자리이에요.
| 프로바이더 | 강점 | 약점 | 한국어 | Spring AI 1.1 공식 starter |
|---|---|---|---|---|
OpenAI gpt-4o-mini-tts (오늘 디폴트) |
한국어 발음 자연 · narrator 톤 안정 · 단가 저렴 | 캐릭터 연기 약함 · voice 13종으로 한정 · 감정 표현 단조로움 | ✅ 자연 | ✅ spring-ai-starter-model-openai |
| Azure Neural TTS | SSML style/styledegree 로 기쁨/슬픔/속삭임 등 감정 톤 표현. 한국어 voice 다수 | 가격이 OpenAI 대비 살짝 높음 · 키 발급 + Azure 포털 진입장벽 | ✅ 자연 + 감정 | ⚠️ 비공식 (RestClient 직접) |
ElevenLabs eleven_multilingual_v2 |
캐릭터 클로닝 + prebuilt voice 수백 종 · 미연시/오디오북에 가장 많이 쓰이는 라인업 | 한국어 억양은 voice 운에 가까움 (특정 voice 만 자연스러움) · UUID 기반 voice 식별 | ⚠️ voice 따라 다름 | ✅ spring-ai-starter-model-elevenlabs |
| VOICEVOX / Style-Bert-VITS2 | anime / 미연시 톤의 정석. JP 캐릭터 보이스 라이브러리가 압도적 | JP 위주 — 한국어는 거의 안 됨. 셀프호스트 (GPU 권장) | ❌ | ❌ community/REST |
| Naver Clova Voice / Typecast | 한국어 자연도가 가장 높은 라인업. 광고/내레이션 품질 | 외국어 약함 · 국내 결제/계약 절차가 글로벌 SaaS 대비 무거움 | ✅✅ 1티어 | ❌ community/REST |
| Cartesia | 저지연 스트리밍 에 특화 — 첫 음절이 ~100ms 안에 떨어짐 (실시간 통화 모드) | 한국어 voice 풀이 좁음 · 신생 라인업이라 가격/지원 변동 가능 | ⚠️ | ❌ community/REST |
| Coqui XTTS-v2 / Piper (셀프호스트) | 호출 단가 0 + voice cloning 가능 · GDPR/오프라인 시나리오 강점 | GPU 자원 + 운영 부담 + 모델 품질이 SaaS 1티어 대비 한 계단 아래 | ⚠️ XTTS 는 ok / Piper 는 한국어 약함 | ❌ 직접 |
💡 Spring AI 1.1.x 의 공식 TTS starter 는 OpenAI / ElevenLabs 두 자루뿐. 나머지 프로바이더는 community starter 또는
RestClient로 직접 호출해야 하는 현재의 한계 가 있어요. (Spring AI 가 2.0 / 2.x 에서 라인업을 더 넓힐지는 로드맵 자리)
본 코드베이스에 이미 들어가 있는 swap 토대
오늘 강의 본문에서는 한 가지 path (OpenAI) 만 코드로 펼쳤어요.
그런데 — 여러분이 받아 가신 코드베이스에는 이미 .env 한 줄로 ElevenLabs 로 갈아끼울 수 있는 토대 가 들어 있어요. Step 5 · Step 6 의 복선 박스 두 자루가 여기서 회수됩니다.
학습 단순도를 위해 본문에선 덜어낸 압축본 으로 흘렸지만, 코드베이스의 실제 클래스 는 더 부피가 큰 자루였죠.
(1) .env 한 줄 swap
# .env
TTS_PROVIDER=openai # ← 디폴트. 오늘 강의 본문에서 본 그 path
# TTS_PROVIDER=elevenlabs # ← 이 한 줄로 즉시 ElevenLabs 라인업으로 swap
ELEVENLABS_API_KEY= # https://elevenlabs.io 에서 발급 (무료 티어 月 10,000자)
ELEVENLABS_TTS_MODEL=eleven_multilingual_v2
ELEVENLABS_DEFAULT_VOICE_ID=21m00Tcm4TlvDq8ikWAM # 매핑 표 누락 시 폴백 voice
이 한 줄 swap 이 동작하는 진짜 이유 는 — TextToSpeechModel 인터페이스 추상화 의 효과예요. Step 5 의 VoiceSynthesisService 가 어떤 프로바이더 구현체가 들어올지 모른 채 인터페이스만 부르고 있던 그 구조.
그 구조가 그대로 살아 있어서, 코드 0 줄 수정으로 백엔드의 voice 가 갈리는 모습이에요. application.yml 에는 두 프로바이더의 default 모델/voice 가 동시에 박혀 있고, spring.ai.model.audio.speech selector 가 .env 의 TTS_PROVIDER 값을 받아 둘 중 하나의 빈만 활성화 해요.
(2) mood key → voice 식별자 매핑 표 (코드베이스의 VoiceSynthesisService 안)
코드베이스의 실제 서비스에는 추상 mood 키 와 프로바이더별 voice 식별자 의 매핑 표가 두 자루 박혀 있어요. 프론트는 추상 키만 던지고, 실제 voice 식별자는 활성 프로바이더에 따라 자동 매핑 곳이에요.
| mood key | OpenAI voice (gpt-4o-mini-tts) |
ElevenLabs voice UUID (prebuilt) |
|---|---|---|
bright |
marin |
5I7B1di44aCL15NkP0jn (Kanna — narrator 여성) |
warm |
coral |
5n5gqmaQi9Ewevrz7bOS (Sian — calm/soft 여성) |
calm |
cedar |
pNInz6obpgDQGcFmaJgB (Adam — deep narrator 남성) |
cheerful |
ash |
ErXwobaYiN019PkySvjV (Antoni — 활기 narrator 남성) |
⚠️ Spring AI 1.1 의 ElevenLabs 모델은 portable
TextToSpeechOptions.voice()슬롯을 무시 해요.instanceof ElevenLabsTextToSpeechOptions체크로만 runtime options 를 인식하기 때문이에요 (OpenAI 도 동일 패턴). 그래서 코드베이스의VoiceSynthesisService는 — 활성 프로바이더에 맞춰ElevenLabsTextToSpeechOptions또는OpenAiAudioSpeechOptions를 명시 빌드 하는 한 단 분기 가 들어가 있어요. portable 인터페이스만 믿고 voice 를 박으면 — type 이 안 맞아서 무시되고 application.yml 의 default voice 로 폴백 되는 사고가 나거든요. 이게 — Spring AI 1.1 의 가장 미묘한 결 자리이에요.
(3) swap 결과 한눈에 확인하는 두 가지 방법
받자마자 손에 익혀두면 어떤 voice 로 합성됐는지 즉시 알 수 있어요.
- DevTools Network 탭 —
/api/voice/speak응답 헤더의X-TTS-Provider(openai/elevenlabs) +X-TTS-Voice(marin/ UUID) 가 그대로 보여요. Step 6 의 복선 박스에서 짚었던 그 두 헤더예요. GET /api/voice/info한 번 찍기 —{"success": true, "data": {"provider": "elevenlabs"}}같은 한 줄 JSON 으로 현재 활성 프로바이더 만 단독 확인.
🎯 한 번 직접 들어보기 — 같은 텍스트가 완전히 다른 톤으로
받아가신 코드베이스를 띄운 뒤, ElevenLabs 키를 발급받아 다음 두 줄만 해보세요.
# 1) ElevenLabs 가입 → 키 발급 (https://elevenlabs.io)
echo 'ELEVENLABS_API_KEY=sk_...' >> .env
sed -i '' 's/TTS_PROVIDER=openai/TTS_PROVIDER=elevenlabs/' .env # mac 기준
./run.sh up
같은 캐릭터, 같은 텍스트로 /api/voice/speak 를 부르면 — 완전히 다른 톤 의 목소리가 떨어져요. Step 5 에서 박은 chatModel.call(prompt) 한 줄도, Step 6 에서 박은 ResponseEntity<byte[]> 한 줄도 변하지 않았어요.
인터페이스 추상화 한 단의 진짜 효과 가 지금 손끝에서 살아나는 모습이에요. Day 2 의 프로바이더 추상화가 — 청각 출력 모달리티에서도 같은 방식으로 흐르는 결정적 풍경이고요.
숙제 한 자루 (선택): 코드베이스의
VoiceSynthesisService안엔 mood key → voice 식별자 매핑 표가 두 자루 박혀 있어요. 오늘 ai-friends 의 캐릭터 4 명을 각각 어떤 mood 에 넣을지 는 정답이 없어요. 본인의 게임 시나리오에 맞게 — ElevenLabs Voices 탭에서 voice UUID 를 골라 매핑 표를 갈아끼워 보세요. "하리 = bright = Kanna UUID" 같은 결정이 캐릭터 정체성의 한 자루 가 되는 자리이에요.
4. 브랜치 박제 — day09-voice (Day 별 브랜치 박제 규약)
오늘 코드베이스 상태를 브랜치로 박제해두세요. Step 단위 커밋이 5 개 들어가 있어야 해요. 본 강의의 오늘의 브랜치 박제 지점이에요.
cd lecture-source-code/ai-friends
git log --oneline -10 # Day 9 의 Step 단위 커밋 흐름 확인
git push origin day09-voice # 원격 저장소가 있다면
오늘의 박제된 5 개 커밋 (Step 1~2 는 이론 Step 이라 코드 변경 0 — Step 단위 커밋 규약의 이론 Step 예외 — 그래서 커밋 생략):
브랜치: day09-voice
박제 커밋:
Step 3: cba078a feat: add VoiceTranscriptionService with TranscriptionModel + AudioTranscriptionPrompt (Day 9 Step 3)
Step 4: f28c731 feat: add VoiceTranscriptionController with whitelist + 10MB guard + ApiResponse (Day 9 Step 4)
Step 5: ee6d7ec feat: add VoiceSynthesisService with TextToSpeechModel + 4000-char guard (Day 9 Step 5)
Step 6: ddc2246 feat: add VoiceSpeechController POST /api/voice/speak with audio/mpeg binary response (Day 9 Step 6)
Step 7: 2380055 feat: add CharacterVoiceService + Controller for 5-stage voice pipeline (Day 9 Step 7)
Step 1~2 에 커밋이 없는 진행 — 모델 라인업 매트릭스 (Step 1) + 자매 추상화 시그니처 + 마이크 녹음 시연 (Step 2) 은 코드 변경이 한 줄도 없는 이론 Step 곳이에요. Step 단위 커밋 규약의 예외 — 코드 변경 0 인 Step 은 커밋 생략 이 그대로 다시 등장한. 모든 Step 이 커밋을 만들어야 하는 건 아니다 는, Day 8 의 단계 그대로예요.
이후 어떤 Day 든 git checkout day09-voice 로 오늘 상태로 돌아올 수 있어요. Step 7 의 CharacterVoiceService.converse(...) 시점만 보고 싶다 같은 요구도 한 줄로 가능해요.
오늘 정리한 코드베이스의 voice 패키지 구성 을 한 부분에 정리해둘게요.
src/main/java/kr/spartaclub/aifriends/voice/
├── service/
│ ├── VoiceTranscriptionService.java (Step 3)
│ ├── VoiceSynthesisService.java (Step 5)
│ └── CharacterVoiceService.java (Step 7)
├── controller/
│ ├── VoiceTranscriptionController.java (Step 4)
│ ├── VoiceSpeechController.java (Step 6)
│ └── CharacterVoiceController.java (Step 7)
├── dto/
│ ├── TranscribeResponse.java (Step 3~4)
│ └── SpeakRequest.java (Step 5~6)
└── exception/
└── VoiceException.java (Step 3, VC001~VC007)
src/test/java/kr/spartaclub/aifriends/voice/
├── service/
│ ├── VoiceTranscriptionServiceTest.java
│ ├── VoiceSynthesisServiceTest.java
│ └── CharacterVoiceServiceTest.java
└── controller/
├── VoiceTranscriptionControllerTest.java
├── VoiceSpeechControllerTest.java
└── CharacterVoiceControllerTest.java
Service 3 개 + Controller 3 개 + DTO 2 개 + Exception 1 개 + Test 6 개 — 오늘의 주제에 들어간 모든 파일 모습이에요.
5. 중간 합류 학생 가이드 (./run.sh 한 줄 실행 표준)
오늘 처음 합류하셨거나, 지난 Day 를 못 따라오셨다면 이 4 단계만 따라오세요.
# 1. Day 9 시작 기준점으로 체크아웃
git clone <repo-url> ai-friends
cd ai-friends/lecture-source-code/ai-friends
git checkout day09-voice
# 2. 환경 변수 세팅
cp .env.example .env
# 3. .env 채우기
# - OPENAI_API_KEY (gpt-4o-transcribe STT + gpt-4o-mini-tts — 본 강의는 모킹 디폴트, 실호출 시에만 필요)
# - GEMINI_API_KEY (ChatModel — Day 1 부터의 베이스 키, 살아 있어야 함)
# - GEMINI_MODEL=gemini-2.5-flash-lite (코드베이스 디폴트)
# - MYSQL 비밀번호 (Day 5 ChatMemory 회귀 안 깨지게)
# 4. 앱 기동 — ./run.sh 가 1 순위 (본 강의의 실행 표준)
./run.sh up
💡 중간 합류 팁 — Day 9 의 학생 실습은 모킹 기반 자리이에요. 실제 OpenAI
gpt-4o-transcribe/ TTS 호출 은 강사 시연 + 본인 카드로 굳이 돌리고 싶은 학생만 의. 유닛 테스트는 OPENAI_API_KEY 없이도 모두 그린 이고 (모킹 100%), 통합 시연만 키가 필요 해요. 지갑 안전 + 감각 100% 의 디폴트예요.
6. Day 10 (비디오) 복선 — 비용 충격 과 비동기 폴링 의 첫 부분
자, 오늘의 마지막 주제이자 다음 시간의 첫 글자.
오늘 우리는 — 음성이 텍스트의 30~50 배 라는 비용 감각을 손에 정리했어요. 그런데 — 다음 시간 만날 모달리티는 그보다 또 몇 십 배 예요. 비디오. 5 초짜리 짧은 클립 한 개 = 텍스트 호출 수천 회 분량 이 가능한 곳예요.
그래서 Day 10 은 — 시연 중심 + 선택 실습 으로 정리했어요. 학생 본인 카드로 무턱대고 돌리는 곳이 아니에요. 강사가 강의장 스튜디오 키로 1~2 개의 짧은 클립 을 시연으로 풀고, 「원하는 학생만 본인 판단하에」 손에 정리하는. Day 10 의 시연 중심 + 선택 실습 흐름 이 그대로 다시 만나요.
| 모달리티 | Day | 입력 | 출력 | 비용 (텍스트 호출 1 회 기준) |
|---|---|---|---|---|
| 텍스트 | Day 1~6 | 텍스트 | 텍스트 | 1 배 |
| 이미지 출력 | Day 7 | 텍스트 | 이미지 | 5~20 배 |
| 이미지 입력 (Vision) | Day 8 | 이미지+텍스트 | 텍스트 | 1.5~2 배 |
| 음성 입출력 | Day 9 (오늘) | 음성/텍스트 | 음성/텍스트 | 30~50 배 |
| 비디오 (시연) | Day 10 (다음 시간) | 텍스트 | 비디오 | 수천 배 |
다음 시간 만날 결정적 새 패턴 한 가지 — 비동기 처리 폴링 (Async Polling). 비디오 생성은 한 번의 동기 호출로 떨어지지 않아요. Job 제출 → 폴링 → 완성 통지 의 방식으로 흐르는 부분이에요.
Day 6 SSE 스트리밍 도 Day 9 binary 응답 도 아닌 — 세 번째 응답 패턴 이 처음으로 등장해요.
비디오 모델 라인업 미리 던져둘게요 (2026-04 기준).
Veo 3(Google) — Gemini 패밀리, 사운드 포함 비디오, 가장 비싼 라인 중 하나Sora(OpenAI) — 2024 말 공개 후 2026 에 API 가용, 최고 품질 + 최고 가격Runway Gen-4— 영상 업계 디폴트, API 안정적Luma Dream Machine— 빠른 생성 + 중간 가격대Kling(Kuaishou) — 중국 라인, 가성비 + 길이 강점
무료 대안도 정리할게요.
Stable Video Diffusion— 로컬 GPU 에서 짧은 클립 (4~25 프레임)- GIF + Ken Burns 효과 — 정적 이미지를 움직이는 듯한 방식으로 흘리는 가짜 비디오 의 감각
- 이미지 시퀀스 → ffmpeg 합성 — Day 7 ImageModel 로 만든 이미지 여러 장을 비디오로 묶는 진행
💡 Day 10 클로저 한 줄
"음성도 텍스트의 30 배인데, 영상은 그보다 또 몇 십 배. 그래서 비디오는 — 시연으로 만나는 부분. 강사가 시연으로 풀고, 원하는 학생만 본인 판단하에 손에 정리하는. 그리고 — 비동기 폴링 이라는 세 번째 응답 패턴 이 처음으로 등장해요. Day 6 SSE 도 Day 9 binary 도 아닌 새. Day 10 에서 만나요."
7. 5 감 진화의 마지막 한 발 (Day 1~10)
자, Day 1 부터 Day 9 까지 — ai-friends 가 익힌 능력의 진화 를 한 도식에 정리할게요. 오늘이 청각 모습의 닫힘 이고, 다음 시간은 5 감 진화의 마지막 한 발 모습이에요.
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 2 의 프로바이더 추상화 + 자매 추상화 (Day 7 ImageModel · Day 8 Media · Day 9 TranscriptionModel / TextToSpeechModel) 라는 통일된 방식 위에서 한 부분씩 익혀요. 오늘 세 추상화가 한 메서드 안에서 손을 잡는 부분까지 —
Spring AI 가 정리해둔 추상화의 일관성 이 진짜로 살아있는 모습이었어요.
그리고 Day 10 너머 한 줄만 더 정리할게요. 비디오까지 다 끝나면 — 이제 LLM 이 스스로 도구를 골라 쓰는으로 갑니다. 에이전트의 시작점. @Tool 어노테이션 한 줄로 — 내가 만든 함수를 LLM 이 직접 호출 하는 그림. Day 11 에서 만나요.
자, Day 9 의 모든이 닫혔어요. 세 자매 추상화가 한 자리에서 손을 잡는 지점 — 비용의 30~50 배 충격 — 바이너리 응답의 첫 부분 — 5 단 파이프라인의 응축 까지. 7 가지 그림 이 한 도시에 모였어요.
자, 마무리 섹션은 여기까지. 본격적으로 손을 더럽혀볼 시간입니다. Mission 섹션 으로 넘어가서 과제와 생각해볼 주제를 받아보세요!
Mission — 오늘의 과제
오늘 손에 정리한 여섯 가지를 내 손에서 한 번 더 펼쳐보는 곳이에요.
TranscriptionModel+TextToSpeechModel의 자매 추상화Resource ↔ MultipartFile변환ApiResponse표준 패턴의 binary 예외 절- 거울 대칭의 두 서비스
- 5 단 파이프라인 통합
- 음성 30~50 배 비용 감각 Day 5 의 ChatMemory 를 5 단 파이프라인에 끼워넣는 진행, Day 7/8 가드 패턴의 세 번째 등장, 프로바이더 추상화의 음성 버전 마지막 회수 까지 — 세 가지 흐름의 도전 과제를 정리할게요.
💡 과제 작업 시 공통 가이드
- 모든 과제는 ai-friends 프로젝트의 별도 브랜치 에서 작업하세요. (예:
day09-assignment1-chat-memory)- 새 컨트롤러/서비스를 만들 때는 본 강의의 ApiResponse 표준 패턴 을 그대로 따르세요 —
ResponseEntity<ApiResponse<T>>. (단, binary 응답 은 Step 4 의 예외 절 그대로 rawbyte[]도 허용.)- 새 ErrorCode 가 필요하면 VC 시리즈 (
VOICE_DAILY_QUOTA_EXCEEDED,VOICE_LOCAL_PROVIDER_FAILED등) 로 박으세요. Day 9 의 VC001~VC007 단계 옆에 자연스럽게 이어지는.- 이미 만든 세 서비스/컨트롤러 (
VoiceTranscriptionService·VoiceSynthesisService·CharacterVoiceService와 그 컨트롤러들) 의 코드를 직접 수정하지 마세요. 중간 합류 학생이git checkout day09-voice로 들어왔을 때 기준 코드의 모양이 흐트러지지 않게 하기 위함이에요.ChatModel·TranscriptionModel·TextToSpeechModel인터페이스 주입 원칙은 그대로 유지 (Day 2 의 감각, 오늘 다시 등장).ChatClient는 Day 11 부터 등장 — 본 과제에선 사용 금지.
[구현 1] 5 단 파이프라인에 ChatMemory 끼워넣기 — Day 5 회수 ⭐⭐
배경 시나리오
ai-friends 의 PM 이 데모 시연을 보고 한 가지 부탁을 들고 왔어요.
"튜터님, 5 단 파이프라인 일단 완성된 거 너무 좋네요. 그런데 — 지금은 매번 첫 대화 같아요. 어제 캐릭터랑 음성으로 5 번 대화했어도, 오늘 새로 들어가면 캐릭터가 그걸 기억 못해요. 사용자가 '어제 우리가 어디 가기로 했더라?' 같이 물어봐도 '네? 처음 듣는 얘기인데요?' 같은 방식이 나와요. Day 5 에서 정리해둔
JdbcChatMemoryRepository를 5 단 파이프라인에 얹어서 캐릭터가 어제 대화를 기억하면서 답하는 부분 만들어 주세요."
Step 7 에서 우리는 의도된 미적용 으로 ChatMemory 를 미뤄뒀어요. 본 과제는 그 곳의 재등장. Day 5 의 JdbcChatMemoryRepository 감각이 세 번째 모달리티 (음성) 에서도 그대로 동작함을 손에 정리하에요.
💡 왜 굳이 이 과제를 할까요?
- Day 5 ChatMemory + Day 9 5 단 파이프라인의 교차점 — 두 큰을 한 부분에 합치는 감각. 텍스트로 들어간 메모리 추상화가 음성 입출력의 한가운데 에서도 그대로 살아있다는 진행이 손에 단단히 들어가요.
ChatModelvsChatClient의 결정 부분 — Day 11 부터 등장하는ChatClient+MessageChatMemoryAdvisor방식으로 가면 어드바이저 한 줄 로 풀려요.- 그런데 본 강의는 Day 11 까지 ChatClient 도입 안 함 이 원칙 — 그러니 수동으로
ChatMemory.add/chatMemory.get하는 방식으로 가야 해요. - 손이 한 번 더 들어가야 ChatClient 의 추상화가 다음 Day 에 더 진하게 들어와요.
- 그런데 본 강의는 Day 11 까지 ChatClient 도입 안 함 이 원칙 — 그러니 수동으로
세션 ID 의 결정 부분 — 어떤 conversationId 로 메모리를 갈라놓을지 가 핵심.
soulmateId 단독 방식이면 같은 캐릭터 = 같은 메모리. userId + soulmateId 조합 이면 유저별 + 캐릭터별 격리. 두 방식 모두 트레이드오프가 다른데, 그 결정의 근거를 PR description 에 한 줄로 정리는.
✅ 요구사항
- 시그니처 — 두 가지 중 하나 선택
- (A) 기존
CharacterVoiceService.converse(soulmateId, audio)를 그대로 유지하면서 세션 ID 는 soulmateId 그대로 방식으로 가는 진행 - (B)
(soulmateId, conversationId, audio)로 시그니처 확장. 세션 격리 단위가 외부 결정 으로 풀리는 진행 - 선택의 근거를 PR description 에 한 줄 — "단일 사용자 학습용이라 (A) 를 선택, 운영급에선 (B) 가 정석" 같은 방식으로 박기
- ChatMemory — 5 단 파이프라인의 3 단째에 박힘
- STT 결과 텍스트 획득 (기존 그대로)
chatMemory.get(conversationId, lastN)— 직전 N 개 메시지 가져오기 (예: N=10)Prompt(List.of(SystemMessage, ...history, currentUserMessage))— 과거 메시지 prepend + 현재 메시지 append 의 흐름- ChatModel 응답 획득 (기존 그대로)
chatMemory.add(conversationId, userMessage)+chatMemory.add(conversationId, assistantMessage)— 양쪽 모두 누적- TTS 변환 (기존 그대로)
JdbcChatMemoryRepository사용 —InMemoryChatMemoryRepository회귀 금지 (Day 5 이후 영속 저장 원칙)- 새 ErrorCode 불필요 — 기존 위에 데코레이션 만 정리하는 부분라 새 V/VC 코드는 안 써도 OK
- 테스트 추가 (필수 2 케이스)
- 메모리 누적 검증 — Mockito spy 또는 Mock 으로
ChatMemory.add(...)가 userMessage + assistantMessage 두 번 호출됨을 verify - 메모리 prepend 검증 —
ArgumentCaptor<Prompt>로 ChatModel 의 인자를 잡아서 Prompt 안에 history 메시지가 들어가 있음 을 검증
확인 방법
./run.sh up
# 1) 첫 대화 — 캐릭터한테 자기 이름 알려주기
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
-F "audio=@./test-audio/introduction.mp3" \
--output reply1.mp3
# (재생) 캐릭터가 자기소개 듣고 답함
# 2) 두 번째 대화 — "내 이름 뭐였더라?" 물어보기
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
-F "audio=@./test-audio/question.mp3" \
--output reply2.mp3
# (재생) 캐릭터가 *첫 대화에서 들은 이름* 을 그대로 답해야 정답
# (DB 의 SPRING_AI_CHAT_MEMORY 테이블에 두 대화의 4 개 메시지가 누적되어 있는지 함께 확인)
# 3) 단위 테스트
./gradlew test --tests "*CharacterVoiceServiceTest*memory*"
💡 힌트
- Day 5 의 두 메서드가 핵심 —
chatMemory.get(conversationId, lastN)/chatMemory.add(conversationId, message)두. 빈 주입은ChatMemory chatMemory인터페이스 그대로. - conversationId 의 학습용 결 — 단일 사용자 시나리오면 soulmateId 그대로 도 OK. 문자열 형식 이 정석이면
"soulmate-" + soulmateId같은 방식으로. - Prompt 조립 모양 —
Prompt(List.of(systemMessage, ...history, userMessage))— List 의 spread 가 자연스럽게 들어가요.history가 List형식이면 그대로. - lastN 의 적정선 — 학습용은 10~20 정도. 너무 크면 토큰 비용 폭발, 너무 작으면 문맥 단절. Day 5 에서 들어간 감각 그대로.
제약 / 금지
ChatClient사용 금지 — Day 11 부터 본격 등장. 본 과제는ChatModel인터페이스 + 수동 ChatMemory 방식으로만. MessageChatMemoryAdvisor 같은 어드바이저 모습도 사용 금지.InMemoryChatMemoryRepository사용 금지 — Day 5 이후 영속 저장 원칙. DB 영속성 이 살아 있어야 어제 대화를 오늘 기억하는 그림이 진짜로 들어가요.- 기존 세 서비스 코드 수정 금지 —
CharacterVoiceService본체에 정리하는 게 아니라, 새 서비스 (CharacterVoiceMemoryService같은 방식) 또는 Service 의 새 메서드 (converseWithMemory(...)) 로 분리하세요. 중간 합류 학생의 기준 코드 보호.
예상 소요 시간
60~90 분. (난이도 ⭐⭐ — Day 5 감각이 들어가 있으면 손이 빠르게 움직이는. 5 단 파이프라인의 어디에 ChatMemory 를 끼워넣는지 의 위치 결정만 한 번 손이 멈출 수 있음.)
[구현 2] 비용 가드 박기 — VoiceDailyQuotaGuard ⭐⭐⭐
배경 시나리오
같은 PM 이 한 가지 더 들고 왔어요.
"튜터님, 음성 데모 시연 영상 올렸더니 — 다른 부서 인턴분들이 너무 좋다고 다들 시연 해보셔서, 어제 하루 OpenAI STT (
gpt-4o-transcribe) 호출 800 회 가 떴어요. 사내 무료 크레딧이 1 주일에 1 회 분량으로 잡혀있는데... 일일 STT 한도 + 일일 TTS 한도 같이 정리해주실 수 있어요? 음성은 비용이 텍스트의 30~50 배 라면서요 — 가드 없으면 다음 달 청구서가 무서워요."
Day 7 의 ImageDailyQuotaGuard · Day 8 의 VisionDailyQuotaGuard 의 세 번째 등장 자리이에요. 같은 패턴이 세 번째 모달리티에서도 그대로 살아있는. 음성은 비용이 텍스트 30~50 배라 가드가 진짜 필요한 모습이에요.
💡 왜 굳이 이 과제를 할까요?
- Day 7/8 가드 패턴의 세 번째 등장 — 같은 모양의 세 번째 등장 이라 손에 단단히 박히는. 90% 이상 코드 재사용이 가능해서 손은 빠른데,은 깊어지는 학습.
STT vs TTS 의 분리 결 — 두 호출의 비용 단가가 다르고 (OpenAI STT gpt-4o-transcribe 는 분당 $0.006, OpenAI TTS 는 문자당 과금), 호출 패턴도 다름 (STT 는 음성 길이 비례, TTS 는 텍스트 길이 비례).
한 카운터로 묶기 vs 분리 의 결정이 들어가요.
3. Clock 주입의 세 번째 등장 — Day 8 에서 정리한 자정 경계 검증 의. 같은 패턴이 음성에서도 살아 있는 그림.
✅ 요구사항
VoiceDailyQuotaGuard클래스 신규 생성 —@Component+Clock주입 (Day 8 패턴 답습)- STT 와 TTS 한도 분리
- STT 일일 한도 — 예: 30 회 (
@Value("${aifriends.voice.stt.daily-limit:30}")) - TTS 일일 한도 — 예: 50 회 (
@Value("${aifriends.voice.tts.daily-limit:50}")) - 두에 별도 카운터 (
Map<LocalDate, AtomicInteger> sttCounters,ttsCounters)
- 두 메서드 노출
checkAndIncrementStt()— STT 호출 직전에 박힘checkAndIncrementTts()— TTS 호출 직전에 박힘
- 새 ErrorCode 추가 —
VC008 VOICE_DAILY_QUOTA_EXCEEDED(status 429)
- STT 와 TTS 메시지를 분리 할지 통합할지 도 학생 선택 (한 ErrorCode + 메시지 결 vs 두 ErrorCode 분리)
- 두 서비스에 가드 호출 박기 — 단, 기존 코드 수정 금지
- 새 서비스 (
VoiceTranscriptionGuardedService,VoiceSynthesisGuardedService같은 방식) 를 만들어 기존 서비스를 위임 호출 + 가드 첫 줄에 박기. 또는 AOP@Aspect로 정리하는 도 OK (학습 보너스).
- 테스트 추가 (필수 3 케이스)
- STT 한도 30 회 채우기 → 31 번째 호출에서 VC008 차단
- TTS 한도 50 회 채우기 → 51 번째 호출에서 VC008 차단
- 자정 경계 검증 —
Clock.fixed()로 23:59:59 의 카운터 가 00:00:00 에 0 으로 리셋 됨을 verify
확인 방법
./run.sh up
# application.yml 의 한도를 1 로 낮추고 재기동
# (또는 .env 에 AIFRIENDS_VOICE_STT_DAILY_LIMIT=1 박기)
# 1) STT 첫 호출 — 정상
curl -X POST "http://localhost:8080/api/voice/transcribe" \
-F "audio=@./test-audio/sample.mp3"
# 200 OK + transcribedText
# 2) STT 두 번째 호출 — 한도 초과 차단
curl -X POST "http://localhost:8080/api/voice/transcribe" \
-F "audio=@./test-audio/sample.mp3"
# 429 + VC008
# 3) 단위 테스트
./gradlew test --tests "*VoiceDailyQuotaGuardTest*"
💡 힌트
- Day 8
VisionDailyQuotaGuard의 코드를 그대로 복사 — 패키지 / 클래스명 / 카운터 자료구조 /Clock주입 / 자정 경계 로직까지 90% 이상 재사용 가능. 들어간 패턴의 반복 학습 자리이에요. Map<LocalDate, AtomicInteger>인메모리 결 — 학습용으론 충분. 운영급은 Redis INCR + EXPIRE 가 정석 (생각해볼 주제 3 의 결과 일치).- 자정 경계 검증 —
Clock.fixed(Instant.parse("2026-04-30T23:59:59Z"), ZoneOffset.UTC)→ 카운터 채움 →Clock.fixed(Instant.parse("2026-05-01T00:00:01Z"), ...)→ 카운터 0 확인. - AOP 방식으로 정리하는 보너스 —
@Aspect @Around("@annotation(VoiceQuotaGuarded)")같은 방식으로 풀면 기존 서비스 코드 수정 0 으로 가드가 들어가요. 시간이 남으면 시도해볼만한.
제약 / 금지
- 기존 세 서비스 코드 수정 금지 —
VoiceTranscriptionService·VoiceSynthesisService·CharacterVoiceService의 코드를 한 줄도 건드리지 마세요. 새 서비스 분리 또는 AOP 로 박기. InMemoryChatMemoryRepository같은 회귀 금지 — 이건 ChatMemory 지점이지만, 가드도 분산 환경 의식 의 감각은 살려두기.- 새 ErrorCode 는 VC 시리즈 — Voice 도메인의 코드는 VC008, VC009... 의 방식으로 이어가세요. V (Vision) 시리즈와 섞이지 않게.
예상 소요 시간
60~90 분. (난이도 ⭐⭐⭐ — Day 8 패턴 회수이라 코드 작성은 빠른데, STT/TTS 카운터 분리 + 자정 경계 검증의 두 시점 보존 이 한 단계 더 깊은 감각.)
[구현 3] 프로바이더 스위칭 — 로컬 무료 (whisper.cpp 또는 edge-tts) 시연 ⭐⭐⭐⭐ 🦙
배경 시나리오
PM 이 마지막으로 한 가지를 부탁해요.
"튜터님, 음성 데모는 외부 API 차단된 망 에서도 시연되어야 해요. 회사 보안 정책상 클라우드 API 호출이 막힌 사내망 에서 시연을 해야 한다든지, 전시회 부스 에서 인터넷 없이 시연한다든지. whisper.cpp 로컬 STT + edge-tts 로컬 TTS 조합으로 코드 0 줄 수정 한 채 .env 한 줄로 갈아끼울 수 있게 해주실 수 있나요? Day 8 에서 정리한 Ollama 시연 의 음성 버전이라고 보시면 돼요."
Day 8 의 Ollama 시연 과제 의 음성 버전이에요. 외부 API 0 원 / 완전 오프라인 으로 같은 5 단 파이프라인을 돌리는. 프로바이더 추상화의 마지막 재등장 — 음성 부분 의 첫 감각이에요. 🦙
💡 왜 굳이 이 과제를 할까요?
- 자매 추상화의 진짜 가치 증명 — Step 1 에서 정리한
TranscriptionModel/TextToSpeechModel인터페이스가 진짜로 새 구현체로 갈리는. Spring AI 가 안 가져다 주는 단계 (whisper.cpp / edge-tts) 도 내가 어댑터로 박을 수 있다 는 감각. - 외부 프로세스 호출 진행 —
ProcessBuilder로 CLI 프로세스를 호출 → 결과 파일 읽기 의 패턴은 MCP (Day 17~18) · Tool Calling (Day 11) 에서도 같은 가족이 등장해요. 본 과제가 그 방식의 첫. - 로컬 운영의 현실적 그림자 — 모델 풀 시간 (~5GB), 첫 호출의 모델 로딩 지연, GPU 가속의 차이, 명령어 인자의 보안 (커맨드 인젝션) 같은 방식이 실무 운영의 진짜 흐름 로 들어가요. Day 8 Ollama에서 한 번 본 모습의 심화 버전.
✅ 요구사항
- 최소 한 가지 — STT 또는 TTS 중 한 부분만 — 학습 시간을 고려해서 한 쪽만 정리하면 ⭐⭐⭐⭐. 둘 다 정리하면 ⭐⭐⭐⭐⭐ (보너스 부분).
- STT 로컬 어댑터 —
WhisperCppTranscriptionModel implements TranscriptionModel
whisper.cpp모델 풀 (예:ggml-medium-q5_0.bin또는ggml-base.bin)ProcessBuilder로whisper-cli -m model.bin -f input.wav -otxt -of out같은 방식으로 호출- 결과 텍스트 파일 (
out.txt) 읽어서TranscriptionResponse로 변환
- TTS 로컬 어댑터 —
EdgeTtsSpeechModel implements TextToSpeechModel
edge-ttsPython CLI 호출 (pip install edge-tts후 명령행 도구로)ProcessBuilder로edge-tts --voice ko-KR-SunHiNeural --text "안녕" --write-media out.mp3방식으로 호출- 결과 mp3 파일 읽어서
byte[]또는Resource로 변환
.env의 프로바이더 스위칭 한 줄
SPRING_AI_VOICE_PROVIDER=local(또는gemini/openai)WHISPER_CPP_PATH=/usr/local/bin/whisper-cliWHISPER_MODEL_PATH=/path/to/ggml-base.binEDGE_TTS_PATH=/usr/local/bin/edge-tts
- Spring Bean 등록 —
@ConditionalOnProperty
@ConditionalOnProperty(prefix="spring.ai.voice", name="provider", havingValue="local")로 스위칭- 디폴트 (Gemini/OpenAI) 빈은
havingValue="gemini"또는matchIfMissing=true
- 기존 코드 0 줄 수정 —
CharacterVoiceService/ 컨트롤러 / 다른 서비스의 Java 코드 한 줄도 안 건드림. 인터페이스 주입의 추상화 가치가 진짜로 살아있어야 본 과제의 본질. - 시연 비교 노트 (선택, ⭐⭐⭐⭐⭐ 보너스) — Gemini/OpenAI 응답 vs 로컬 응답의 지연 + 음질 + 비용 차이를 1 페이지로 정리. 표 한 장 + 짧은 후기 두 단락.
확인 방법
# 0) 로컬 도구 설치 (호스트에서 한 번)
brew install whisper-cpp # Mac
# 또는 git clone https://github.com/ggerganov/whisper.cpp && make
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin
pip install edge-tts # Python 3.8+
edge-tts --list-voices | grep ko-KR # 한국어 보이스 확인
# 1) 클라우드 모드 시연 — 디폴트
echo "SPRING_AI_VOICE_PROVIDER=gemini" > .env.demo
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
-F "audio=@./test-audio/sample.mp3" --output reply-cloud.mp3
# (재생) Gemini/OpenAI 응답 — 자연스러운 음질, 응답 시간 5~10 초
# 2) 로컬 모드로 갈아끼움 — .env 한 줄 변경 + 재기동
./run.sh down
echo "SPRING_AI_VOICE_PROVIDER=local" > .env.demo
echo "WHISPER_MODEL_PATH=/path/to/ggml-base.bin" >> .env.demo
echo "EDGE_TTS_PATH=$(which edge-tts)" >> .env.demo
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
-F "audio=@./test-audio/sample.mp3" --output reply-local.mp3
# (재생) 로컬 응답 — 음질 약간 떨어지지만 *외부 API 0 호출* / 완전 오프라인
# 3) 단위 테스트
./gradlew test --tests "*WhisperCppTranscriptionModelTest*"
./gradlew test --tests "*EdgeTtsTextToSpeechModelTest*"
💡 힌트
- whisper.cpp 빠른 시작 — Mac 이면
brew install whisper-cpp한 줄. 모델은ggml-base.bin(~140MB) 이 학습용 디폴트. medium 이상은 ~1.5GB 라 첫 풀 시간 들어가요. - edge-tts 빠른 시작 —
pip install edge-tts한 줄. Microsoft Edge 의 Read Aloud 음성을 무료로 호출 하는. 한국어 보이스 수십 종 가능 (ko-KR-SunHiNeural여성,ko-KR-InJoonNeural남성 등). ProcessBuilder안전 사용 패턴 —new ProcessBuilder("edge-tts", "--voice", voice, "--text", text, "--write-media", outPath)의 방식으로 명령어 인자를 List 로 분리..command("edge-tts --voice " + voice + ...)같이 문자열 합치기 방식은 절대 금지 (커맨드 인젝션).- 임시 파일 디렉토리 —
Files.createTempFile("audio-", ".wav")/Files.createTempFile("tts-", ".mp3")방식으로 임시 파일 박고, 호출 후Files.delete(...)로 정리. @ConditionalOnProperty결 — Day 2 에서 손에 정리한 패턴 그대로. 세 곳 이상의 빈 (cloud / local / mock) 이 같은 인터페이스 위에서 갈리는 그림.
제약 / 금지
CharacterVoiceService/ 컨트롤러 코드 0 줄 수정 — 인터페이스 주입의 가치가 진짜로 살아있어야 본 과제의 본질. Java 코드 한 줄이라도 건드리면이 깨짐.ProcessBuilder의 명령어 인자에 사용자 입력 직접 박기 금지 — 커맨드 인젝션 의 정통. 반드시 List 로 분리 해서 박으세요. 셸 escape 우회 시도 금지.- 모델 파일 경로 하드코딩 금지 —
.env또는application.yml의${WHISPER_MODEL_PATH}같은 방식으로 외부화. 학생 머신마다 경로가 다른이라 하드코딩하면 재현성 0. - 새 프로바이더 어댑터 작성을 회피하지 마세요 — Spring AI 가 기본 제공하지 않는 부분라 학생이 직접 어댑터를 만들어보는 게 본 과제의 본질. 기존 OpenAI/Gemini 의 옵션만 갈아끼우는 진행 금지.
예상 소요 시간
120~180 분. (난이도 ⭐⭐⭐⭐ — 외부 도구 설치 + 어댑터 작성 + ProcessBuilder 안전 호출 의 3이 한 곳에 모임. 한 번 익숙해지면 MCP / Tool Calling 까지 펼쳐지는 평생 자산.)
제출 방법
- 세 과제는 ai-friends 프로젝트에서 각각 별도 브랜치 로 작업 권장.
- 과제 1:
day09-assignment1-chat-memory - 과제 2:
day09-assignment2-quota-guard - 과제 3:
day09-assignment3-local-voice - PR 에 (1) 음성 대화 시연 영상 또는 audio 파일 (reply1.mp3 + reply2.mp3 의 기억 회수 모습) 또는 (2) 가드 발동 테스트 통과 로그 + 자정 경계 검증 그린 캡처 또는 (3) 클라우드 vs 로컬 응답의 지연/음질/비용 비교 노트 를 첨부하면 리뷰가 훨씬 빨라집니다.
생각해볼 주제
이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 세 자매 추상화 · 5 단 파이프라인 · 음성 30~50 배 비용 · binary 응답 의 결정들을 한 발 떨어져 바라보고, "왜 이렇게 결정했지?" 와 "다른 길은 없었나?" 를 사고하는 모습이에요. 면접에서도 자주 등장하는 토픽들이라, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다.
주제 1 — 음성 + 캐릭터 페르소나의 감정 톤 — TTS 보이스 선택
Step 7 에서 우리는 캐릭터의 페르소나를 SystemMessage 의 자연어 로 정리했어요.
이름 / 성격 / 취미 / 말투. 그런데 — 음성으로 답하는 모습에서 가장 큰 체감 페르소나 는 목소리 그 자체 예요.
같은 답변 텍스트라도 남자 저음 vs 여자 고음 vs 어린아이 톤 이 결정적으로 다른 캐릭터를 만들어요.
사용자가 "이 캐릭터는 분홍 토끼. 발랄하고 어린 흐름" 이라고 머릿속에 그린 페르소나가 — 낮은 남자 보이스 로 답이 흘러나오면 순간 캐릭터가 박살 나요.
OpenAI TTS 의 voice 옵션은 6 개 (alloy / echo / fable / onyx / nova / shimmer) 가 있고, edge-tts 는 언어별 수십 개 voice 가 있어요.
한국어만 해도 ko-KR-SunHiNeural (여성) / ko-KR-InJoonNeural (남성) / ko-KR-BongJinNeural (어린 톤) 등 여러. 그럼 — 각 캐릭터마다 어떤 voice 를 매핑할지 의 결정은 어디서 어떻게 정리해야 할까요?
가장 단순 한 방식은 — Soulmate.voiceModel 컬럼에 문자열로 정리해두기. 캐릭터 생성 시 입력 폼 에서 사용자가 직접 고르거나, 기본값 으로 박힘. 다음 단 은.
VoiceProfileMapper 라는 별도 빈 으로 분리해서 캐릭터 성격 → voice 자동 매핑. ("발랄" → BongJinNeural 어린 톤, "차분" → SunHiNeural 여성 등).
그 다음 은 LLM 자체가 voice 도 추천 하는 진행 — 캐릭터 생성 시 "이 캐릭터에 어울리는 보이스는?" 까지 LLM 에게 물어보는 그림.
각 결마다 유연성 / 운영 부담 / 캐릭터 일관성 의 트레이드오프가 다 달라요. 그리고 — voice 를 너무 자주 바꾸면 사용자에게 같은 캐릭터로 안 들리는 일이 생겨요. 모바일 게임의 성우 캐스팅 과 같은 무게가 AI 보이스 매핑 에서도 그대로 적용돼요.
🎯 핵심 질문 — 음성 페르소나 (voice) 를 캐릭터 정보에 정리하는 부분은 어디인가?
Soulmate.voiceModel컬럼?VoiceProfileMapper라는 별도 빈? 또는 LLM 자체에게 voice 추천을 맡기는 진행? 그리고 voice 를 너무 자주 바꾸면 사용자에게 같은 캐릭터로 안 들리는 일 은 어떻게 막을 것인가? 한 번 들어간 voice 의 변경 가능성 을 어떻게 통제할 것인가?
생각해볼 자료:
- Step 5 의
TextToSpeechPrompt(text)— 디폴트 보이스 만 사용. 옵션 (OpenAiAudioSpeechOptions.builder().voice(...)) 를 어떻게 박을지의 결정. - Day 7 portrait 의 — 그림이 캐릭터 정체성 이었던. 음성은 더 강한 정체성 — 목소리 한 번 들으면 잊히지 않는 인지 무게.
- 모바일 게임의 캐릭터 보이스 결정 트리 — 성우 캐스팅이 프로젝트 후반에 바뀌면 캐릭터가 죽는다 는 업계 격언. AI 보이스 매핑 도 같은 가족.
주제 2 — PII (개인식별정보) 가 음성에 들어가 있을 때 — 로깅과 저장
오늘 우리는 사용자 마이크 음성을 백엔드에 업로드 → STT → 로그 의을 정리했어요. Step 4 의 log.info("...") 한 줄에는 변환된 텍스트 그대로 가 박힐 수 있어요.
그런데 — 음성은 텍스트보다 PII 가 더 진하게 들어가요. 사용자가 자기 이름 / 주소 / 전화번호 / 카드 번호 / 주민등록번호 를 음성으로 말하면, STT 가 그걸 텍스트로 변환 해서 로그에 박히고 → ELK 로 흘러가고 → S3 백업으로 영구 저장 되는 자리이 있을 수 있어요. 그리고. 목소리 자체 도 PII 예요. 음성 지문 (voiceprint) 으로 화자를 식별할 수 있는 진행이라, GDPR / 개인정보보호법은 음성을 생체정보로 분류 해요.
업계의 정석은 다섯 가지 가드 가 있어요.
- PII 마스킹 — 이름/번호 패턴을 정규식 또는 NER 로 마스킹. 예:
"전화번호 010-XXXX-1234 입니다"→"전화번호 010-****-**** 입니다" - 오디오 자체 미저장 — 메모리 → STT → 즉시 폐기, 디스크에 떨어뜨리지 않기
- 짧은 보존 기간 — S3 lifecycle 로 24~72 시간 후 자동 삭제
- 암호화 — S3 SSE-KMS + 전송 구간 TLS
- 동의 절차 — 음성 업로드 시 명시적 동의 체크박스
본 강의 코드는 어디까지 비어 있을까요? VoiceTranscriptionService.transcribe(...) 는 변환된 텍스트를 그대로 응답 만 하고 DB 에 저장하지 않아요. 로깅도 길이만 박고 본문은 안 박는 방식으로 갈 여지.
그런데 — 학생들이 직접 박을 흐름 이 있어요. log.info("transcribed: {}", text) 한 줄을 무심코 정리하면.
PII 가 로그에 그대로 흘러요.
🎯 핵심 질문 — 사용자 음성에 주민등록번호 가 들어가 있고, 우리 백엔드가 그걸 STT 로 텍스트화 해서 로그 에 들어갔다고 가정. 운영에서 이 방식을 막는 결정 트리는? PII 마스킹 / 오디오 미저장 / 보존 기간 제한 / 암호화 / 동의 절차 중 어느이 1 순위이고, 본 강의 코드는 어디까지 비어 있는가, 어디서부터 학생이 직접 정리해야 하는가? 그리고 목소리 지문 (voiceprint) 도 PII 라는 진행은 우리 코드의 어느에 영향 을 주는가?
생각해볼 자료:
- Day 8 의 URL 기반 Vision SSRF 주제 — 보안 결정 트리이 음성에서도 그대로 살아있는 모양. 다른 모달리티, 같은.
- GDPR / 개인정보보호법 — 음성 지문 을 생체정보 로 분류한 결정. 보관 / 삭제 / 동의이 일반 PII 보다 한 단 더 깐깐.
- Spring Boot 인스타그램 클론의 마스킹 패턴 — 같은 방식을 음성 에서 어떻게 적용할지. @JsonIgnore / Logback Pattern Layout / Spring AI Observability redactor 같은 자리들.
주제 3 — 5 단 파이프라인의 스트리밍 가능성 — 사용자 체감 지연 줄이기
오늘 우리는 5 단 파이프라인을 동기 + 일괄 방식으로 정리했어요.
사용자가 마이크에 1 분 말하면 — STT 5 초 + ChatModel 3 초 + TTS 4 초 = 총 12 초 후에야 첫 음성이 흘러나와요.
사용자 입장에선 답답한 침묵의 12 초. 진짜 사람과 대화하는에서 12 초 침묵은 대화의 죽음 지점이에요.
스트리밍 가능성은 몇 단 으로 펼쳐져요. 첫 단 은 — StreamingTextToSpeechModel (Spring AI 1.1.x 에 있음, Step 6 에서 한 줄 언급) 로 TTS 를 토큰 단위 스트리밍. 응답 전체가 완성되기 전에 문장 단위로 흘러나오는. 두 번째 단 은.
Day 6 의 ChatModel 토큰 단위 응답 (Flux
각 단계마다 복잡도 / 사용자 체감 / 비용 / 모델 호환성 의 트레이드오프가 다 달라요. 첫 단 (TTS 스트리밍) 만 정리해도 사용자 체감이 12 초 → 6~8 초 로 줄어요. 두 번째 단 까지 정리하면 4~5 초. 세 번째 단 까지 가면 2~3 초 거의 사람 대화 속도. 단, 단을 더할수록 코드 복잡도가 기하급수적으로 증가 하고, 모델 호환성 (지원 안 하는 프로바이더) 의 가지치기가 늘어요.
그리고 실시간 음성 대화 (예: ChatGPT Voice Mode / Gemini Live) 의은. 우리가 정리한 동기 일괄 흐름 에서 몇 단의 진화 가 더 필요해요. 클라이언트가 WebSocket 으로 양방향 음성 스트리밍 + 서버 측이 음성 자체를 모델에 직접 입력 (multimodal native).
본 강의의 5 단 파이프라인이 그 형태의 1.0 이라면, ChatGPT Voice Mode 는 2.0~3.0 곳이에요.
🎯 핵심 질문 — 5 단 파이프라인의 총 응답 시간 12 초 를 줄이려면 어디부터 손대야 하는가? TTS 스트리밍 / ChatModel 문장 단위 분할 / STT 실시간 — 각 단계 트레이드오프와 학습 단계별 적정 단계 는? 주니어 개발자 / 사이드 프로젝트 / 사내 PoC / 운영급 서비스 — 각 시점에서 어느 단까지가 적정 인가? 그리고 실시간 음성 대화 (ChatGPT Voice Mode) 의은 본 강의의 동기 일괄 흐름 에서 몇 단의 진화 가 더 필요한가?
생각해볼 자료:
- Day 6 의
Flux<String>스트리밍 — 토큰 단위로 흘러오는 그림. 거기가 5 단 파이프라인의 두 번째 단에 그대로 박힐 수 있는 길. - Step 6 의
StreamingTextToSpeechModel한 줄 언급 — 본 강의는 단순도 우선 이라 미사용. 그 한 줄이 첫 단의 입구. - ChatGPT Voice Mode / Gemini Live — 실시간 음성 대화 의 결정 트리. 클라이언트 WebSocket + 서버 멀티모달 네이티브 의. 본 강의의 5 단 파이프라인 1.0 에서 2.0~3.0 으로 가는 진화 도식.
다음 시간 강의 시작 전에 한 번 굴려보세요. 답안은 day09-answers.md 에서 만나요.
✅ 예시 답안정답 보기
본 답안은 교안의 Mission 섹션 에 박힌 3 개 과제 + 3 개 생각해볼 주제 의 권장 풀이 입니다. 정답이 하나만 있는 건 아니에요. 본인이 풀어본 결과와 비교하면서 왜 이렇게 결정했는가 의 근거를 살펴보세요.
Day 9 답안의 세 줄 정신 — (1) ChatMemory 를 5 단 파이프라인에 끼워넣을 때 prepend 가 진짜로 적용됐는가 는
ArgumentCaptor<Prompt>로만 검증된다 (과제 1), (2) STT/TTS 두 카운터의 분리 + Clock 자정 경계 두 축이 한 곳에 들어가야 가드의 진짜 손맛이 살아납니다 (과제 2),. (3) Java 코드 0 줄 변경 + .env 한 줄 + ProcessBuilder 안전 List 분리 — 추상화의 마지막 증명은 오프라인 시연이 그대로 풀리는 데서 닫힙니다 (과제 3). Day 7 · Day 8 답안과 같은 호흡이에요. 본인의 풀이가 다르더라도 왜 그렇게 결정했는지 가 한 줄로 들어가 있으면 더 좋은 답입니다.과제 풀이 코드는 현재 코드베이스에 들어가 있지 않은 예시 구현 입니다 (Day 9 본문은
VoiceTranscriptionService·VoiceSynthesisService·CharacterVoiceService와 그 컨트롤러까지 — 세 과제는 학생이 직접 구현 합니다). 답안 코드는 권장 시그니처 + 핵심 흐름 만 담은 형태라, 그대로 복붙 보다 손으로 한 번 더 짜보는 게 학습 의미가 있어요.
과제 1 풀이 — 5 단 파이프라인에 ChatMemory 끼워넣기 (난이도 ⭐⭐ 🌱)
1. 시나리오 회상
ai-friends 의 PM 이 5 단 파이프라인 데모 를 보고 한 가지를 부탁했어요.
"매번 첫 대화 같아요 — 어제 음성으로 5 번 대화했어도 오늘 들어가면 캐릭터가 그걸 기억 못해요." Day 5 에서 구축한 JdbcChatMemoryRepository 를 5 단 파이프라인의 3 단째 에 얹는 과제입니다.
Step 7 에서 의도된 미적용으로 미뤄둔 회수의 시점. 🌱
핵심은 — ChatClient 는 Day 11 부터 이므로 본 과제에선 ChatModel 인터페이스 + 수동 ChatMemory.add / chatMemory.get 만 써서 풀어요.
직접 한 번 더 짜봐야 ChatClient 의 MessageChatMemoryAdvisor 한 줄의 추상화 가치가 다음 Day 에 더 진하게 들어와요.
2. 채점 포인트 표
| # | 항목 | 가중치 | 핵심 |
|---|---|---|---|
| 1 | 새 서비스 분리 (CharacterVoiceMemoryService) — 기존 CharacterVoiceService 0 줄 수정 |
상 | 중간 합류 학생의 기준 코드 보호 — 기존 파일 한 줄이라도 수정 시 감점 |
| 2 | ChatMemory 빈 주입 (인터페이스 그대로, 구현체 직접 X) |
상 | JdbcChatMemoryRepository 를 직접 주입하면 Day 5 이후 영속 저장 원칙 위반 — Spring AI 자동 등록 빈 (ChatMemory) 그대로 |
| 3 | chatMemory.get(conversationId, lastN) → Prompt 의 history 위치에 prepend |
상 | 가장 흔한 누락 — chatMemory.get(...) 만 호출하고 Prompt 에 안 넣는 사고 |
| 4 | chatMemory.add(...) 가 userMessage + assistantMessage 두 번 |
상 | assistant 만 박고 user 를 빠뜨리거나, 그 반대 — 둘 다 누적되어야 다음 턴 prepend 가 자연스러움 |
| 5 | conversationId 결정 근거 (PR description 한 줄) | 중 | (A) soulmateId 단순 / (B) userId + ":" + soulmateId 격리 — 둘 중 근거가 박혀 있으면 OK |
| 6 | ChatModel 인터페이스 주입 |
중 | OpenAiChatModel 직접 주입 시 감점 — Day 2 프로바이더 추상화 원칙 |
| 7 | ResponseEntity<ApiResponse<...>> 응답 또는 binary byte[] 응답 — ApiResponse 표준 패턴 |
상 | 새 컨트롤러를 만들었다면 ApiResponse 래핑 또는 binary 예외 절 그대로 |
| 8 | 테스트 — ArgumentCaptor<Prompt> 로 history prepend 검증 |
상 | 단순 200 응답 검증만 하면 진짜로 history 가 들어갔는지 알 수 없음 — captor 가 필수 |
| 9 | 테스트 — chatMemory.add(...) 가 user/assistant 두 번 verify |
상 | Mockito spy 또는 Mock 으로 verify(chatMemory, times(2)).add(...) |
배점 비율은 상 60% / 중 25% / 하 15%. 8·9 번이 진짜 손맛 의 포인트예요. DB 에 메시지가 저장됐는가 가 아니라 Prompt 의 instructions 안에 history 가 prepend 되었는가 가 검증의 핵심입니다.
3. 권장 구현
3-1. 새 서비스 — CharacterVoiceMemoryService
@Service
@RequiredArgsConstructor
public class CharacterVoiceMemoryService {
private final VoiceTranscriptionService voiceTranscriptionService;
private final VoiceSynthesisService voiceSynthesisService;
private final ChatModel chatModel; // ★ 인터페이스 주입 (Day 2 프로바이더 추상화 원칙)
private final ChatMemory chatMemory; // ★ Spring AI 자동 등록 빈 (JdbcChatMemoryRepository 기반)
private final SoulmateRepository soulmateRepository;
private static final int LAST_N = 10; // 직전 10 개 메시지 회상
public byte[] converseWithMemory(Long soulmateId, MultipartFile audio) {
// 1) STT — 사용자 음성 → 텍스트
String userText = voiceTranscriptionService.transcribe(audio);
// 2) 캐릭터 조회 + 페르소나 SystemMessage
Soulmate soulmate = soulmateRepository.findById(soulmateId)
.orElseThrow(() -> new VoiceException(ErrorCode.SOULMATE_NOT_FOUND));
SystemMessage systemMessage = new SystemMessage(buildPersonaPrompt(soulmate));
// 3) ★ ChatMemory 회상 — 직전 N 개 메시지
String conversationId = "soulmate-" + soulmateId; // 학습용 단일 사용자 가정
List<Message> history = chatMemory.get(conversationId, LAST_N);
// 4) Prompt 조립 — System + ...history + 현재 User
UserMessage currentUser = new UserMessage(userText);
List<Message> messages = new ArrayList<>();
messages.add(systemMessage);
messages.addAll(history); // ★ history prepend
messages.add(currentUser);
// 5) ChatModel 호출
ChatResponse response = chatModel.call(new Prompt(messages));
String assistantText = response.getResult().getOutput().getText();
// 6) ★ ChatMemory 누적 — user + assistant 두 번
chatMemory.add(conversationId, currentUser);
chatMemory.add(conversationId, new AssistantMessage(assistantText));
// 7) TTS — 텍스트 → 음성 binary
return voiceSynthesisService.synthesize(assistantText);
}
private String buildPersonaPrompt(Soulmate soulmate) {
return """
너는 %s 야. 성격은 %s 이고, 말투는 %s.
사용자와 자연스럽게 대화해.
""".formatted(soulmate.getName(), soulmate.getPersonality(), soulmate.getSpeechStyle());
}
}
💡 학생이 직접 짜야 할 자리 — 컨트롤러 작성 (
/api/voice/characters/{soulmateId}/converse-memory같은 경로, binary 응답이라byte[]raw 허용 —. 교안의 ApiResponse 표준 패턴의 binary 예외 절),Soulmate엔티티의personality/speechStyle필드가 없다면 고정 페르소나 문자열 로 대체,LAST_N의 적정선 (10~20).
3-2. conversationId 결정의 두 갈래 — PR description 에 한 줄
## conversationId 결정
(A) `"soulmate-" + soulmateId` — 단일 사용자 학습용으로 채택.
(B) `userId + ":" + soulmateId` — 운영급에선 정석 (멀티유저 격리).
본 과제는 학습용 단일 사용자 가정이라 (A) 를 선택. 운영 시점에서
SecurityContext 의 `principal.id` 를 prefix 로 박는 방식으로 마이그레이션
가능 — 시그니처에 conversationId 외부 주입 자리가 박혀 있으면 더 유연.
3-3. 검증 포인트 — 두 자리의 핵심
채점 시 Prompt 자루의 모양 과 DB 누적 횟수 두 자리를 다음 두 약속으로 본다.
history 가 Prompt 에 prepend 되는가— STT 결과 +chatMemory.get(...)가 돌려준 직전 메시지 2 개를 stub 으로 박아두고,chatModel.call(...)에 들어간 Prompt 의 instructions 가 4 자리 (System + history 2 + currentUser 1) 인지.- 두 번째 메시지의 텍스트에 전 대화 사용자 발화 가, 네 번째 메시지에 현재 사용자 발화 가 들어 있는지.
- 단순 200 응답 검증만 하면 DB 에는 박혔는데 모델 입력에는 안 들어간 침묵의 사고 가 잡히지 않아요. Prompt 자루 안 까지 들여다보는 단계가 필수.
chatMemory.add(...)가 user + assistant 두 번 호출되는가` — 누적 횟수 단언 + 잡힌 메시지의 타입이 UserMessage → AssistantMessage 순서 인지. assistant 만 박는 사고 가 가장 흔한데, 그러면 다음 턴 prepend 시 사용자 발화가 비어 있어 모델이 어색한 흐름으로 답해요.
4. 흔한 실수
chatMemory.get(...)만 호출하고 Prompt 에 안 박음 — 가장 흔한 사고.messages.addAll(history)한 줄을 빠뜨리면 DB 에 메시지는 박히는데 모델 입력엔 안 들어가는 침묵의 사고. 어제 대화를 모델은 모른 채로 응답함.chatMemory.add(...)를 assistant 만 박음 — user 를 빠뜨리면 다음 턴 prepend 시 사용자 발화가 없어서 모델이 어색한 흐름 으로 답해요. user / assistant 둘 다 누적이 정석.InMemoryChatMemoryRepository회귀 — 본 강의 Day 5 이후 영속 저장 원칙 위반. DB 영속성 이 살아 있어야 어제 대화를 오늘 기억하는 풍경이 진짜로 박힙니다. 테스트 코드는 MockChatMemory사용 — 그건 OK.ChatClient에 손대기 — Day 11 부터 등장. 본 과제는ChatModel+ 수동 ChatMemory 만 사용.MessageChatMemoryAdvisor도 사용 금지.- conversationId 를 런타임마다 다른 값으로 박기 —
UUID.randomUUID()처럼 박으면 항상 새 대화 가 되어 메모리가 회수 안 됩니다. soulmateId / userId 같은 결정적 값 이 정석. - 기존
CharacterVoiceService코드에if (memory) { ... }분기 박기 — 한 클래스가 두 책임 을 갖게 됨. 새 서비스 분리가 깔끔.
5. 실무 개선 포인트 (심화)
- lastN 의 동적 결정 — 학습용 디폴트는 10, 운영급은 사용자 토큰 사용량 / 모델 컨텍스트 한도 에 따라 동적 결정. 세션이 길어질수록 prepend 토큰 비용이 폭발 하므로 Sliding Window + Summary 결 (이전 10 개를 요약 한 줄 로 압축한 뒤 다음 10 개 prepend) 이 운영 정석. Day 14 의 Memory Compaction 자리 복선.
- Day 11 ChatClient 마이그레이션 노트 — 본 과제 코드는 Day 11 에서 다음 한 줄로 풀려요.
chatClient.prompt(userText).advisors(new MessageChatMemoryAdvisor(chatMemory)).call().- 15 줄 → 1 줄 의 압축. 본 과제의 손맛이 그 한 줄의 추상화 가치를 알아보는 손 을 만들어요.
- 마이그레이션 시점에 기존 서비스를 deprecate 표시하고 점진적 교체.
과제 2 풀이 — `VoiceDailyQuotaGuard` (난이도 ⭐⭐⭐ 🪪)
1. 시나리오 회상
같은 PM 이 "음성 데모 시연 영상 올렸더니 다른 부서 인턴분들이 다들 시연해보셔서 어제 OpenAI STT (gpt-4o-transcribe) 800 회" 를 들고 왔어요.
Day 7 의 ImageDailyQuotaGuard · Day 8 의 VisionDailyQuotaGuard 에 이어 세 번째 회수 입니다.
음성은 비용이 텍스트의 30~50 배 라 가드가 진짜 필요한 자리예요. 🪪
핵심은 — STT 와 TTS 의 단가가 다르다 는 사실이에요.
OpenAI STT gpt-4o-transcribe 는 분당 $0.006 (오디오 길이 비례, whisper-1 레거시도 동일가), OpenAI TTS 는 문자당 $15/1M (텍스트 길이 비례).
두 카운터를 분리 해서 박는 게 정석.
한 카운터로 묶으면 STT 가 한도 채워도 TTS 가 막히고, 반대도 마찬가지의 상호 간섭 사고 가 납니다.
2. 채점 포인트 표
| # | 항목 | 가중치 | 핵심 |
|---|---|---|---|
| 1 | STT/TTS 카운터 분리 — Map<LocalDate, AtomicInteger> sttCounters, ttsCounters 두 필드 |
상 | 한 카운터로 묶으면 상호 간섭 사고 — 분리가 정석 |
| 2 | Clock 주입 (Day 8 패턴) — LocalDate.now(clock) |
상 | LocalDate.now() 직접 호출 시 자정 경계 검증 불가 — Clock 주입이 필수 |
| 3 | checkAndIncrementStt() / checkAndIncrementTts() 두 메서드 |
상 | 이름이 명확해야 호출부가 헷갈리지 않음 |
| 4 | ErrorCode VC008 VOICE_DAILY_QUOTA_EXCEEDED (status 429) |
상 | VC 시리즈 일관성 — V (Vision) 시리즈와 섞이지 않게 |
| 5 | 한도 외부 주입 — @Value("${aifriends.voice.stt.daily-limit:30}") |
중 | 하드코딩 시 환경별 조정 불가 — 외부 주입이 정석 |
| 6 | 새 서비스 분리 (위임 호출) 또는 AOP — 기존 두 서비스 0 줄 수정 | 상 | VoiceTranscriptionService 본체에 박으면 중간 합류 학생 코드 흐트러짐 |
| 7 | 테스트 — STT 한도 초과 차단 (한도 = 1 로 낮춰서 검증) | 상 | @Value 주입 시 ReflectionTestUtils.setField(...) 또는 @TestPropertySource 활용 |
| 8 | 테스트 — TTS 한도 초과 차단 | 상 | STT/TTS 카운터가 진짜로 분리되어 있는가 의 검증 |
| 9 | 테스트 — Clock.fixed() 자정 경계 리셋 |
상 | 23:59:59 한도 채움 → 00:00:01 카운터 0 — 두 시점 검증이 핵심 |
| 10 | AtomicInteger (동시성) |
중 | int raw 사용 시 동시성 사고 — 한도 30 이지만 실제 35 통과 같은 풍경 |
배점 비율은 상 65% / 중 20% / 하 15%. 1·2·9 번이 진짜 손맛의 핵심이에요.
3. 권장 구현
3-1. VoiceDailyQuotaGuard — Day 8 패턴 답습
@Component
@Slf4j
public class VoiceDailyQuotaGuard {
private final Clock clock;
private final int sttDailyLimit;
private final int ttsDailyLimit;
private final Map<LocalDate, AtomicInteger> sttCounters = new ConcurrentHashMap<>();
private final Map<LocalDate, AtomicInteger> ttsCounters = new ConcurrentHashMap<>();
public VoiceDailyQuotaGuard(
Clock clock,
@Value("${aifriends.voice.stt.daily-limit:30}") int sttDailyLimit,
@Value("${aifriends.voice.tts.daily-limit:50}") int ttsDailyLimit) {
this.clock = clock;
this.sttDailyLimit = sttDailyLimit;
this.ttsDailyLimit = ttsDailyLimit;
}
public void checkAndIncrementStt() {
LocalDate today = LocalDate.now(clock);
AtomicInteger counter = sttCounters.computeIfAbsent(today, k -> new AtomicInteger(0));
int next = counter.incrementAndGet();
if (next > sttDailyLimit) {
counter.decrementAndGet(); // 카운트 롤백
log.warn("[VoiceQuota] STT 일일 한도 초과 — date={}, limit={}", today, sttDailyLimit);
throw new VoiceException(ErrorCode.VOICE_DAILY_QUOTA_EXCEEDED);
}
log.debug("[VoiceQuota] STT 호출 — date={}, count={}/{}", today, next, sttDailyLimit);
}
public void checkAndIncrementTts() {
LocalDate today = LocalDate.now(clock);
AtomicInteger counter = ttsCounters.computeIfAbsent(today, k -> new AtomicInteger(0));
int next = counter.incrementAndGet();
if (next > ttsDailyLimit) {
counter.decrementAndGet();
log.warn("[VoiceQuota] TTS 일일 한도 초과 — date={}, limit={}", today, ttsDailyLimit);
throw new VoiceException(ErrorCode.VOICE_DAILY_QUOTA_EXCEEDED);
}
log.debug("[VoiceQuota] TTS 호출 — date={}, count={}/{}", today, next, ttsDailyLimit);
}
}
💡 카운트 롤백 — 한도 초과 시
counter.decrementAndGet()으로 되돌려야 한도 = 30 인데 실제 카운터가 31 까지 올라가는 사고를 막아요. Day 8 패턴 답습.
3-2. ErrorCode 추가
// ErrorCode.java
VOICE_DAILY_QUOTA_EXCEEDED("VC008", "음성 일일 한도를 초과했습니다.", HttpStatus.TOO_MANY_REQUESTS),
💡 STT/TTS 메시지 분리도 OK 입니다 —
VC008 STT 일일 한도 초과/VC009 TTS 일일 한도 초과. 운영 모니터링 대시보드에서 어느 자리가 막혔는지 가 한눈에 보임. 학습용은 하나로 통합도 충분.
3-3. 새 서비스 — 위임 + 가드 첫 줄
@Service
@RequiredArgsConstructor
public class VoiceTranscriptionGuardedService {
private final VoiceDailyQuotaGuard quotaGuard;
private final VoiceTranscriptionService delegate;
public String transcribe(MultipartFile audio) {
quotaGuard.checkAndIncrementStt(); // ★ 첫 줄
return delegate.transcribe(audio);
}
}
@Service
@RequiredArgsConstructor
public class VoiceSynthesisGuardedService {
private final VoiceDailyQuotaGuard quotaGuard;
private final VoiceSynthesisService delegate;
public byte[] synthesize(String text) {
quotaGuard.checkAndIncrementTts(); // ★ 첫 줄
return delegate.synthesize(text);
}
}
3-4. 검증 포인트 — 자정 경계 + 두 카운터 분리
채점 시 Clock 주입 덕분에 검증할 수 있는 두 시점을 본다.
- 자정 경계에서 카운터 리셋 —
Clock.fixed(2026-04-30T23:59:59Z)로 한도를 채워 한 번 차단된 뒤 (예외 +VOICE_DAILY_QUOTA_EXCEEDED),Clock빈을2026-05-01T00:00:01Z로 교체 (Spring TestContext /ReflectionTestUtils.setField) → 새LocalDate라 카운터가 0 부터 다시 흐르는지.LocalDate.now()직접 호출 로 짠 코드는 이 검증이 불가능해요.- Clock 주입의 진짜 가치가 경계 두 시점에서 가드의 동작이 정확히 갈리는지 를 보는 데서 살아나요.
- STT/TTS 카운터가 별개 — STT 한도를 채워 차단된 직후에도 TTS 카운터는 0 이라 통과해야. 한 Map 으로 묶어 짠 코드 는 이 부분에서 깨져요. 두 자루로 분리한 결정 이 진짜로 들어갔는지의 직접 증명.
4. 흔한 실수
LocalDate.now()직접 호출 — Day 8 의 패턴 무시. 자정 경계 검증 자체가 불가능해져요.Clock빈 주입 →LocalDate.now(clock)으로.- STT/TTS 카운터를 한 Map 으로 묶음 —
Map<LocalDate, AtomicInteger> dailyCounters하나만 쓰면 상호 간섭. STT 가 30 회 채우면 TTS 도 동시에 막힘. 두 필드로 분리가 정석. intraw 사용 — 동시성 사고. 한도 = 30 인데 동시 호출 35 개가 통과 하는 풍경.AtomicInteger가 정석.- 카운트 롤백 빠뜨림 — 한도 초과 시
decrementAndGet()안 부르면 카운터가 31, 32, 33... 으로 계속 증가. 다음 시도도 모두 차단. 예외를 던지더라도 카운터는 한도 안에 머물러야. @Value주입 한도를 테스트에서 못 바꿈 —@TestPropertySource(properties = "aifriends.voice.stt.daily-limit=1")또는ReflectionTestUtils.setField로 박기.- 기존
VoiceTranscriptionService본체에 가드 박기 — 중간 합류 학생 코드 흐트러짐. 새 서비스 분리 또는 AOP 가 정석.
5. 실무 개선 포인트 (심화)
- Redis INCR + EXPIRE 마이그레이션 — 분산 환경 (서버 N 대) 으로 가는 순간 인메모리
Map은 서버별로 카운터가 N 개로 분리되어 우회 가능 의 사고.INCR voice:stt:{userId}:{date}+EXPIRE 86400한 쌍이 분산 + 자정 자동 리셋 을 한 군데서 풀어요. Day 19 Harness 의 Rate Limit 복선. - 사용자별 (멀티테넌시) 격리 — 학습용은 전체 한도 라 한 사용자가 모두 점유 의 사고. 운영급은
Map<UserId, AtomicInteger>또는INCR voice:stt:{userId}:{date}로 유저별 격리. 그 위에 조직별 quota / USD 기반 비용 한도 까지 — 4 단 결정 트리 (생각해볼 주제 3 에서 자세히).
과제 3 풀이 — 로컬 무료 어댑터 (`whisper.cpp` / `edge-tts`) (난이도 ⭐⭐⭐⭐ 🦙)
1. 시나리오 회상
PM 이 "외부 API 차단된 망에서도 시연되어야 한다" 를 들고 왔어요. Day 8 의 Ollama 시연 과제 의 음성 버전. 외부 API 0 원 / 완전 오프라인 으로 같은 5 단 파이프라인을 돌리는 풍경. 프로바이더 추상화의 마지막 회수 — 음성 모달리티 의 첫 손맛이에요. 🦙
핵심은 — Java 코드 0 줄 변경 + .env 한 줄 + ProcessBuilder 안전 List 분리 의 세 축. 인터페이스 주입의 추상화 가치가 진짜로 살아있어야 본 과제의 본질이에요. 기존 CharacterVoiceService / 컨트롤러 한 줄이라도 수정 시 본질이 깨져요.
2. 채점 포인트 표
| # | 항목 | 가중치 | 핵심 |
|---|---|---|---|
| 1 | Java 코드 0 줄 변경 (기존 두 서비스 + 컨트롤러) | 상 | 인터페이스 주입의 가치 증명 — 한 줄이라도 수정 시 본 과제 본질 깨짐 → 0 점 |
| 2 | WhisperCppTranscriptionModel implements TranscriptionModel 또는 EdgeTtsTextToSpeechModel implements TextToSpeechModel |
상 | Spring AI 인터페이스를 직접 구현 — 새 어댑터 작성의 본질 |
| 3 | @ConditionalOnProperty 로 빈 등록 분기 (prefix="spring.ai.voice", name="provider", havingValue="local") |
상 | Day 2 손맛 답습 — matchIfMissing=true 로 디폴트 (Cloud) 빈 자동 등록 |
| 4 | ProcessBuilder 의 명령어 인자 List 분리 |
상 | 문자열 합치기 결은 커맨드 인젝션 — new ProcessBuilder("edge-tts", "--text", text) 처럼 List 로 |
| 5 | Process.waitFor(timeout) + 임시 파일 정리 |
상 | timeout 없으면 영구 hang 위험. Files.delete(...) 를 try-finally 로 정리 |
| 6 | 모델 / 바이너리 경로 외부화 — @Value("${aifriends.voice.whisper.model-path}") |
상 | 학생 머신마다 경로가 다름 — 하드코딩 시 재현성 0 |
| 7 | .env 한 줄로 풀림 — SPRING_AI_VOICE_PROVIDER=local |
상 | 도커 재빌드 / Gradle 재컴파일 / 코드 수정 — 모두 불필요 |
| 8 | 테스트 — MockedConstruction<ProcessBuilder> 또는 고정 입력 → 고정 출력 검증 |
중 | 실제 CLI 호출 없이도 어댑터 흐름 만 검증 |
| 9 | 시연 비교 노트 (보너스 ⭐⭐⭐⭐⭐) — Cloud vs Local 의 지연 / 음질 / 비용 표 | 하 | 트레이드오프 매트릭스가 박혀 있으면 만점 |
배점 비율은 상 70% / 중 20% / 하 10%. 1·2·4 번이 생사의 자리 예요. 기존 코드 0 줄 수정 의 룰 + ProcessBuilder 안전 호출 두 축이 무너지면 본 과제 본질이 깨져요.
3. 권장 구현 — TTS 측 (EdgeTtsTextToSpeechModel)
학습 시간을 고려해 TTS 한 쪽만 박는 풀이로 시연. STT (WhisperCppTranscriptionModel) 도 같은 방식으로 펼쳐져요.
3-1. 어댑터 클래스
package kr.spartaclub.aifriends.voice.local;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.audio.tts.TextToSpeechResponse;
// ... 기타 import
public class EdgeTtsTextToSpeechModel implements TextToSpeechModel {
private final String edgeTtsPath;
private final String voice;
public EdgeTtsTextToSpeechModel(String edgeTtsPath, String voice) {
this.edgeTtsPath = edgeTtsPath;
this.voice = voice;
}
@Override
public TextToSpeechResponse call(TextToSpeechPrompt prompt) {
String text = prompt.getInstructions();
Path outFile = null;
try {
outFile = Files.createTempFile("tts-", ".mp3");
// ★ 명령어 인자 List 로 분리 — 커맨드 인젝션 방어
ProcessBuilder pb = new ProcessBuilder(
edgeTtsPath,
"--voice", voice,
"--text", text,
"--write-media", outFile.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new VoiceException(ErrorCode.VOICE_LOCAL_PROVIDER_FAILED);
}
if (process.exitValue() != 0) {
throw new VoiceException(ErrorCode.VOICE_LOCAL_PROVIDER_FAILED);
}
byte[] audioBytes = Files.readAllBytes(outFile);
return new TextToSpeechResponse(/* audioBytes 를 응답 형태로 래핑 */);
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new VoiceException(ErrorCode.VOICE_LOCAL_PROVIDER_FAILED, e);
} finally {
// ★ 임시 파일 정리
if (outFile != null) {
try { Files.deleteIfExists(outFile); }
catch (IOException ignore) { /* 로깅만 */ }
}
}
}
}
💡
TextToSpeechResponse의 정확한 시그니처는 Spring AI 1.1.x 의org.springframework.ai.audio.tts패키지를 참조 — 본 답안은 흐름 만 박았어요. 실제 구현 시 IDE 자동완성으로 정확한 타입을 잡으면 OK.
3-2. @ConditionalOnProperty 로 빈 등록 분기
@Configuration
public class LocalVoiceConfig {
@Bean
@ConditionalOnProperty(prefix = "spring.ai.voice", name = "provider", havingValue = "local")
public TextToSpeechModel localTextToSpeechModel(
@Value("${aifriends.voice.edge-tts.path}") String edgeTtsPath,
@Value("${aifriends.voice.edge-tts.voice:ko-KR-SunHiNeural}") String voice) {
return new EdgeTtsTextToSpeechModel(edgeTtsPath, voice);
}
@Bean
@ConditionalOnProperty(prefix = "spring.ai.voice", name = "provider", havingValue = "local")
public TranscriptionModel localTranscriptionModel(
@Value("${aifriends.voice.whisper.path}") String whisperPath,
@Value("${aifriends.voice.whisper.model-path}") String modelPath) {
return new WhisperCppTranscriptionModel(whisperPath, modelPath);
}
}
💡 디폴트 (Gemini/OpenAI) 빈은 Spring AI 의 자동 설정이 그대로 처리해요.
@ConditionalOnProperty(matchIfMissing=true)로 프로바이더 미지정 시 자동 등록. 본 어댑터는 명시적provider=local일 때만 등록되고, 그러면 자동 등록 빈과 우리 빈이 같은 인터페이스를 두 개 갖게 되는 사고를 피하기 위해 Cloud 빈을 disable 해야 — 보통@ConditionalOnProperty(havingValue="cloud", matchIfMissing=true)처럼 반대 조건 을 박아 둘 중 하나만 등록되게 합니다.
3-3. .env 한 줄
# Day 9 과제 3 — 로컬 음성 모드 스위치
SPRING_AI_VOICE_PROVIDER=local
AIFRIENDS_VOICE_EDGE_TTS_PATH=/usr/local/bin/edge-tts
AIFRIENDS_VOICE_EDGE_TTS_VOICE=ko-KR-SunHiNeural
AIFRIENDS_VOICE_WHISPER_PATH=/usr/local/bin/whisper-cli
AIFRIENDS_VOICE_WHISPER_MODEL_PATH=/path/to/ggml-base.bin
3-4. 시연 흐름 — ./run.sh 한 번
# 0) 호스트에서 한 번 (학습용은 Mac 디폴트)
brew install whisper-cpp
pip install edge-tts
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin
# 1) Cloud 모드 (디폴트) 응답 캡처
./run.sh down
cp .env.cloud .env # SPRING_AI_VOICE_PROVIDER=gemini
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
-F "audio=@./test-audio/sample.mp3" --output reply-cloud.mp3
# 2) 로컬 모드로 갈아끼움 — Java 코드 0 줄 변경
./run.sh down
cp .env.local .env # SPRING_AI_VOICE_PROVIDER=local
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
-F "audio=@./test-audio/sample.mp3" --output reply-local.mp3
4. 흔한 실수
CharacterVoiceService/ 컨트롤러 한 줄이라도 수정 — 가장 큰 함정. 어댑터 분기 로직 을 서비스에 박으면 추상화의 본질이 깨짐.@ConditionalOnProperty+ 빈 두 개로 풀려야.ProcessBuilder("edge-tts --voice " + voice + ...")문자열 합치기 — 커맨드 인젝션 의 정통 사고. 사용자가text에"; rm -rf /"박으면 서버 디스크 날아감.new ProcessBuilder("edge-tts", "--voice", voice, ...)처럼 List 로 분리 가 정석.Process.waitFor()에 timeout 없음 — 외부 CLI 가 영구 hang 하면 우리 스레드도 함께 영구 대기.waitFor(30, TimeUnit.SECONDS)로 상한 박기 +destroyForcibly()로 정리.- 임시 파일 정리 빠뜨림 —
Files.createTempFile(...)박고Files.deleteIfExists(...)안 부르면 디스크가 점진적으로 차오름.try-finally로 반드시 정리. - 모델 파일 경로 하드코딩 —
"/Users/sumin/whisper/ggml-base.bin"처럼 박기. 학생 머신마다 경로가 다름 —@Value("${aifriends.voice.whisper.model-path}")로 외부화. - WHISPER_BASE_URL — 외부 데몬으로 착각 — whisper.cpp 는 데몬이 아니라 CLI 도구.
http://localhost:11434처럼 박으면 동작 안 함. 바이너리 경로 + 모델 파일 경로 두 가지. ggml-medium-q5_0.bin(1.5GB) 부터 시도 — 학습용은ggml-base.bin(~140MB) 이 충분. 큰 모델은 첫 풀 시간 + 첫 호출 로딩 시간 모두 길어요. 학습 호흡 끊김.
5. 실무 개선 포인트 (심화)
- CLI 호출 → Native Java 라이브러리 마이그레이션 —
ProcessBuilder결은 프로세스 fork 비용 + 임시 파일 I/O 가 있어요. 운영급은whisper-jni(whisper.cpp 의 JNI 바인딩) 또는Vosk(Java native STT) 로 프로세스 호출 없이 직접 호출. 응답 시간 5~10 배 단축. 단 의존성 무게가 늘어남. - Cloud → Local Fallback 전략 — 운영 시점에 Cloud 빈을 1 순위, Local 빈을 2 순위 fallback 으로 박기.
@Primary+@Order+ 커스텀FallbackTextToSpeechModel래퍼. Cloud 한도 초과 / 장애 시 자동으로 Local 로 흘러가는 풍경. Day 19 Harness 의 Resilience4j Fallback 복선. - 모델 warm-up 자동화 — 앱 기동 시 백그라운드로 whisper.cpp / edge-tts 한 번 호출 흘려보내서 첫 사용자의 timeout 경험 제거.
@EventListener(ApplicationReadyEvent)로 박기.
생각해볼 주제 1 — 음성 + 캐릭터 페르소나의 **감정 톤** — TTS 보이스 매핑
[문제 상황 요약]
Step 7 에서 우리는 캐릭터의 페르소나를 SystemMessage 의 자연어 로 설계했어요. 그런데. 음성으로 답하는 단계에서 가장 큰 체감 페르소나 는 목소리 그 자체. 같은 답변 텍스트라도 남자 저음 vs 여자 고음 vs 어린아이 톤 이 결정적으로 다른 캐릭터를 만들어요. 분홍 토끼 캐릭터 가 낮은 남자 보이스 로 답이 흘러나오면 순간 캐릭터가 박살. 이 보이스 매핑을 어디에 어떻게 둘지 의 결정이 핵심 트레이드오프입니다.
[튜터의 가이드 및 해설]
이 문제는 캐릭터 정체성의 영속성 과 운영 부담 vs 자유도 의 트레이드오프를 묻습니다. 결정 트리는 세 갈래.
Option A — Soulmate.voiceModel DB 컬럼
가장 단순한 접근. 캐릭터 생성 시 입력 폼 에서 사용자가 직접 고르거나 기본값 으로 결정. Day 7 의 Soulmate.portraitUrl 과 같은 가족이에요. 영속성이 강하고 — 한 번 정해진 voice 가 DB 에 영구 보관 되어 캐릭터의 정체성 일부가 돼요.
- 장점: 명시적 + 영속적 + 운영 부담 0 (조회 한 번이면 끝).
- 단점: 캐릭터 추가 시 매번 voice 결정 이 필요. 수십 개 voice 중 어떤 걸 고를지 의 부담이 사용자 또는 운영자에게.
Option B — VoiceProfileMapper 별도 빈
캐릭터 성격 → voice 매핑 정책 을 별도 빈에 분리. "발랄" → BongJinNeural (어린 톤), "차분" → SunHiNeural (여성), "진중" → InJoonNeural (남성). Soulmate.personality 컬럼 하나만 보고 Mapper 가 voice 를 자동 결정.
- 장점: 캐릭터 추가 시 자동 매핑 — 운영 부담 0. 매핑 정책이 한 곳에 모임 (수정 시 한 군데만 수정).
- 단점: 같은 성격 = 같은 voice 가 되어 캐릭터별 다양성이 줄어듦. 매핑 정책 변경 시 기존 캐릭터의 voice 가 한꺼번에 바뀌는 사고 (사용자 입장에선 어제까지 차분한 여성이던 캐릭터가 오늘 갑자기 남성으로 들리는 풍경 — 캐릭터 정체성 붕괴).
Option C — LLM 자체에게 추천 받기
캐릭터 생성 시 "이 캐릭터에 어울리는 보이스는?" 까지 LLM 에게 물어보는 풍경. "이름: 분홍이, 성격: 발랄, 외모: 어린 토끼" → "BongJinNeural" 같은 흐름.
- 장점: 가장 자연스러운 매칭. 새 voice 가 추가되어도 LLM 이 알아서 매칭.
- 단점: 비용 ↑ (캐릭터 생성마다 LLM 호출 한 번 추가) + 일관성 어려움 (같은 입력이라도 응답이 흔들림 — temperature 0 으로도 100% 일관 보장 안 됨) + 환각 (존재하지 않는 voice 이름을 추천하는 사고).
현업에서는 보통 — Option A + B 의 하이브리드
- Option A 가 1 순위. DB 컬럼에 voice 가 명시적으로 기록되는 방식이 영속성 + 운영 안정성 두 축을 동시에 잡아요.
- Option B 는 fallback.
Soulmate.voiceModel이 null 이거나 deprecated 된 voice 일 때만 Mapper 가 성격 기반 자동 매핑 으로 fallback. - Option C 는 사용 안 함. 비용 + 일관성 + 환각 셋 다 약함.
voice 변경 가능성의 통제
voice 를 너무 자주 바꾸면 같은 캐릭터로 안 들리는 게 가장 큰 사고예요. 통제는 세 가지.
- DB 컬럼 변경 시 사용자 확인 다이얼로그 — "이 캐릭터의 voice 를 바꾸면 사용자에게 다른 캐릭터처럼 들릴 수 있어요. 정말 바꾸시겠어요?" 의 한 줄.
- 변경 이력 로그 —
voice_change_history테이블에 언제 어떤 voice 에서 어떤 voice 로 바뀌었는지 기록. 디버깅 + 사고 복구. - 운영 정책으로 보존 — "캐릭터 출시 후 voice 변경은 분기별 1 회 이하" 같은 팀 컨벤션. 모바일 게임의 성우 캐스팅 변경 과 같은 무게.
🎯 면접관을 홀리는 핵심 멘트
"캐릭터 정체성에서 목소리 의 무게는 그림 보다 더 무거워요 — 그림은 안 봐도 대화가 가능하지만 음성은 듣자마자 캐릭터의 성격이 결정 됩니다. 운영 디폴트는 DB 컬럼으로 명시적 보존 (Option A) + 성격 기반 Mapper 를 fallback (Option B) 의 하이브리드. LLM 추천 (Option C) 은 비용 + 일관성 + 환각 셋 다 약해서 운영급에선 비추. 그리고 voice 자체의 일관성을 위해 — 한 번 정한 voice 는 함부로 바꾸지 않는 운영 정책이 DB 설계만큼 중요 하다고 봅니다."
생각해볼 주제 2 — **PII 가 음성에 박힐 때 — 로깅과 저장의 결**
[문제 상황 요약]
오늘 만든 VoiceTranscriptionService.transcribe(...) 는 변환된 텍스트 를 그대로 응답해요. 학습 코드는 DB 저장 0 / 마스킹 0 / 미저장 0 의 세 가지가 모두 비어 있는 상태. 사용자가 주민등록번호 / 카드번호 / 전화번호 를 음성으로 말하면.
STT 가 텍스트로 변환 → log.info → ELK → S3 백업 의 흐름으로 PII 가 영구 저장 되는 사고가 가능해요.
그리고 — 목소리 자체 도 PII 예요.
음성 지문 (voiceprint) 으로 화자를 식별할 수 있어 GDPR / 개인정보보호법은 음성을 생체정보로 분류. 운영급 가드는 어디까지 두어야 할까요?
[튜터의 가이드 및 해설]
이 문제는 PII 보호의 5 단 결정 트리 와 학습 코드의 합리화가 운영에서도 통하는가 의 두 결을 묻습니다.
1. 5 단 결정 트리 (1 순위 → 5 순위)
- 1 순위 — 오디오 자체 미저장: 메모리 → STT → 즉시 폐기. 디스크에 떨어뜨리지 않기. 가장 강한 카드 — 유출되지 않을 데이터를 만드는 게 유출 방어보다 안전.
MultipartFile을 임시 파일로 떨어뜨리는 기본 동작도transcribe(...)직후Files.delete(...)로 즉시 정리. - 2 순위 — 변환 텍스트 마스킹: 정규식 + NER (Named Entity Recognition) 으로 주민번호 / 카드번호 / 전화번호 / 이메일 패턴을 마스킹.
"전화번호 010-1234-5678 입니다"→"전화번호 010-****-**** 입니다". 로그·DB·응답 모두 마스킹된 텍스트만 흐르도록. - 3 순위 — 짧은 보존 기간: 디버깅 필요시에만 24~72 시간 보존, S3 lifecycle 자동 삭제. 영구 저장이 아니라 시간 제한 저장.
- 4 순위 — 암호화: S3 SSE-KMS + 전송 구간 TLS + DB 컬럼 레벨 암호화. 유출되어도 읽을 수 없는 단계.
- 5 순위 — 동의 절차: 음성 업로드 시 명시적 동의 체크박스 + 사용 목적 고지. GDPR Art.6 / 한국 개인정보보호법 의 법적 근거 단계.
2. 본 강의 코드는 어디까지 비어 있는가?
| 항목 | 본 강의 코드 | 운영 디폴트 |
|---|---|---|
| 오디오 미저장 | ❌ (MultipartFile 의 임시 파일이 OS temp 에 잠시 떨어졌다 정리) |
✅ 명시적 Files.deleteIfExists(...) 추가 |
| 변환 텍스트 마스킹 | ❌ (log.info("transcribed: {}", text) 흘러나갈 위험) |
✅ PiiMaskingFilter 또는 Logback Pattern Layout 으로 적용 |
| 보존 기간 | N/A (DB 저장 없음) | ✅ ChatMemory 와 함께 갈 때 lifecycle 정책 |
| 암호화 | ❌ | ✅ TLS (전송) + SSE-KMS (저장) |
| 동의 | ❌ | ✅ 음성 업로드 폼에 동의 박스 |
학습 코드의 합리화 는 — 외부 사용자가 임의 음성을 보내는 환경이 아니라 강사 1 인 시연 이라는 가정. 그 가정이 한 줄이라도 깨지면 (예: 운영 배포 / 다중 사용자) — 최소 1 순위 (오디오 미저장) + 2 순위 (텍스트 마스킹) 두 단계는 반드시 들어가야 해요.
3. 목소리 지문 (voiceprint) 의 무게
GDPR Art.9 는 음성 지문 을 생체정보 (Biometric Data) 로 분류해서 일반 PII 보다 한 단 더 깐깐 하게 다뤄요. 명시적 동의 (explicit consent) 가 필수, 목적 한정 이 강제, 익명화 후에도 식별 가능성 까지 검토.
우리 코드의 영향 범위 — 오디오 파일을 S3 에 저장하는 동작 이 들어간다면 그 시점이 생체정보 처리. 단순 임시 파일 → STT → 즉시 폐기라면 처리 로 보지 않을 가능성이 높지만 — 법무팀 검토 필수. 학습 코드처럼 오디오 자체를 영구 저장하지 않는 기본 형태가 생체정보 규제 회피 의 가장 단순한 길이에요.
4. 결정 트리의 우선순위 — 학습 vs 운영
- 학습 코드 (본 강의): 1·2 순위 비어 있어도 데모용 이라 OK. 단 주석에라도 "운영 시 마스킹 필수" 남기기.
- 운영 디폴트: 최소 1·2 순위 적용 + 3·4 순위는 디버깅 필요 시. 5 순위는 법무 협의.
- GDPR 적용 EU 사용자가 한 명이라도 있다면: 5 순위까지 모두 적용해야. voiceprint 의 명시적 동의 가 핵심.
🎯 면접관을 홀리는 핵심 멘트
"음성 PII 는 텍스트보다 한 단계 더 무거워요 — 음성 지문 자체 가 GDPR 의 생체정보 분류라, 일반 PII 보다 한 단 더 깐깐 합니다. 5 단 결정 트리에서 오디오 미저장이 1 순위, 변환 텍스트 마스킹이 2 순위 — 이 두 가지가 최소한의 운영 디폴트. 학습 코드는 외부 사용자 입력이 없는 단일 시연 가정이라 비어 있는데, 운영 배포 한 줄이라도 들어가는 순간 그 합리화가 깨져요. 저는 유출되지 않을 데이터를 만드는 게 유출 방어보다 안전 을 1 순위로 잡고, 그 위에 마스킹 + 짧은 보존 + 암호화 + 동의 를 서비스 성숙도에 따라 단계적으로 올리는 게 맞다고 봅니다."
생각해볼 주제 3 — **5 단 파이프라인의 스트리밍 가능성 — 사용자 체감 지연 줄이기**
[문제 상황 요약]
오늘 우리는 5 단 파이프라인을 동기 + 일괄 로 만들었어요.
사용자가 마이크에 1 분 말하면 — STT 5 초 + ChatModel 3 초 + TTS 4 초 = 총 12 초 후에야 첫 음성이 흘러나와요.
진짜 사람과 대화하는 풍경에서 12 초 침묵은 대화의 죽음. 이걸 줄이는 단계는 스트리밍의 4 단 진화 로 펼쳐지는데. 각 단계마다 복잡도 / 사용자 체감 / 비용 의 트레이드오프가 다 다릅니다.
[튜터의 가이드 및 해설]
이 문제는 스트리밍의 4 단 진화 와 서비스 성숙도에 맞는 적정 단계 를 묻습니다.
1. 4 단 진화의 도식
- 단계 0 (현재): 동기 일괄 — 총 12 초 후 첫 음성. 사람 대화 풍경 0%.
- 단계 1 — TTS 스트리밍:
StreamingTextToSpeechModel(Step 6 한 줄 언급) 로 TTS 응답을 청크 단위로 흘려보냄. 사용자 체감 12 초 → 6~8 초. ChatModel 응답 완성 직후 첫 청크 가 흐르기 시작. - 단계 2 — ChatModel 토큰 단위 + 문장 분할: Day 6 의
Flux<String>토큰 스트리밍을 문장 끝 (마침표 / 쉼표) 단위로 chunking 후 각 chunk 에 TTS 호출. ChatModel 이 토큰을 만드는 동시에 TTS 가 음성 생성. 사용자 체감 6 초 → 3~4 초. 대화 풍경의 50%. - 단계 3 — STT 실시간: Whisper realtime API / Gemini Live 처럼 말하는 도중에 변환 시작. 사용자가 말 끝나기도 전에 ChatModel 호출 시작. 사용자 체감 3 초 → 1~2 초. 대화 풍경의 80%.
- 단계 4 — 풀 듀플렉스 음성 대화: ChatGPT Voice Mode / Gemini Live. 클라이언트 ↔ 서버 양방향 WebSocket + 서버 측이 음성 자체를 모델에 직접 입력 (multimodal native). 말 중간에 끼어드는 결 까지. 대화 풍경의 100%.
2. 서비스 성숙도별 권장 단계
| 시점 | 권장 단계 | 근거 |
|---|---|---|
| 주니어 개발자 (본 강의) | 단계 0 (현재) | 5 단 파이프라인의 기본 흐름 을 직접 익히는 단계. 스트리밍은 다음 진화 |
| 사이드 프로젝트 | 단계 1 (TTS 스트리밍) | 최소 비용으로 가장 큰 사용자 체감 개선 — 추가 코드 수십 줄 |
| 사내 PoC | 단계 1 + 2 (TTS + 문장 분할) | 대화 풍경의 절반 — Day 6 SSE 결합 의 손맛이 살아남 |
| 운영급 서비스 | 단계 3 까지 | 실시간 STT 의 진입 비용 (Whisper realtime API / Gemini Live) 을 감수할 가치 |
| 음성 비서 / 통화 봇 | 단계 4 | 말 중간에 끼어드는 결까지 필요한 자리 — 전용 인프라 (LiveKit / WebRTC) 까지 |
3. 단계별 코드 복잡도의 지수 증가
- 단계 1: 추가 코드 수십 줄.
StreamingTextToSpeechModel.stream(...)+StreamingResponseBody(Spring WebMVC) 또는Flux<DataBuffer>(WebFlux). - 단계 2: 추가 코드 수백 줄. 문장 끝 감지 알고리즘 (한국어는 마침표 외에도 ./?/! 세 갈래) + 문장 단위 TTS 호출 큐 + chunk 합성 순서 보장 — 동시성 사고가 시작되는 영역.
- 단계 3: 추가 코드 수천 줄. WebSocket 양방향 + STT 부분 결과 처리 + ChatModel 호출 시점 결정 (사용자 발화 종료 vs 일시 정지) — 백엔드 아키텍처 자체가 reactive 로 갈아탐.
- 단계 4: 프레임워크 전환. LiveKit / Pipecat / OpenAI Realtime API 같은 전용 풀 듀플렉스 인프라 도입.
4. 최소 비용 / 최대 효과 의 지점
단계 1 (TTS 스트리밍) 이 가성비의 챔피언. 총 12 초 → 첫 음성 0.5 초 까지 압축할 수 있는 단계예요. 추가 코드 수십 줄, 모델 호환성 거의 깨지지 않음 (StreamingTextToSpeechModel 을 지원하는 프로바이더가 늘어나는 추세). 학생 사이드 프로젝트라도 단계 1 까지 는 적용할 가치가 있어요.
단계 2 부터는 진입 장벽이 급격히 올라가요. 한 명의 백엔드 개발자가 주말 한 번에 풀 수 있는 분량이 아니에요. 팀 + 1~2 주의 작업 이 정석. 그래서 PoC 단계에서 단계 2 까지 의 결정은 비즈니스 가치 vs 기술 부채 의 무게 비교가 필요해요.
5. ChatGPT Voice Mode 의 결
ChatGPT Voice Mode / Gemini Live 는 본 강의의 5 단 파이프라인 1.0 에서 2.0~3.0 으로 가는 모양이에요. Multimodal Native 모델이 음성을 텍스트로 변환하지 않고 직접 처리 하는 풍경.
STT/TTS 의 자매 추상화가 모델 안에 통합 되어 우리가 5 단 파이프라인을 짤 영역 자체가 사라짐. 이건 코드 진화 가 아니라 모델 진화 가 만드는 새 모양이라, 우리 백엔드는 모델의 발전을 기다리는 입장.
2026 년 후반 ~ 2027 년 의 흐름.
🎯 면접관을 홀리는 핵심 멘트
"5 단 파이프라인의 동기 일괄 12 초 → TTS 스트리밍 첫 음성 0.5 초 까지가 최소 비용으로 가장 큰 사용자 체감 개선 — 가성비의 챔피언입니다. 그 위는 문장 분할 + 실시간 STT + 풀 듀플렉스 의 4 단 진화로 펼쳐지는데, 각 단계의 코드 복잡도가 지수적으로 증가 하고 팀 + 1~2 주의 작업 이 정석이라 — 사이드 프로젝트는 단계 1, PoC 는 단계 2, 운영급은 단계 3 까지가 적정선이라고 봅니다. 단계 4 (풀 듀플렉스) 는 코드 진화가 아니라 모델 진화 가 만드는 영역이라 모델의 발전을 기다리는 입장 — ChatGPT Voice Mode 가 그 풍경입니다."
💡 답안을 마무리하며 — Day 9 의 세 과제와 세 주제는 모두 Day 5 (ChatMemory) · Day 7~8 (가드 + 추상화) · Day 6 (스트리밍) 의 패턴을 음성의 자리 에서 한 번 더 회수하는 풍경이었어요. 세 모달리티에 같은 손맛이 박혀 있으면 — Day 11 부터 등장하는 ChatClient + Advisors 추상화 가 왜 한 줄로 풀리는지 가 손에 진하게 들어와요. 본 답안이 정답은 아니지만, 왜 이렇게 결정했는가 의 근거가 본인의 답안과 다르다면 — 그 차이가 본인의 손맛 이에요. 🌸