ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “백엔드” 트랙부터 권해요.
안녕하세요! 여러분의 백엔드 가이드, 홍순구 튜터입니다.
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), 답을 생각하고 (ChatModel), 자기 목소리로 (TTS) 다시 답해주는 그림. 캐릭터가 처음으로 목소리를 갖는 날이에요.
텍스트
Day 7 Day 8 Day 9 (오늘)
그리는 손 → 보는 눈 → 듣는 귀 + 말하는 입
(시각 출력) (시각 입력) (청각 입력 + 청각 출력)
💡 오늘 수업의 핵심 — "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) 가 한 줄 위에서 손을 잡는 그림이에요.
텍스트
마이크 ──▶ STT ──────────▶ 텍스트 ──▶ ChatModel ──▶ 텍스트 ──▶ TTS ──────────▶ 스피커
(TranscriptionModel) (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 분짜리 오디오 인식 한 번 = 텍스트 호출 30~50 번 분량 까지 가요.
그래서 본 강의 디폴트는 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 번 분량. 그래서 어느 라인업으로 가느냐 의 결정이 — 비용을 가둘 수 있느냐 와 학생 진입장벽이 어디까지 낮아지느냐 두 축에 동시에 걸려요.
텍스트
텍스트 호출 1회 ▏(기준)
TTS 짧은 답변 1회 (5~10배) ▆▆▆
STT 1분 음성 1회 (30~50배) ▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆
무료 옵션 (Gemini 무료 티어 · whisper.cpp · 브라우저 SpeechSynthesis) = 0원
본 강의는 완전 무료 라인업 으로 흐르되 — 유료 라인업 도 한곳에 정리해둬요. 학생이 졸업 후 실무에서 OpenAI STT 라인 (gpt-4o-transcribe · whisper-1) 을 만났을 때 — 첫 호흡이 흐트러지지 않게. 그게 오늘 Step 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 두 자매 추상화가 자연스럽게 받아들여져요.
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 추상화 위에서 흐르는 이유예요.
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 변경 위험
시연 / 사이드 프로젝트
ElevenLabseleven_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 가지 라인업의 합본
자, STT/TTS 두 표를 한 화면 에 묶어볼게요. 학생이 들고 다닐 수 있는 합본 매트릭스예요.
이 매트릭스가 머리에 들어오면 — 왜 본 강의가 Gemini 디폴트 + 브라우저 SpeechSynthesis 폴백의 조합으로 가는지가 자연스럽게 풀려요. 비용 0 원에서 시작 + 학생 진입장벽 최소화 + 추상화 학습 가능 세 축이 동시에 잡히니까요.
본 강의 디폴트 — Gemini 디폴트 + 추상화는 OpenAI 스타터 🌟
여기서 솔직한 결정 한 줄 을 짚고 갈게요. 본 강의의 디폴트 결정은 조금 묘해요.
STT: 호출 자체는 Gemini 2.5 Flash (audio input) 무료 티어 로 가지만, 추상화 학습은 OpenAI 스타터의 TranscriptionModel 인터페이스 (OpenAiAudioTranscriptionModel) 위에서 흘러요.
TTS: 학습용으론 브라우저 SpeechSynthesis Web API 로 0 원에 시작하고, 실습 후반부 (선택) 에 OpenAI TTS 또는 edge-tts 로 갈아끼워요.
텍스트
┌─────────────────────────────────────────────────────────┐
│ 교육 목표 — Spring AI 자매 추상화 │
│ TranscriptionModel · TextToSpeechModel (OpenAI 스타터)│
└──────────────────────┬──────────────────────────────────┘
│ .env 한 줄 + 스프링 빈 스위칭
┌──────────────────────┴─────────────────────────────────┐
│ 실제 호출 — Gemini 무료 티어 · 브라우저 SpeechSynthesis│
│ · 모킹 빈 │
└────────────────────────────────────────────────────────┘
솔직한 트레이드오프 한 박스
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 키만 넣으면 — 같은 컨트롤러가 그대로 흘러가는 그림이 되고요.
🙋 한 학생의 날카로운 질문
"튜터님, 그럼 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 스타터의 인터페이스로 잡는 결정이 — 그 세 축을 동시에 잡아요.
비용 감각 한 줄 — 음성은 텍스트보다 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 라인업으로 운영 배포할 때 의 이야기고요. 오늘은 감각만 무료로 익히고, 비용은 머리에 새겨두는 호흡으로 가요.
💡 튜터의 결론 — 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: 자매 추상화 두 빈 + 마이크 녹음 감각
자, Step 1 에서 어느 라인업으로 갈지 의 결정이 들어왔으니 — 이제 그 라인업을 어떤 모양으로 부를지가 본 Step 의 미션이에요. 결론부터 한 줄로 정리할게요.
"Spring AI 1.1.x 는 음성 도메인을 두 빈 으로 쪼개 추상화했어요. TranscriptionModel (듣는 빈) 과 TextToSpeechModel (말하는 빈) 이 나란히 들어가요. Day 2 의 ChatModel 옆에, Day 7 의 ImageModel 옆에 — 세 번째로 자매 추상화가 한 번 더 등장하는 지점이에요."
오늘 펼칠 감각은 두 가지예요. 첫째는 — Spring AI 의 두 자매 인터페이스 시그니처를 머릿속에 그려두기. 둘째는 — 브라우저의 마이크 녹음 (MediaRecorder) 으로 첫 음성 데이터를 만들어보기. 둘 다 Step 3 이후의 자바 코드 가 흐를 길을 위에서 내려다보며 미리 그려두는 호흡이에요.
세 번째 등장 — 자매 추상화라는 패턴
본 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) 의 모양을 그대로 따르고 있어요.
텍스트
Day 2 ChatModel.call(Prompt) → ChatResponse
Day 7 ImageModel.call(ImagePrompt) → ImageResponse
Day 9 TranscriptionModel.call(AudioTranscriptionPrompt) → AudioTranscriptionResponse
Day 9 TextToSpeechModel.call(TextToSpeechPrompt) → TextToSpeechResponse
└─ 네 줄 모두 같은 모양 — 세 모달리티를 한 방식으로 추상화 ─┘
이 그림이 머리에 들어오면 — Step 3 부터 흐를 자바 코드의 낯섦 이 사라져요. 완전히 새로 외우는 게 아니라, Day 2 의 감각이 음성 도메인에 한 번 더 복제된 것 이니까요.
TranscriptionModel — 듣는 빈의 시그니처 👂
자, 첫 번째 자매 — 듣는 빈 이에요. Spring AI 1.1.x 의 표준 인터페이스 풀네임은 이거예요.
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 빈을 등록해줘요. 컨트롤러에선 그냥 생성자 주입 한 줄.
Java
// 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 에서 코드베이스의 build.gradle 의존성과 함께 한 번 더 확인 할 거예요. 학습 호흡상 시그니처를 미리 머릿속에 그려두는 단계라고 생각해주세요.
TextToSpeechModel — 말하는 빈의 시그니처 🗣️
두 번째 자매 — 말하는 빈 이에요. Spring AI 1.1.x 의 공통 추상화 패키지 에 들어간 인터페이스의 풀네임은 이거예요.
org.springframework.ai.audio.tts.TextToSpeechModel (1.1.x 공통 추상화)
OpenAI 구현체는 org.springframework.ai.openai.OpenAiAudioSpeechModel — OpenAiAudioSpeechAutoConfiguration 이 위 인터페이스의 빈으로 자동 등록해줘요. 컨트롤러 / 서비스 쪽에선 인터페이스 (TextToSpeechModel) 로만 받으면 — 어느 구현체가 와도 같은 방식으로 흐릅니다 (본 강의의 프로바이더 추상화 원칙).
입력 (TextToSpeechPrompt) — 합성할 텍스트 String 한 줄을 받아요. new TextToSpeechPrompt("안녕하세요, 저는 쿠로미예요!") 같은 모양.
옵션을 더 주고 싶으면 new TextToSpeechPrompt(text, OpenAiAudioSpeechOptions.builder().voice("alloy").build()) 처럼 음성 캐릭터·포맷·속도 를 추가로 지정할 수 있어요.
출력 (TextToSpeechResponse) — .getResult() 로 Speech 하나가 떨어지고, .getOutput() 한 줄로 byte[] 가 떨어져요. 바이트 배열 자체 가 음성 데이터예요 (디폴트로 mp3 인코딩). 그대로 컨트롤러의 응답 본문에 흘려보내면 — 브라우저의 <audio> 태그가 받아서 재생해요.
Java
// 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) 을 등록해줘요. 듣는 빈 과 말하는 빈
두 자매가 같은 스타터 안에서 나란히 들어가 있는 그림이에요. 부엌의 칼과 도마가 한곳에 놓이듯, 서로를 보완하는 두 도구가 나란히 놓여 있죠.
왜 두 빈으로 쪼개졌을까
자, 여기서 결정적인 질문 한 줄을 던져볼게요.
"튜터님, 음성 모달리티는 — 한 빈 (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 단 풀 파이프라인. 추상화가 모듈러 해요.
텍스트
음성 ──▶ [STT 어댑터] ──▶ ChatModel (텍스트 → 텍스트) ──▶ [TTS 어댑터] ──▶ 음성
└── 끼우고 뺄 수 있는 모듈 ──┘
· STT 만 끼우면 → 음성 입력 + 텍스트 출력
· TTS 만 끼우면 → 텍스트 입력 + 음성 출력
· 둘 다 끼우면 → 5 단 풀 파이프라인
🙋 한 학생의 날카로운 질문
"튜터님, ChatModel 처럼 이것도 .env 한 줄로 프로바이더 갈아끼울 수 있나요? OpenAI gpt-4o-transcribe 에서 — 다른 프로바이더로 옮기는 게 진짜 한 줄 수정으로 끝나나요?"
정확히 핵심을 짚으셨어요. 답은 — "Day 2 의 감각이 음성 도메인에서도 그대로 살아있어요." Spring AI 가 정리해둔 인터페이스 — TranscriptionModel · TextToSpeechModel — 위에서 흐르는 컨트롤러 코드는 프로바이더가 바뀌어도 변하지 않아요.
OpenAI 스타터를 빼고 다른 음성 스타터를 정리해 넣으면 — 같은 인터페이스에 다른 빈이 등록 되고, 컨트롤러는 그 빈이 누구든 신경 안 써요.
단, 솔직한 한 가지 — 2026-04 시점, 음성 도메인의 자동 빈 등록 은 OpenAI 스타터에 가장 잘 들어가 있어요.
Gemini 의 audio input 은 — 별도 TranscriptionModel 빈으로 등록되기보단 ChatModel 의 multimodal 입력 으로 흐르는 쪽이 더 자연스러워요.
그래서 완벽한 .env 한 줄 스위칭 은 OpenAI ↔ 다른 OpenAI 호환 프로바이더 (Groq Whisper 같은 곳) 사이에선 깔끔하게 살아있고, Gemini 로 갈 때는 호출 코드가 한 단계 우회 해야 해요. 추상화의 감각은 살아있되, 프로바이더 생태계의 성숙도가 텍스트 도메인보단 한 박자 늦다 고 정리해두면 정확해요.
마이크 녹음 — 브라우저의 MediaRecorder API
자, 자매 추상화의 시그니처를 머리에 그려뒀으니 — 이제 백엔드로 흘려보낼 첫 음성 데이터 를 어디서 따올지 살펴봐요. 브라우저의 마이크 녹음 이에요. 본 강의는 백엔드 중심 이지만, 여기는 5 단 파이프라인의 첫 단 이라 — 짧게라도 익혀둘 가치가 있어요.
브라우저가 우리한테 던져주는 도구는 — Web API 표준 의 두 개예요.
navigator.mediaDevices.getUserMedia({ audio: true }) — 사용자에게 마이크 권한 을 요청해요. 첫 호출 시 브라우저가 "이 사이트가 마이크 사용을 요청합니다" 팝업을 띄우고 — 사용자가 허용하면 MediaStream 이 떨어져요. 오디오 스트림의 핸들 이에요.
MediaRecorder — MediaStream 을 받아서 바이너리 오디오 청크 를 토해내는 녹음기예요. recorder.start() 로 녹음 시작, recorder.stop() 으로 끝, 그 사이에 ondataavailable 콜백이 청크 단위로 데이터를 넘겨줘요.
JavaScript
// 마이크 녹음 → 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 로 서로 달라요.
백엔드에서 어떤 포맷을 받아도 처리할 수 있게 해두든가, 프론트에서 미리 한 포맷으로 강제 해두든가 둘 중 하나가 필요해요. 본 강의는 후자 (강제) + 백엔드 검증 의 안전이에요.
chunks.push(e.data) — 녹음 중에 청크가 조각조각 떨어져요. Blob 으로 모아 둬요.
stream.getTracks().forEach(t => t.stop()) — 마이크 권한을 명시적으로 닫아주는 한 줄. 안 닫아두면 브라우저 탭의 마이크 표시 빨간 점이 계속 켜진 채 로 남아요. 사용자 신뢰를 위해 반드시 닫아줘요.
new FormData() + multipart/form-data — Blob 을 멀티파트 업로드 로 백엔드에 흘려보내요. 백엔드 컨트롤러는 — Step 4 에서 만들 @RequestParam("audio") MultipartFile audio 한 줄로 받아내요. 여기가 자바 코드의 첫 발 이 닿는 지점이고요.
포맷 한 줄 — 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 통합 시연 시 별도 검증 하는 방식으로 갑니다.
<audio> 태그 — TTS 응답을 재생하기
자매의 반대쪽 — TTS 응답을 브라우저가 받아 재생하는 흐름 도 한 줄 정리할게요. 이게 5 단 파이프라인의 마지막 단 이에요.
JavaScript
// /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 는 없고, 소리만 흘러요.
"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 — 첫 자매 빈을 직접 호출하기
자, Step 2 에서 손으로 그렸던 자매 추상화 — 그 시그니처가 진짜 빈으로 주입되는 순간 이 바로 여기예요.
머리 속에서만 굴러다니던 TranscriptionModel.call(AudioTranscriptionPrompt) 의 한 줄이 — 실제 @Service 클래스의 한 메서드 안에서 흐르기 시작해요.
Day 8 에서 VisionChatService 를 익혔던 것과 완전히 같은 호흡 으로 갑니다.
본 Step 의 미션을 한 줄로 정리하면 — "음성 파일(Resource) 하나를 받아 STT 모델에 던지고 → 텍스트 하나로 돌려주는 서비스 빈" 을 만드는 거예요.
코드 자체는 Day 8 의 VisionChatService 와 흐름이 똑같고, 모달리티 하나만 음성으로 바뀌어요.
지난 시간까지의 감각이 모달리티 하나만 옆으로 슬쩍 옮겨가는 것이라 — 가볍게 따라올 수 있어요.
먼저 완성된 모습 을 한 번에 들여다볼게요. Day 8 의 VisionChatService 와 같은 호흡이라 — 낯선 부분은 두 줄도 안 돼요.
Java
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 의 톤 통일 도 같이 가져가는 셈이고요.
Java
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 인터페이스 위에 완전히 추상화 돼요. 이게 왜 중요 한지는 — 다음 박스에서 한 학생이 바로 그 부분를 찔러줄 거예요.
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)) — 자매 추상화의 진짜 한 줄
이 한 줄이 — Step 2 에서 머리에 그려둔 시그니처가 진짜로 흐르는 자리예요. Resource 하나를 — AudioTranscriptionPrompt 라는 객체에 한 번 더 포장 하고, TranscriptionModel.call() 한 줄에 던져요. 출력은 AudioTranscriptionResponseDay 2 의 ChatModel.call(prompt) → ChatResponse · Day 7 의 ImageModel.call(imagePrompt) → ImageResponse 와 완전히 같은 모양의 시그니처 가 — 세 번째로 익히셨어요.
(6) 응답 추출 — null 안전 빈 문자열 폴백
Java
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 8 VisionChatService 의 감각이 음성 도메인으로 그대로 옮겨온.
VoiceException — 도메인별 예외의 세 번째 등장
VoiceTranscriptionService 가 던지는 예외 — VoiceException 의 본문을 하나 펼쳐볼게요. 이 방식이 왜 굳이 도메인별로 쪼개졌는가 가 본 Step 의 또 다른 곳이에요.
Java
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 개씩 쌓여요. "이 예외가 어느 도메인의 책임인가" 가 흐려지고, 수정 시 영향 범위 도 같이 흐려져요. 지금부터 도메인별로 쪼개두는 결이 — 나중에 감각이 살아남는 비결입니다.
ErrorCode.VOICE_AUDIO_REQUIRED — 음성 도메인의 첫 코드
VoiceException 이 들고 다니는 ErrorCode 의 도 한 줄 정리할게요. Day 7 (Image, I001~I005) · Day 8 (Vision, V001~V005) 에 이어 Day 9 (Voice) 가 세 번째로 자기 prefix 를 받는 모습이에요.
코드베이스의 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 에서 한 번 더 펼칠게요.
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 시나리오로 검증해뒀어요.
다음으로
자, 서비스 빈 은 익히셨어요. Resource 하나를 받아 — 자매 추상화의 한 줄을 통과시키고 — 텍스트 한 줄을 반환 하는 그림. 이게 5 단 파이프라인의 가장 핵심 노드 예요.
다음 부분은 — 사용자가 마이크로 녹음한 webm Blob 이 → MultipartFile 로 백엔드에 도착하고 → Resource 로 변환되어 → 이 서비스에 흘러들어가는. 컨트롤러 곳이에요. 오늘 정리한 서비스가 — HTTP 입구와 어떻게 만나는가 이 Step 4 의 감각입니다.
Step 4: VoiceTranscriptionController — 마이크 음성을 받아내는 첫 엔드포인트
자, Step 3 에서 서비스 빈 이 들어왔으니 — 이제 그 빈 앞에 HTTP 입구를 다는 모습이에요. 지난 시간 (Day 8) 우리는 VisionUploadController에서 이미지 MultipartFile 을 받아 정적 URL 로 박는 그림을 손에 정리했죠. 오늘은.
오디오 MultipartFile 을 받아 STT 모델로 흘려보내는 쪽으로 한 모달리티만 옆으로 슬쩍 옮긴 셈이에요. 지난 시간과 같은 호흡 의 세 번째 등장이에요.
본 Step 의 미션을 한 줄로 정리해두면 — "브라우저 MediaRecorder 가 흘려보낸 오디오 멀티파트 업로드 하나를 받아, 검증 → Resource 변환 → 서비스 위임 → ApiResponse 래핑 의 4 단 파이프라인을 한 메서드 안에 응축한다." 지난 시간 익숙해진이 —
오디오 도메인에서 한 박자 더 또렷해지는 곳이에요.
텍스트
[프론트엔드] ──────────────────┊────────────────── [백엔드]
┊
사용자 마이크 → MediaRecorder → multipart/form-data POST
┊
▼
VoiceTranscriptionController.transcribe
│
validate(audio) ← null · empty · 확장자 · 크기 4종 검증
│
audio.getResource() ← HTTP 입력을 비즈니스 입력으로 옮기는 변환점
│
▼
VoiceTranscriptionService.transcribe → STT 모델
VoiceTranscriptionController — 전체 코드 하나
먼저 완성된 방식 을 한 번에 들여다볼게요. Day 8 의 VisionUploadController 와 호흡이 같아요 — 검증 4 단 + Resource 변환 + 서비스 위임 + ApiResponse 래핑.
Java
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);
}
}
자, 이 코드가 왜 이렇게 들어갔는지 한 부분씩 짚어볼게요.
두 상수 — 학습용 화이트리스트 의 흐름
가장 위에 들어간 두 상수가 본 컨트롤러의 정책 결정 부분 예요.
Java
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 를 정리해뒀어요.
한 줄로 정리하면 — 프로바이더가 받아줄 수 있다고 우리가 받아야 하는 건 아니다. 우리 도메인의 한도는 우리가 다시 결정 해요.
텍스트
OpenAI STT 공식 7종 우리 코드 ALLOWED_EXTENSIONS
┌───────────────────────┐ ┌───────────────────────┐
│ mp3 mp4 mpeg mpga │ ═══▶ │ mp3 mp4 mpeg mpga │
│ m4a wav webm │ │ m4a wav webm │
└───────────────────────┘ └───────────────────────┘
"프로바이더의 한계가 그대로 우리 도메인의 한계로 옮겨온다"
✗ txt ✗ pdf ✗ png ← 화이트리스트 밖, 거부
transcribe 메서드 — 4 단 파이프라인의 한 응축
이 컨트롤러의 핵심 하나 가 바로 이 메서드예요. 4 단 파이프라인 이 5 줄 안에 응축돼 있어요.
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 자루 → 비즈니스 자루 변환의 한 줄
Java
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) 의 감각이에요.
Day 4-1 에서 들어간 "새로 만드는 컨트롤러의 정상 응답은 반드시 ApiResponse<T> 로 래핑한다" 는 규약 — Day 7 (이미지) → Day 8 (Vision) → Day 9 (Voice) 까지 세 번째로 다시 만나는 부분예요.
raw 객체나 record 직접 반환 금지 의.
왜 이 규약이 결정적 인지 한 줄 더 정리해두면 — GlobalExceptionHandler 가 에러 응답을 자동으로 ApiResponse.fail(ErrorResponse) 로 감싸기 때문에, 정상 응답을 raw 로 두면 같은 엔드포인트의 정상/에러 응답 형태가 비대칭 이 돼요.
학생들이 응답 표준을 학습할 때 일관된 패턴이 보여야 하거든요.
VoiceTranscriptionResponse — 한 필드 record 의 흐름
응답 DTO 는 놀랍도록 짧아요.
Java
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 결과가 들어와요.
validate(MultipartFile) — 4 단 검증
transcribe 메서드의 첫 줄 validate(audio) 가 호출하는 진짜 일이에요. 4 가지 시나리오를 순차로 흘려 검증해요.
Java
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 로 묶었어요 — 사용자 입장에선 어느 쪽이든 "음성 파일을 첨부해 주세요" 라는 같은 메시지를 받는 게 자연스러우니까요.
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)
Java
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 의 운영 흐름 에서 본격적으로 다시 다룰게요.
오늘 컨트롤러는 입력 검증 만 깔끔하게 정리해뒀어요.
텍스트
┌──────────────────────────────┐
│ VoiceTranscriptionController │
└───────────────┬──────────────┘
정상 응답 │ │ 에러 응답
▼▼
ApiResponse.success( ApiResponse.fail(
{ text: "..." }) { code: "VC001", message: "..." })
└─────────────── 같은 봉투 (ApiResponse) ───────────────┘
"성공이든 실패든, 사용자가 받아드는 응답의 모양은 같다"
진입점 — 프론트 ↔ 컨트롤러 ↔ 검증 키워드 한 줄 정리
컨트롤러를 닫기 전에 한 줄짜리 정리 만 하고 갈게요. 멀티파트 키 "audio" 는 세 곳에 똑같이 들어가야 닫혀요.
프론트 (form.append('audio', ...)) → 컨트롤러 (@RequestParam("audio")) → 통합 테스트의 MockMultipartFile("audio", ...) — 이 세 곳이죠. 키 한 글자가 어긋나면 멀티파트 매칭이 깨져 400 도 아닌 지저분한 5xx 가 떨어져요.
💡 컨트롤러가 약속대로 흐르는지 (정상 mp3·wav 업로드 → 200 + $.data.text · 빈 파일 → VC001 · 미지원 확장자 → VC002 · 10MB+1 byte 경계값 → VC003) 는 코드베이스의 VoiceTranscriptionControllerTest 가 5 시나리오로 검증해뒀어요.
Day 8 VisionUploadControllerTest 와 같은 호흡 으로 슬라이스 부팅 + GlobalExceptionHandler import 패턴이 들어가 있어 예외 응답이 ApiResponse.fail 로 자동 변환되는 진행 까지 함께 검증돼요.
💡 튜터의 결론 — Step 4 한 줄
MultipartFile.getResource() 의 한 줄 이 — HTTP 멀티파트 자루를 Spring AI 의 AudioTranscriptionPrompt(Resource) 와 자연스럽게 잇는 다리예요.
컨트롤러는 검증과 응답 래핑만, 비즈니스 로직은 서비스에 — 책임 분리 이 지난 시간 (Day 8 VisionUploadController) 와 완전히 같은 호흡 으로 흐르고, ApiResponse 표준 패턴 + 도메인별 ErrorCode + WebMvcTest + GlobalExceptionHandler import 의 네 감각이 세 번째로 다시 만나는 부분.
마이크 음성 → 백엔드의 첫 진입 단계이 들어갔다.
다음으로 — 듣는 빈 의 백엔드 부분이 닫혔다
자, 정리해보면 — 오늘 4 개 Step 을 거쳐 듣는 빈 (TranscriptionModel) 의 백엔드 곳이 완전히 닫혔어요.
Step 1 — 라인업 매트릭스 (어느 프로바이더로 갈지)
Step 2 — 자매 추상화 시그니처 (TranscriptionModel · TextToSpeechModel 의 모양)
Step 3 — VoiceTranscriptionService (Resource → 텍스트의 비즈니스 로직)
다음은 — 오른쪽 절반 부분이에요. 말하는 빈 (TextToSpeechModel) 의. 텍스트 한 줄을 던져서 → 음성 byte[] 가 떨어지고 → 브라우저의 <audio> 태그가 받아 재생하는. 5 단 파이프라인의 오른쪽이 닫히는 곳이에요.
Step 5 에서 말하는 빈 의 시그니처를 손에 잡고, 텍스트를 음성 byte[] 로 풀어내는 자리를 펼쳐봅시다.
Step 5: VoiceSynthesisService — 말하는 빈, 거울처럼 대칭의 모습
자, 이제 오른쪽 절반 의 첫 자루를 익힐 차례예요.
Step 2 에서 우리는 자매 추상화 두 개 를 한 번에 머리에 정리했죠.
왼쪽이 듣는 빈 (TranscriptionModel), 오른쪽이 말하는 빈 (TextToSpeechModel).
그 오른쪽 빈 의 1.1.x 정확한 이름이 TextToSpeechModel 이에요.
그리고 — 그 빈을 직접 호출하는 부분 가 바로 오늘 이 Step 이에요.
Step 3 의 VoiceTranscriptionService 와 비교하면 입출력의 방향이 정반대 예요.
그쪽은 오디오 byte[] → 텍스트 였고, 이쪽은 텍스트 → 오디오 byte[].
그런데 코드의 모양은 거의 거울처럼 대칭이에요.
같은 자매 추상화 패턴 이 두 번째로 박히는 지점이기 때문에 — 손이 한 번에 들어옵니다.
텍스트
듣는 빈 말하는 빈
TranscriptionModel ↔ TextToSpeechModel
────────────────────── 거울 ──────────────────────
입력: 오디오 파형 입력: 텍스트
│ │
▼▼
출력: 텍스트 출력: 오디오 파형
두 빈의 입력과 출력이 정확히 거울처럼 뒤집혀 있다
말하는 빈 의 정확한 시그니처 — 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 의 자동 구성 패턴 그대로예요.
VoiceSynthesisService — 거울처럼 대칭의 한 클래스
자, 이제 서비스 본체 를 한 번에 펼쳐 봅시다. 코드를 먼저 흘려보고 아래에서 한 줄씩 풀어볼게요.
Java
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단 변환의 가운데 3단을 한 번에 묶어요. 텍스트를 TextToSpeechPrompt 로 감싸고, call 로 호출하고, 응답을 받아 변수에 담는. Spring AI 의 자매 추상화가 가장 빛나는 한 줄 이에요. 프로바이더가 OpenAI 든 다른 무엇이든 — 이 한 줄은 안 바뀌어요.
Java
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[] 로 폴백 해요. 예외를 던지지 않고 빈 byte[] 를 반환 해요. 컨트롤러가 Content-Length: 0 으로 흘려보내면 그만 이고, 상위 레이어가 별도 분기 없이 똑같이 처리 할 수 있어요.
Step 3 의 text == null ? "" : text 와 완전히 같은 방어 패턴 모습이에요.
💡 튜터의 결론 — 거울처럼 대칭의 모양
Step 3 의 VoiceTranscriptionService 와 이 Step 5 의 VoiceSynthesisService 를 나란히 놓고 보면 코드가 거울처럼 대칭이에요.
입력/출력의 방향만 정반대일 뿐, 의존성 주입 · 가드 · 호출 한 줄 · null 폴백 의 네 가지 감각이 완전히 같은 방식으로 들어 있죠. 같은 자매 추상화 패턴이 두 번째로 들어간 곳 — 손이 한 번에 들어와요.
듣는 빈 ↔ 말하는 빈 — 한 표로 정리하는 대칭
자, 얼마나 거울처럼 대칭인지 를 — 한 표로 펼쳐 볼게요.
항목
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 가 자매 추상화를 얼마나 균형 있게 정리해뒀는지 가 한눈에 보이죠. 입력만 「오디오 ↔ 텍스트」 로 뒤집고, 출력만 「텍스트 ↔ 오디오」 로 뒤집으면 — 나머지는 같은 방식 곳이에요.
ErrorCode 추가분 — VC005 · VC006 두 개
서비스가 던지는 VOICE_TEXT_REQUIRED 가 어디 사는 지 보여드릴게요. kr.spartaclub.aifriends.common.exception.ErrorCode 의 Voice 섹션을 보면 — Step 3 의 VC001~VC004 뒤에 VC005 · VC006 두 줄이 새로 들어가 있어요.
Java
// 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 자루 두 개가 미리 들어가 있다」 는 그림만 머리에 두면 됩니다.
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() 를 헷갈려 한 글자만 잘못 정리하면 그 테스트가 깨져요 — 공백 가드의 감각이 거기서 들어가요.
솔직한 트레이드오프 — 추상화는 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 를 호출하지 않아도 — 결과물은 완전히 같은 형태 으로 들어와요.
자기 손으로 돌려보고 싶다면 — 무료 대안. 두 개를 추천해요.
1.
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 의 통합 시연 에서 한 번 흘려볼 자루예요.
옵션
1,000자당 비용
품질
OpenAI tts-1
$0.015
보통
OpenAI tts-1-hd
$0.030
고품질
edge-tts (무료 CLI)
$0
의외로 자연스러움
브라우저 SpeechSynthesis
$0
단조로움
Step 1 의 비용 매트릭스가 음성 합성 옵션으로 다시 등장하는 자리예요.
🚨 잊지 말 한 줄
추상화 (TextToSpeechModel) 는 OpenAI 스타터를 그대로 쓰되, 실제 호출만 무료 대안으로 우회한다 — 이 방식이 본 강의의 비용 가이드 핵심이에요. 코드의 모양은 그대로 두고, 비용만 가두는. 이미지 · 음성 · 비디오 비용 급등 경고 가 한 박스로 다시 만나는 부분예요.
🙋 학생 질문 박스
"튜터님, 솔직히 한 가지 의문이 들어요. 왜 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[] 는 「외부로 나가는 출력 자루」 의 가장 단순한. 두 개를 목적에 맞게 다르게 쓰는 게 옳아요.
다음 주제로 — 말하는 빈 도 들어왔다
자, 정리하면 — 오늘 5 번째 Step 에서 말하는 빈 (TextToSpeechModel) 도 백엔드 비즈니스 로직 단계이 완전히 익히셨어요.
듣는 빈 (Step 3 VoiceTranscriptionService) ↔ 말하는 빈 (Step 5 VoiceSynthesisService) — 거울처럼 대칭의 두 개.
같은 자매 추상화 패턴이 두 번째로 박힘 — 의존성 주입 · 가드 · 호출 한 줄 · 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 표준 패턴의 예외 절
자, 이제 말하는 빈 의 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 로 감싸고, 어떤 컨트롤러는 안 감싸지?" 라는 분류 기준을 익히는 거예요.
텍스트
VoiceSpeechController
│ text
▼
TextToSpeechModel.call()
│ byte[] (mp3)
▼
ResponseEntity<byte[]> ── Content-Type: audio/mpeg
│
▼
HTTP 응답 (audio/mpeg)
│
▼
브라우저 <audio> 태그가 그대로 자동 재생 🔊
VoiceSpeechController — 한 클래스를 한 번에 펼치기
자, 컨트롤러 본체 를 먼저 한 번에 펼쳐볼게요. 코드를 흘려보고 아래에서 한 줄씩 풀어요.
Java
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 부터 한 줄씩 풀어볼게요.
클래스 javadoc 의 ApiResponse 표준 패턴 예외 결정 — 본 Step 의 진짜 핵심
Java
/**
* <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 표준 패턴의 진짜 모양 이에요.
MAX_TEXT_LENGTH = 4000 — 학습 비용 억제의, 한 번 더
Java
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 결정과 정확히 같은 보호 장치예요.
@PostMapping(value = "/speak", produces = "audio/mpeg") — Content-Type 미리 박기
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 로 흘리는 게 자연스러워요. 각 자루의 본질에 맞게 다른 방식으로 들어간 부분이에요.
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(...) 에는 응답 헤더 두 자루 가 추가로 박혀 있어요.
그리고 — 형제 엔드포인트 한 자루가 더 있어요. GET /api/voice/info — 현재 활성 TTS 프로바이더만 단독 확인 하는 한 줄짜리 디버그 엔드포인트예요.
DevTools Network 탭 에서 방금 부른 합성이 어떤 프로바이더 / 어떤 voice 로 떨어졌는지 즉시 보이고, .env 의 TTS_PROVIDER 를 갈아끼고 재기동한 뒤 /api/voice/info 한 번 찍으면 — swap 이 살아 있는지 단번에 확인돼요. 오늘 본문 컨트롤러의 모양 은 — 이 학습 시연 메타데이터 두 자루 를 덜어낸 압축본 모습이에요.
본격 swap 시연은 마무리에서.
validate(VoiceSpeechRequest) — 2 단 검증
Java
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 컷 과 같은 방식이에요.
VoiceSpeechRequest — 한 필드 record 의 짧음
요청 DTO 도 놀랍도록 짧아요.
Java
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) 같은 옵션이 필요해지면 — 필드를 추가하면 그만이에요. 기본은 가장 단순하게 정리해뒀어요.
ErrorCode 의 Voice 섹션을 다시 보면 — Step 5 에서 들어간 VC005 · VC006 뒤로 VC007 한 줄이 새로 들어가 있어요.
Java
// 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 로만 정리해두고, 실사용은 학습 범위 밖으로 둬요.
정상 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] 와 같은 호흡 으로 들어가, 비교 연산자 한 글자 (> ↔ >=) 만 잘못 정리해도 깨지는.
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 표준 패턴의 예외 절 에 묶여 있는 그림이에요.
텍스트
Day 6 (텍스트 스트리밍)
T0 ▮ T1 ▮ T2 ▮ T3 ▮ ... "첫 글자가 빨라요"
└ 토큰이 조각조각 도착 ───────────▶ 시간
Day 9 (음성 binary)
▰▰▰▰▰▰ "한 번에 떨어져요"
└ 합성이 끝난 뒤 큰 byte[] 한 번 ──▶ 시간
💡 튜터의 결론 — 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 에도 적용해볼 수 있도록 열어둡니다.
다음으로 — 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 단 파이프라인 통합 — 세 추상화가 한 자리에서 손을 잡는 피날레
자, 드디어 오늘의 피날레에 왔어요. Step 1 부터 Step 6 까지 — 듣는 빈 (VoiceTranscriptionService) 과 말하는 빈 (VoiceSynthesisService) 을 각자의 컨트롤러 와 함께 손에 정리했죠. 그런데 — 두 개가 아직 서로 손을 잡지 않은 상태였어요.
오늘 손에 정리한 듣는 빈 (TranscriptionModel 자매 추상화) 과 말하는 빈 (TextToSpeechModel 자매 추상화), 그리고 Day 1 부터 함께 걸어온 생각하는 빈 (ChatModel) — 세 추상화가 처음으로 한 줄 위에서 손을 잡는 부분 예요.
사용자가 마이크에 대고 말하면 — 캐릭터가 자기 목소리로 답하는 그림이 진짜로 살아나는 순간이에요.
텍스트
(1) 사용자 마이크
│
(2) 브라우저 MediaRecorder Blob
│ multipart/form-data 업로드
▼
(3) CharacterVoiceController.validate(audio)
│
▼
(4) STT ── TranscriptionModel ──▶ 사용자 텍스트
│
▼
(5) Prompt(SystemMessage[페르소나] + UserMessage[사용자 텍스트])
│
▼
(6) ChatModel.call(prompt) ──▶ AI 텍스트
│
▼
(7) TTS ── TextToSpeechModel ──▶ byte[]
│
▼
(8) audio/mpeg 응답 → 브라우저 <audio> 자동 재생 🔊
세 자매 추상화 = TranscriptionModel · ChatModel · TextToSpeechModel
(점선) Day 5 ChatMemory — 오늘은 일부러 비워둠 (과제에서 회수)
도입 — 세 추상화가 손을 잡는다
"오늘 손에 정리한 듣는 빈 (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 두 추상화 를 한 모습에 모았다면 —
오늘은 세 추상화가 모이는 한 단계 더 깊은 자리예요.
CharacterVoiceService — 5 단 파이프라인의 응축
자, 오늘의 핵심 빈이에요. 네 개의 의존성 이 한 지점에 모이고, 5 단 흐름 이 한 메서드 안에 응축 돼 있어요.
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) — 세 호출이 같은 모양의 인터페이스 위에서 일렬로 잡혀요.프레임워크 하나, 감각 하나, 모달리티는 셋.
converse(Long soulmateId, Resource audio) — 5 단 한 메서드
이 메서드의 결정적 모양이에요. 5 단계 가 한 메서드에 응축 돼 있어요. 한 단계씩 풀어볼게요.
(1) 캐릭터 조회 + 가드 한 줄
Java
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 한 번) 을 가장 먼저 두는 거죠.
Step 3 에서 정리한 빈을 한 줄로 호출.audio 라는 Resource 가 — 텍스트 한 줄로 변신. 그리고 — 결정적인 폴백 한 줄이 들어가 있어요.
STT 결과가 null 이거나 공백뿐이면 — ChatModel 도 TTS 도 호출하지 않고 빈 byte[] 를 즉시 반환 해요. 왜 — 무성/노이즈만 들어온 입력일 때 비용만 빠지는 호출을 막는 부분이에요. 조용히 들어주기 의 감각이에요. 학생이 마이크를 잘못 켜뒀거나, 아무 말도 안 하고 멈췄을 때수업 한 번에 ChatModel + TTS 두 개의 비용이 빠지는 사고 를 입구에서 막는 모습이에요.
(3) Prompt 조립 — 페르소나 + 사용자 텍스트 두 메시지
Java
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 가 인식한 사용자 텍스트 를 그대로 정리해요. 두 메시지가 분리된 결정적 이유 는.
페르소나는 시스템의 약속 (변하지 않는 부분), 사용자 발화는 매 호출마다 새로 들어오는 입력.프로바이더가 두 개의 가중치를 다르게 다룰 수 있게 분리해뒀어요.
ChatResponse 자루의 4 단 null 체크 가 들어가 있어요. 왜 이렇게까지 방어적 이냐 — 프로바이더가 빈 응답을 돌려주는 케이스 (rate limit · safety filter · 모델 오작동) 가 실제로 발생하기 때문이에요.
그럴 때 NPE 가 떨어지면 — 5 단 파이프라인 전체가 죽어 버려요. 명시적 null 체크 → 빈 문자열 폴백 이 훨씬 자연스럽고요.
그리고 — AI 텍스트가 비어 있으면 TTS 도 건너뛰어요.빈 텍스트로 TTS 를 호출하면 — 빈 음성 byte[] 를 받기 위해 비용을 지불 하는 셈이 돼요. 조용히 끝내고 빈 byte[] 를 돌려주는 게 비용과 사용자 경험 둘 다 자연스러운 선택이에요.
(5) TTS 호출 — 마지막 한 줄
Java
return voiceSynthesisService.synthesize(aiText);
마지막 한 줄.AI 가 만든 텍스트 응답 이 — byte[] 음성 자루 로 변신해서 그대로 반환돼요. 컨트롤러 → 서비스 → 컨트롤러 의 자루가 한 번의 변환도 없이 흘러요.
@Transactional(readOnly = true) 가 클래스가 아닌 메서드 위에 들어간 것도 짚고 갈게요. Soulmate 조회만 있는 메서드라 읽기 전용 트랜잭션 으로 충분해요. 외부 프로바이더 호출 (STT · ChatModel · TTS) 은 트랜잭션 안에 둘 의미가 없지만.DB 조회만 명시적으로 readOnly 로 정리해뒀어요. Day 8 의 CharacterVisionService 와 같은 호흡 이고요.
buildPersonaSystemMessage(Soulmate) — 페르소나 자연어 한 덩어리
Java
private String buildPersonaSystemMessage(Soulmate soulmate) {
return "당신은 '" + soulmate.getName() + "' 라는 이름의 캐릭터예요. "
+ "성격: " + soulmate.getPersonalityKeywords() + ". "
+ "취미: " + soulmate.getHobbies() + ". "
+ "말투: " + soulmate.getSpeechStyles() + ". "
+ "사용자의 말에 한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요.";
}
캐릭터의 정체성 네 가지 (이름 · 성격 · 취미 · 말투) 를 자연어 한 덩어리 로 정리하는 헬퍼예요. 한 가지를 짚어두고 갈게요.
왜 PromptTemplate 을 안 썼는가 — 학습 단순도 때문이에요. Day 3 에서 손에 정리한 PromptTemplate("...{name}...{personality}...") 는 변수 치환에 더 정교한 제어가 필요할 때 의 도구예요. 오늘은.
네 가지를 한 줄로 자연어로 정리하면 그만인 경우라, 문자열 연결 (+) 만으로 충분 한 방식으로 두었어요. 기본은 단순하게, 필요할 때 PromptTemplate 으로 승격 — 의 감각이에요.
마지막 한 줄 — "한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요." — 여기가 결정적 가드 예요. 없으면 — 모델이 5~10 문장의 긴 답 을 돌려줄 수 있고, 그게 그대로 TTS에 흘러서 비용이 5~10 배로 부풀어 요.
프롬프트 한 줄로 답변 길이를 가두는 것이 비용 가드의 가장 저렴한 부분이에요.
세 추상화가 한 메서드에 들어간 — 진화의 한 발 더
결정적 한 박스
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 두 추상화 를 한 모습에 모았다면 — 오늘은 세 추상화가 모이는 진화의 한 발 더 들어간 부분예요.
텍스트
Day 8 Day 9
CharacterVisionController CharacterVoiceService
.introduce() .converse()
┌──────────────────┐ ┌──────────────────────┐
│ Media │ │ TranscriptionModel │
│ ChatModel │ ──▶ │ ChatModel │
│ (2 추상화) │ │ TextToSpeechModel │
└──────────────────┘ │ (3 추상화) │
└──────────────────────┘
진화는 단계적 — 한 번에 두 추상화로 점프하지 않고 하나씩 늘어난다
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 가 자연스럽게 합류 해요. 학습은 한 번에 두 가지를 박지 않아요 — 하나씩, 그 다음에 둘이 만나는 부분이에요.
CharacterVoiceController — 진입점 한 컨트롤러
이제 — 컨트롤러 부분 예요. Step 4 의 검증 패턴 + Step 6 의 binary 응답 패턴 두 개가 한 곳에서 만나는 곳이에요.
Step 4 (/api/voice/transcribe) · Step 6 (/api/voice/speak) 와 같은 prefix 아래 세 번째 엔드포인트 가 들어갔어요. 그런데 — 경로 모양이 달라요./characters/{soulmateId}/converse캐릭터 도메인 경로가 가운데에 들어갔어요.Step 4·6 은 「음성 도메인의 변환」, Step 7 은 「캐릭터와의 대화」 라는 차이를 URL 모양 으로도 신호하는 부분이에요.
(2) 입력 — 멀티파트 + PathVariable 두 개
Java
@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 (캐릭터의 대답) 의 흐름의 차이 가 파일명 하나로 신호되는 그림이에요.
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 표준 패턴의 의도적인 비대칭이 한 컨트롤러 테스트 안에서 다시 등장 돼요.
💡 튜터의 결론 — 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 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 7 ImageModel, Day 8 Media (입력 모달리티), Day 9 TranscriptionModel + TextToSpeechModel (청각 입출력). 세 추상화가 한 메서드 안에서 손을 잡는 5 단 파이프라인이 — 모달리티 추상화의 일관성을 가장 선명하게 보여주는 진행이다."
비용 가드 — 음성은 텍스트의 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 에서 본격적으로 —
비용 가드의 모양 이 한 단계씩 펼쳐져요.
🎤 더 미연시스러운 캐릭터 보이스 — 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
OpenAIgpt-4o-mini-tts (오늘 디폴트)
한국어 발음 자연 · narrator 톤 안정 · 단가 저렴
캐릭터 연기 약함 · voice 13종으로 한정 · 감정 표현 단조로움
✅ 자연
✅ spring-ai-starter-model-openai
Azure Neural TTS
SSML style/styledegree 로 기쁨/슬픔/속삭임 등 감정 톤 표현. 한국어 voice 다수
가격이 OpenAI 대비 살짝 높음 · 키 발급 + Azure 포털 진입장벽
✅ 자연 + 감정
⚠️ 비공식 (RestClient 직접)
ElevenLabseleven_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
Bash
# .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 값을 받아 둘 중 하나의 빈만 활성화 해요.
코드베이스의 실제 서비스에는 추상 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 키를 발급받아 다음 두 줄만 해보세요.
Bash
# 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" 같은 결정이 캐릭터 정체성의 한 조각이 되는 자리예요.
브랜치 박제 — day09-voice (Day 별 브랜치 박제 규약)
오늘 코드베이스 상태를 브랜치로 박제해두세요. Step 단위 커밋이 5 개 들어가 있어야 해요. 본 강의의 오늘의 브랜치 박제 지점이에요.
Bash
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 예외 — 그래서 커밋 생략):
Step 1~2 에 커밋이 없는 이유 — 모델 라인업 매트릭스 (Step 1) + 자매 추상화 시그니처 + 마이크 녹음 시연 (Step 2) 은 코드 변경이 한 줄도 없는 이론 Step 이기 때문이에요. Step 단위 커밋 규약의 예외 — 코드 변경 0 인 Step 은 커밋 생략 이 그대로 다시 등장한 거죠. 모든 Step 이 커밋을 만들어야 하는 건 아니다 는, Day 8 과 같은 결이고요.
이후 어떤 Day 든 git checkout day09-voice 로 오늘 상태로 돌아올 수 있어요. Step 7 의 CharacterVoiceService.converse(...) 시점만 보고 싶다 같은 요구도 한 줄로 가능해요.
Service 3 개 + Controller 3 개 + DTO 2 개 + Exception 1 개 + Test 6 개 — 오늘의 주제에 들어간 모든 파일이에요.
중간 합류 학생 가이드 (./run.sh 한 줄 실행 표준)
오늘 처음 합류하셨거나, 지난 Day 를 못 따라오셨다면 이 4 단계만 따라오세요.
Bash
# 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% 의 디폴트예요.
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 에서 만나요."
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 섹션 으로 넘어가서 과제와 생각해볼 주제를 받아보세요!
과제
오늘 손에 정리한 여섯 가지를 내 손에서 한 번 더 펼쳐보는 곳이에요.
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 의 예외 절 그대로 raw byte[] 도 허용.)
새 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 단 파이프라인의 교차점 — 두 큰을 한 부분에 합치는 감각. 텍스트로 들어간 메모리 추상화가 음성 입출력의 한가운데 에서도 그대로 살아있다는 진행이 손에 단단히 들어가요.
ChatModel vs ChatClient 의 결정 부분 — Day 11 부터 등장하는 ChatClient + MessageChatMemoryAdvisor 방식으로 가면 어드바이저 한 줄 로 풀려요.
그런데 본 강의는 Day 11 까지 ChatClient 도입 안 함 이 원칙 — 그러니 수동으로 ChatMemory.add / chatMemory.get 하는 방식으로 가야 해요.
손이 한 번 더 들어가야 ChatClient 의 추상화가 다음 Day 에 더 진하게 들어와요.
3.
세션 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 메시지가 들어가 있음 을 검증
확인 방법
Bash
./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% 이상 코드 재사용이 가능해서 손은 빠른데 이해는 깊어지는 학습.
2.
STT vs TTS 의 분리 — 두 호출의 비용 단가가 다르고 (OpenAI STT gpt-4o-transcribe 는 분당 $0.006, OpenAI TTS 는 문자당 과금), 호출 패턴도 다름 (STT 는 음성 길이 비례, TTS 는 텍스트 길이 비례).
한 카운터로 묶기 vs 분리 의 결정이 들어가요.
3. Clock 주입의 세 번째 등장 — Day 8 에서 정리한 자정 경계 검증 그대로예요. 같은 패턴이 음성에서도 살아 있는 그림.
✅ 요구사항
VoiceDailyQuotaGuard 클래스 신규 생성 — @Component + Clock 주입 (Day 8 패턴 답습)
두 곳에 별도 카운터 (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
확인 방법
Bash
./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 의 결론과 일치).
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 중 한 부분만 — 학습 시간을 고려해서 한 쪽만 정리하면 ⭐⭐⭐⭐. 둘 다 정리하면 ⭐⭐⭐⭐⭐ (보너스 부분).
@ConditionalOnProperty(prefix="spring.ai.voice", name="provider", havingValue="local") 로 스위칭
디폴트 (Gemini/OpenAI) 빈은 havingValue="gemini" 또는 matchIfMissing=true
기존 코드 0 줄 수정 — CharacterVoiceService / 컨트롤러 / 다른 서비스의 Java 코드 한 줄도 안 건드림. 인터페이스 주입의 추상화 가치가 진짜로 살아있어야 본 과제의 본질.
시연 비교 노트 (선택, ⭐⭐⭐⭐⭐ 보너스) — Gemini/OpenAI 응답 vs 로컬 응답의 지연 + 음질 + 비용 차이를 1 페이지로 정리. 표 한 장 + 짧은 후기 두 단락.
확인 방법
Bash
# 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 로컬 응답의 지연/음질/비용 비교 노트 를 첨부하면 리뷰가 훨씬 빨라집니다.
텍스트
과제 1 🌱 ChatMemory 끼워넣기
→ 5단 파이프라인 3단째에 DB 메시지 누적을 prepend
과제 2 🪪 VoiceDailyQuotaGuard
→ STT/TTS 두 카운터가 Clock 자정 경계에서 리셋
과제 3 🦙 로컬 어댑터 스위칭
→ 같은 인터페이스 위에서 클라우드 / whisper.cpp / edge-tts 가 한 빈으로
세 과제 = 오늘 감각(ChatMemory · 가드 · 프로바이더 추상화)의 세 방향
생각해볼 주제
이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 세 자매 추상화 · 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 에 흘려보내는. ChatModel 이 토큰을 만드는 동시에 TTS 가 동기화되어 음성을 만드는. 세 번째 단 은.
STT 를 Whisper realtime API 같은 방식으로 말하는 도중에 변환 시작. 사용자가 말 끝나기도 전에 백엔드가 이미 ChatModel 호출을 시작하는.
각 단계마다 복잡도 / 사용자 체감 / 비용 / 모델 호환성 의 트레이드오프가 다 달라요. 첫 단 (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
Java
@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 에 한 줄
Markdown
## 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 영속성 이 살아 있어야 어제 대화를 오늘 기억하는 풍경이 진짜로 박힙니다. 테스트 코드는 Mock ChatMemory 사용 — 그건 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() 두 메서드
💡 STT/TTS 메시지 분리도 OK 입니다 — VC008 STT 일일 한도 초과 / VC009 TTS 일일 한도 초과. 운영 모니터링 대시보드에서 어느 자리가 막혔는지 가 한눈에 보임. 학습용은 하나로 통합도 충분.
3-3. 새 서비스 — 위임 + 가드 첫 줄
Java
@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 도 동시에 막힘. 두 필드로 분리가 정석.
int raw 사용 — 동시성 사고. 한도 = 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 에서 자세히).
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. 어댑터 클래스
Java
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 로 빈 등록 분기
Java
@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 한 줄
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 한 번
Bash
# 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 추상화 가 왜 한 줄로 풀리는지 가 손에 진하게 들어와요. 본 답안이 정답은 아니지만, 왜 이렇게 결정했는가 의 근거가 본인의 답안과 다르다면 — 그 차이가 본인의 손맛 이에요. 🌸