문서 읽는 데 392분 · day09

Day 9. Voice (STT/TTS) — "캐릭터가 처음으로 목소리 를 갖는, 듣고 말하는 모달리티"

전체 26강 중 10강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 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), 답을 생각하고 (ChatClient), 자기 목소리로 (TTS) 다시 답해주는 그림. 캐릭터가 처음으로 목소리를 갖는 곳이에요.

💡 오늘 수업의 핵심 "TranscriptionModel / TextToSpeechModel 자매 추상화 — 지난 시간까지 익힌 추상화가 세 번째 모달리티 에서 다시 등장된다. 마이크 → STT → ChatModel → TTS → 스피커, 5 단 파이프라인이 한 부분에서 닫힌다."

오늘 수업은 한 문장으로 요약돼요.

"Spring AI 는 음성 도메인에도 자매 추상화 를 똑같이 정리해뒀다. ChatModel 옆에 TranscriptionModel (STT) 과 TextToSpeechModel (TTS) 두 빈이 나란히 놓이고, 프로바이더는 .env 한 줄로 스위칭 된다. 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인이 — 세 추상화가 차례로 손을 잡는 모습이다."

여기서 다시 자매 추상화의 모양 이 등장해요.

Day 2 의 프로바이더 추상화 (ChatModel) 가 첫 재등장, Day 7 의 모달리티 자매 (ImageModel) 가 두 번째 등장, 오늘이 세 번째 등장 예요.

같은 모양의 추상화가 다른 모달리티에 한 번 더, 한 번 더 라는 그림.

손이 한 번에 들어옵니다.

  1. TranscriptionModel음성 → 텍스트 의 자매 추상화. chatModel.call(prompt) 와 같은 방식으로 transcriptionModel.call(audioPrompt) 한 줄. 입력은 Resource (오디오 바이트), 출력은 텍스트.

  2. TextToSpeechModel텍스트 → 음성 의 자매 추상화. speechModel.call(speechPrompt) 한 줄로 byte[] 음성 데이터 가 떨어져요. 그대로 브라우저에 흘려보내면 <audio> 태그가 받아 재생.

5 단 파이프라인 — 브라우저 MediaRecorder 로 음성 녹음 → /api/voice/transcribe 에서 STT → ChatModel.call 호출 → /api/voice/speak 에서 TTS → 음성 응답을 브라우저가 재생.

세 추상화 (TranscriptionModel · ChatModel · TextToSpeechModel) 가 한 줄 위에서 손을 잡는.

🙋 한 학생의 걱정

"튜터님, 지난 시간 도 겨우 적응했는데 오늘은 귀와 입 두 가지를 한꺼번에 한다고요? 게다가 마이크 녹음 같은 프론트엔드 일도 끼어 있고... 그리고 솔직히 — 음성도 텍스트랑 비용이 비슷한가요? 지난 시간 Vision 이 텍스트의 1.5~2 배라고 하셨잖아요. 음성은 또 얼마나 비싸지..."

그 걱정 너무 잘 알아요. 세 가지를 짧게 풀어드릴게요.

첫째, 오늘의 새로 외울 핵심 은 지난 시간처럼 세 단어 예요 — TranscriptionModel / TextToSpeechModel / 5 단 파이프라인. 그리고 결정적인 한 가지. Day 2 의 프로바이더 추상화가 그대로 살아있어요. 새 프레임워크가 아니에요. 같은 Spring AI 위에서 빈 두 개가 한 자리에 더 놓이는 그림이에요. 지난 시간 ChatModel 옆에 Media 가 추가됐다면, 오늘은 ChatModel 옆에 두 빈 (TranscriptionModel · TextToSpeechModel) 이 나란히 놓이에요.

둘째, 마이크 녹음 같은 프론트엔드 일 은 — 본 강의는 백엔드 중심 모습이에요. 브라우저의 MediaRecorder API 시연 스니펫만 Step 2 에서 짧게 다루고, 핵심은 백엔드의 두 컨트롤러 (/api/voice/transcribe · /api/voice/speak) 예요. 오디오 파일을 멀티파트로 받고 → STT 호출 → 텍스트 응답 / 텍스트 받고 → TTS 호출 → 이진 응답 두에 손이 들어와 있으면, 프론트엔드는 Step 7 에서 통합 시연 으로 한 번 흘려보면 끝이에요.

셋째, 비용 감각 은 — 솔직히 Vision 보다 더 비쌀 수 있어요. 음성은 길이 가 결정 변수라서, 1 분짜리 오디오 한 번 = 텍스트 호출의 3~5 배 정도.

그래서 본 강의 디폴트는 Gemini 2.5 Flash 무료 티어 (음성 인식 가능) + 무료 TTS 옵션 (edge-tts · 브라우저 SpeechSynthesis Web API) 으로 갑니다. 유료 라인 (OpenAI gpt-4o-transcribe STT · OpenAI TTS) 은 비교 매트릭스에서만 다루고, 학생 실습은 완전 무료 옵션 으로 흐르게 정리할게요.

요약하자면 오늘 새로 외울 건 세 가지 단어 예요 — TranscriptionModel / TextToSpeechModel / 5 단 파이프라인.

그리고 세 가지 결정어느 프로바이더로 갈지 (Gemini · OpenAI · 로컬) / 오디오 포맷을 어떻게 정할지 (mp3 · wav · webm) / 비용을 어떻게 가둘지. 지난 시간과 같은 호흡으로 풀면 됩니다.

학습 목표

  • TranscriptionModel / TextToSpeechModel 자매 추상화 를 익히고, Day 2 의 프로바이더 추상화와 Day 7 의 모달리티 자매세 번째 모달리티 (청각) 에서 다시 등장됨을 체득합니다.
  • 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인을 한 자리에서 흘려보면서, 세 추상화가 손을 잡는 그림을 익혀둡니다.
  • Gemini 무료 티어 vs OpenAI STT (gpt-4o-transcribe 디폴트, whisper-1 레거시) vs 로컬 무료 (whisper.cpp · edge-tts) 세 옵션의 비용 · 지연 · 품질 트레이드오프 매트릭스를 익혀둡니다.
  • MultipartFileResource 변환 으로 사용자 업로드 오디오를 STT 모델에 흘리는, byte[] 이진 응답 으로 TTS 결과를 브라우저에 그대로 재생시키는 진행 — 두 가지 입출력 패턴을 익힙니다.
  • 음성 호출 비용 가드 를 지난 시간과 같은 방식으로 (단순 일일 쿼터 + 길이 제한) 정리해두고, 언제 음성을 호출하지 않을지 의 정책을 한 줄로 정리합니다.

Step 1. 음성 모델 선택 가이드 — 어느 라인업으로 갈까

자, 본 Step 으로 들어가기 전에 — 왜 또 모델 선택부터 시작하는가 를 한 번 더 짚고 갈게요. Day 7 (이미지) · Day 8 (Vision) 두 번 모두 Step 1 은 모델 라인업 비교 였죠. 오늘 Day 9 도 같은 이에요. 이유는 한 가지. 모달리티가 새로 등장할 때마다 프로바이더의 능력 (capability) 이 다시 한 번 갈라지기 때문이에요. 텍스트는 대부분의 모델이 다 잘하지만, Vision 은 일부 모델만 가능 했고, 음성은 — 또 다시 주제가 갈려요.

그리고 한 가지가 더 붙어요 — 음성은 텍스트 LLM 호출보다 훨씬 비싼 도시락 지점이에요. 1 분짜리 음성 한 번 인식이 텍스트 호출 30~50 번 분량 의 비용 (OpenAI gpt-4o-transcribe $0.006/분 기준). TTS 한 번이 5~10 번 분량. 그래서 어느 라인업으로 가느냐 의 결정이. 비용을 가둘 수 있느냐학습 진입장벽이 어디까지 낮아지느냐 두 축에서 동시에 묶여요.

본 강의는 완전 무료 라인업 으로 흐르되 — 유료 라인업 의 도 한 곳에 정리해둬요. 학생이 졸업 후 실무에서 OpenAI STT 라인 (gpt-4o-transcribe · whisper-1) 을 만났을 때 — 첫 호흡이 흐트러지지 않게. 그게 오늘 Step 1 의 감각이에요.

1. 첫 주제 — 음성은 STT · TTS 두 가지로 쪼개진다

음성 도메인은 텍스트 / 이미지와 한 가지 다른 게 있어요.

입력과 출력이 — 서로 다른 모델 로 처리돼요. Vision 은 ChatModel 이 그림과 텍스트를 동시에 받아냈지만, 음성은 듣는 모델 (STT, Speech-to-Text) 과 말하는 모델 (TTS, Text-to-Speech) 이 분리 돼 있어요.

그래서 오늘 비교 매트릭스도. STT 표 한 장 + TTS 표 한 장 두 부분로 나눠 펼쳐요.

기억해둘 한 줄은 이거예요.

"음성 = 듣는 모델 (STT) + 말하는 모델 (TTS) 두 개. 같은 프로바이더가 둘 다 잘할 수도 있고, 한쪽만 잘할 수도 있어요. 그래서 서로 다른 프로바이더를 STT/TTS 에 섞어 쓰는 단계도 흔해요."

예를 들어 — STT 는 Gemini 무료 티어로, TTS 는 브라우저 SpeechSynthesis 갈 수 있어요.

듣는 부분말하는 부분완전히 분리된 빈 으로 들어가 있으니까요.

이 방식이 들어오면 — Step 2 에서 보게 될 TranscriptionModel · TextToSpeechModel 두 자매 추상화가 자연스럽게 받아들여져요.

2. STT (Speech-to-Text) 라인업 비교 — 듣는 모델 👂

자, 첫 주제 — 듣는 모델 의 라인업을 펼쳐볼게요. 2026-04 시점, 사용할 수 있는 옵션은 네 가지 예요.

(1) OpenAI gpt-4o-transcribe2026-04 시점 OpenAI 의 음성 인식 디폴트 라인 모습이에요. 분당 $0.006 (1 분 음성 한 번 ≈ 텍스트 호출 30~50 회 분량) 가격은 whisper-1 과 동일 한데 한국어 인식 정확도는 더 높은 모델이에요. GPT-4o 가 음성 인식까지 확장된 라인이라 잡음·억양·코드 스위칭 같은 까다로운 자루를 더 단단하게 받아요. 유료 운영 배포의 1 순위 + Spring AI 1.1.x OpenAI 스타터에 자동 등록 되는 모델. 본 강의 실습 코드의 디폴트 가 여기예요 (.envOPENAI_STT_MODEL=gpt-4o-transcribe).

(2) OpenAI gpt-4o-mini-transcribe동일 라인의 저단가 버전. 분당 $0.003. gpt-4o-transcribe반값. 정확도가 한 단계 낮지만 학생 실습·대량 호출 자리엔 매력적이에요.

본 강의는 디폴트는 gpt-4o-transcribe 로 두되, 비용 절감이 필요하면 .env 한 줄 (OPENAI_STT_MODEL=gpt-4o-mini-transcribe) 로 갈아끼우면 끝 자리이에요.

(2-b) OpenAI whisper-1 — 레거시 옵션 — 음성 인식 분야의 고전 정답. 가격은 gpt-4o-transcribe완전히 동일 ($0.006/분) 한데 모델 자체는 2022 년 라인.

OpenAI 가 하위 호환 (deprecated 가 아닌 legacy compatibility) 상태로 유지 하고 있어요. 기존에 whisper-1 로 박혀 있던 시스템의 단순 호환 이 아니라면 — 오늘 새로 시작하는 코드는 gpt-4o-transcribe 로 가는 방식이 맞아요. 본 강의에서는 과거 코드베이스를 만났을 때를 위해 라인업에만 정리 해두는 정도예요.

(3) Gemini 2.5 Flash (audio input)학생 실습 무료 라인 부분이에요. Gemini 의 audio input 모달리티는 일일 무료 쿼터 안 에 들어가요. 1 분짜리 음성을 input 토큰으로 카운트하긴 하는데. 무료 티어 RPD 가 학생 실습엔 충분히 들어가 있어요. 한국어 인식 품질도 수준이고, Day 8 까지 정리해둔 GEMINI_API_KEY 한 줄로 그대로 호출 가능. 학생 진입장벽이 가장 낮은. 🌟

(4) 로컬 whisper.cpp완전 무료 + 오프라인 곳이에요. CPP 로 포팅된 Whisper 모델을 노트북 안에서 직접 돌려요. 한국어도 medium 모델 이상이면 OK. 단점은 첫 호출 모델 로딩에 5~30 초 + GPU 가 없으면 응답 자체가 느려요.

그리고 모델 다운로드 (medium ≈ 1.5GB) 가 첫 시작 비용. 프라이버시가 핵심인 부분 또는 완전 오프라인 환경 에서만 1 순위.

옵션 비용 한국어 음질 응답 시간 (1 분 음성) 세팅 추천 곳
OpenAI gpt-4o-transcribe 🔴 분당 $0.006 🟢 최상 🟢 3~5 초 🟢 API 키 한 줄 🌟 본 강의 실습 디폴트 / 운영 배포 1 순위
OpenAI gpt-4o-mini-transcribe 🟡 분당 $0.003 🟢 상 🟢 3~5 초 🟢 API 키 한 줄 저단가 라인
OpenAI whisper-1 (레거시) 🔴 분당 $0.006 🟢 최상 🟢 3~7 초 🟢 API 키 한 줄 레거시 코드 호환
Gemini 2.5 Flash (audio input) 🟢 무료 (일일 쿼터) 🟢 상 🟢 2~5 초 🟢 API 키 한 줄 🌟 학생 무료 실습 라인
로컬 whisper.cpp (medium) 🟢 완전 무료 🟡 중~상 🔴 10~60 초 (CPU) 🔴 모델 다운로드 ~1.5GB 오프라인 / 보안망

🟢 = 우수 / 🟡 = 보통 / 🔴 = 약점

여기서 Spring AI 1.1.x 의 추상화를 한 줄 정리할게요. STT 의 표준 인터페이스는 org.springframework.ai.audio.transcription.TranscriptionModel 이에요 (Step 2 에서 시그니처를 펼칠 자리). 그리고. OpenAI 스타터에 OpenAiAudioTranscriptionAutoConfiguration 이 들어가 있어요.

그래서 gpt-4o-transcribe / gpt-4o-mini-transcribe / whisper-1 같은 OpenAI 라인업은 모델 식별자 한 줄 (OPENAI_STT_MODEL) 만 바꿔도 동일한 빈으로 자동 등록 돼요.

추상화 학습이 가장 매끄러운 자리는 OpenAI 스타터 예요. 이게 본 강의의 교육용 코드OpenAI 추상화 위에서 흐르는 이유예요.

3. TTS (Text-to-Speech) 라인업 비교 — 말하는 모델 🗣️

이제 두 번째 주제 — 말하는 모델 의 라인업이에요. TTS 는 STT 보다 옵션이 더 다양 해요. 완전 무료에 가까운 부분 가 두 부분나 있거든요 (브라우저 내장 + Microsoft Edge 의 unofficial).

(1) OpenAI TTS (tts-1 / tts-1-hd) — Spring AI 1.1.x 시점의 유료 1 순위. tts-1 은 $0.015 / 1K chars (한국어 짧은 답변 한 번 ≈ 텍스트 호출 5~10 번 분량), tts-1-hd 는 두 배 가격으로 오디오 품질이 한 단계 더 또렷 해요.

한국어 자연스러움도 상위권. 운영 배포에서 유료를 감수할 수 있는 부분 의 첫 번째 후보.

(2) OpenAI 신모델 (gpt-4o-mini-tts) — 1.1.x 신라인. 음성 캐릭터 (voice) 가 다양 하고 — instructions 옵션 으로 "차분한 톤으로 읽어줘" 같은 방식을 직접 지시할 수 있는 부분. ai-friends 의 캐릭터별 음성 차별화 시나리오에 매력적 모습이에요.

(선택 실습.)

(3) Gemini TTS — Gemini Audio 출력. 일일 무료 쿼터 안에서 — 텍스트 응답을 음성으로 받아올 수 있어요. 한국어 음성 품질은 수준. Gemini 키 한 줄로 시작.

(4) edge-ttsMicrosoft Edge 의 TTS Web Service 를 unofficial 로 호출하는 Python CLI 예요. 완전 무료 + 한국어 음성 다양 (남성/여성 여러 보이스) — 매력이 큰 옵션이에요. 단. unofficial 이라 API 변경 위험 이 항상 따라와요. Microsoft 가 어느 날 갑자기 막아버리면 거기에 있던 모든 시스템이 멈춰요. 그래서 본 강의에선 시연 옵션 으로만 정리해두고, 프로덕션 의존은 비추천.

(5) 브라우저 SpeechSynthesis Web API백엔드 비용 0 원. 브라우저가 기기 안에서 직접 합성 해요. 한국어 음성은 OS 의존 이라. 맥은 자연스럽고, 윈도우는 조금 기계적 이고, 모바일은 또 다른. 그래도 학습용 폴백 (fallback) 으로 가장 가벼운. <button onclick="speechSynthesis.speak(new SpeechSynthesisUtterance('안녕하세요'))"> 같은 한 줄짜리 자바스크립트로 끝.

(6) ElevenLabs (eleven_multilingual_v2)캐릭터 보이스 라인업이 가장 두꺼운 프로바이더예요. prebuilt voice 수백 종 + voice cloning 까지. 미연시·오디오북·게임 보이스 시장의 디폴트 라인업 자리이에요. OpenAI TTS 가 narrator + 자연스러운 한국어 발음 에 강하다면, ElevenLabs 는 반말 + 끝에 "ㅎㅎ" 이 묻는 톤 같은 캐릭터 연기 가 살아나는. 단점은 두 자루. 한국어 억양은 voice 운에 가까워서 (특정 voice 만 자연스럽고 전부 잘 되진 않아요), voice 식별자가 이름이 아닌 UUID 라 들고 다녀야 해요. Spring AI 1.1.x 의 OpenAI 와 함께 공식 starter 가 박혀 있는 두 번째 TTS 프로바이더 부분이에요 (spring-ai-starter-model-elevenlabs). 비용은 무료 티어 月 10,000 글자, 그 이후 100만자당 $30 정도 — OpenAI gpt-4o-mini-tts 의 ~50배. 오늘 본 강의는 OpenAI 디폴트로 가되, 마무리 섹션에서 .env 한 줄로 ElevenLabs swap 되는 토대 가 들어가 있어요.

옵션 비용 한국어 음질 음성 다양성 안정성 추천 모습
브라우저 SpeechSynthesis 🟢 백엔드 0 원 🟡 중 (OS 의존) 🟡 OS 의존 🟢 표준 Web API 학습용 폴백
Gemini TTS (audio output) 🟢 무료 (일일 쿼터) 🟢 상 🟡 보통 🟢 안정 🌟 무료 + 품질
OpenAI tts-1 / tts-1-hd 🔴 $0.015~0.030 / 1K 🟢 상~최상 🟢 다양 🟢 안정 운영 배포
OpenAI gpt-4o-mini-tts 🟡 합리적 🟢 상 🟢 매우 다양 + instructions 🟢 안정 운영 배포 (신라인)
edge-tts (CLI) 🟢 완전 무료 🟢 상 🟢 다양 🔴 unofficial 변경 위험 시연 / 사이드 프로젝트
ElevenLabs eleven_multilingual_v2 🔴 $$$ (무료 月 1만 자) 🟡 voice 따라 다름 🟢🟢 캐릭터 보이스 최강 (수백 종 + cloning) 🟢 안정 (공식 starter) 미연시 캐릭터 보이스 / 마무리 섹션 swap

Spring AI 1.1.x 의 TTS 표준 인터페이스는 org.springframework.ai.audio.tts.TextToSpeechModel 또는 OpenAI 한정의 OpenAiAudioSpeechModel 이에요 (정확한 클래스명은 Step 2 에서 코드베이스의 build 의존성과 함께 펼칠 부분). STT 와 마찬가지로. OpenAI 스타터에 가장 잘 들어가 있고, Gemini · edge-tts 는 직접 RestClient 로 우회 하는 진행이 2026-04 시점의 보편 곳이에요.

4. 비교 매트릭스 한 화면에 — 4 가지 라인업의 합본

자, STT/TTS 두 표를 한 화면 에 묶어볼게요. 학생이 익히고 다닐 수 있는 합본 매트릭스예요.

옵션 STT TTS 비용 한국어 음질 세팅 추천 자리
Gemini 2.5 Flash ✅ (audio input) ✅ (audio output) 🟢 무료 (일일 쿼터) 🟢 상 API 키 한 줄 🌟 본 강의 디폴트 / 처음 배우기
OpenAI STT + TTS ✅ (gpt-4o-transcribe / gpt-4o-mini-transcribe / whisper-1 레거시) ✅ (gpt-4o-mini-tts / tts-1 / tts-1-hd) 🔴 $$ 🟢 최상 API 키 한 줄 본 강의 실습 디폴트 + 운영 배포
로컬 whisper.cpp + edge-tts ✅ (whisper.cpp) ✅ (edge-tts CLI) 🟢 완전 무료 🟡 중~상 🔴 도구 설치 (~1GB+) 오프라인 / 보안 망
브라우저 SpeechSynthesis ❌ (브라우저는 STT 전용 X) ✅ (Web API) 🟢 무료 🟡 중 (OS 의존) 🟢 0 (브라우저 내장) 학습용 / 폴백

이 매트릭스가 들어오면 — 왜 본 강의가 Gemini 디폴트 + 브라우저 SpeechSynthesis 폴백의 조합으로 흐르는지가 자연스럽게 풀려요. 비용 0 원에서 시작 + 학생 진입장벽 최소화 + 추상화 학습 가능 세 축이 동시에 잡히는 부분니까요.

5. 본 강의 디폴트 — Gemini 디폴트 + 추상화는 OpenAI 스타터 🌟

여기서 솔직한 결정 한 줄 을 정리해두고 갈게요. 본 강의의 디폴트 결정은 조금 묘한 방식 지점이에요.

  • STT: 호출 자체는 Gemini 2.5 Flash (audio input) 무료 티어 로 가지만, 추상화 학습은 OpenAI 스타터의 TranscriptionModel 인터페이스 (OpenAiAudioTranscriptionModel) 위에서 흘러요.
  • TTS: 학습용으론 브라우저 SpeechSynthesis Web API 로 0 원에 시작하고, 실습 후반부 (선택)OpenAI TTS 또는 edge-tts 로 갈아끼우는.

솔직한 트레이드오프 한 박스

Spring AI 1.1.x 의 STT/TTS 자매 추상화는 — 현 시점 OpenAI 스타터에 가장 잘 들어가 있어요. OpenAiAudioTranscriptionAutoConfiguration · OpenAiAudioSpeechAutoConfiguration 이 빈을 자동 등록해 주는 부분은 OpenAI 가 1 순위. Gemini 의 audio input 은 Spring AI 1.1.x 에서 ChatModel 의 multimodal 입력 으로 들어가는 진행이 더 자연스럽고, 별도 TranscriptionModel 인터페이스에 빈으로 자동 등록되지는 않아요.

그래서 본 강의는 — 교육 목표인 추상화 감각 (TranscriptionModel · TextToSpeechModel) 은 OpenAI 스타터의 인터페이스로 잡고, 실제 호출은 학생이 무료로 흘릴 수 있는 옵션 (Gemini · 브라우저 · 모킹) 으로 분리해요. 추상화 학습 vs 실제 호출 의 두 방식을 다른 빈 으로 풀어두는 결정이에요. 운영 배포 단계에서는 거기에 OpenAI 키만 정리하면 — 같은 컨트롤러가 그대로 흘러가는 그림이 되고요.

6. 🙋 한 학생의 날카로운 질문

"튜터님, 그럼 Gemini 무료 티어로 STT/TTS 가 다 되는데 — 왜 OpenAI 스타터의 추상화를 굳이 쓰나요? Gemini 그냥 RestClient 로 직접 부르면 안 되나요?"

🔥 정말 좋은 질문이에요. 이 질문이 — 이 강의의 결정이 묘한 이유 를 정확히 짚었어요. 세 가지 방식으로 풀어드릴게요.

  1. Spring AI 의 교육 목표는 추상화 자체예요. TranscriptionModel.call(prompt) 한 줄 — 어느 프로바이더든 같은 방식으로 부른다는 감각이 오늘의 진짜 학습 목표예요.
    • 익혀야 할은 프로바이더 호출 코드의 디테일이 아니라, Spring AI 가 어떻게 음성 도메인을 추상화했는가 예요.
    • RestClient 로 Gemini 를 직접 부르는 코드는 Day 1 이전의 ai-friends 코드베이스에 이미 들어가 있던 흐름. 그 방식을 한 단계 추상화한 부분이 오늘 학습할 감각이고요.
  2. 운영 배포 시 프로바이더 스위칭의 자유가 살아있어요. 학습 시점엔 Gemini 무료 티어로 흐르더라도, 실서비스에서 OpenAI gpt-4o-transcribe 로 갈아끼울 곳이 자연스럽게 열려 있어야 해요.
    • 추상화 위에서 흐르는 컨트롤러 코드는 프로바이더가 바뀌어도 변하지 않는 부분.
    • 학습 시점부터 그 방식을 손에 정리해두면 — 졸업 후 실무에서 같은 호흡으로 옮겨갈 수 있어요. Day 2 의 프로바이더 추상화가 세 번째 모달리티에서 다시 등장하는 자리고요.
  3. 모킹 + 통합 테스트가 매끄러워져요. 추상화 위에서 컨트롤러를 짜두면 — @MockBean TranscriptionModel 한 줄로 호출을 모킹하고 컨트롤러만 통합 테스트가 가능해요. RestClient 로 직접 부르는 진행은 모킹이 훨씬 흐려져요. 본 강의 코드의 일부 테스트가 모킹된 빈 위에서 흐르는 이유예요.

요약하자면 — 추상화 감각 (교육 목표) + 프로바이더 자유 (운영) + 테스트 용이성 (품질) 세 축에서 추상화 위에서 흐르는 진행 이 우세해요. 호출 자체는 무료 옵션으로 가두고, 추상화는 OpenAI 스타터의 인터페이스로 잡는 결정이 — 그 세 축을 동시에 잡는 모습이에요.

7. 비용 감각 한 줄 — 음성은 텍스트 30 배 비싼 도시락

마지막 부분은 비용 감각 자리이에요. 지난 시간 Day 8 마무리에서 Vision 호출은 텍스트의 1.5~2 배 라고 정리해뒀던, 기억나시죠? 음성은 — 그보다 훨씬 더 비싼 도시락 부분이에요. 한 줄로 손에 정리할게요.

  • 1 분짜리 음성 STT 1 번 ≈ 텍스트 LLM 호출 30~50 번 분량 (OpenAI gpt-4o-transcribe · whisper-1 모두 동일가 $0.006/분 기준).
  • 짧은 답변 TTS 1 번 ≈ 텍스트 LLM 호출 5~10 번 분량 (tts-1 기준).

Vision 보다 한 단계 더 비싸요. 그래서 본 강의의 비용 가드 (VoiceDailyQuotaGuard) 는 지난 시간 Vision 가드의 결과 같지만 — 길이 제한 (예: 30 초 이상 음성은 거절) 한 줄이 더 들어가요. 언제 음성을 호출하지 않을지 의 정책이 — 지난 시간보다 한 줄 더 깊어요. 단. 오늘의 Step 7 에선 의도적으로 비워두고, 학생 손으로 직접 정리하는 과제 로 미뤄둘게요. 5 단 파이프라인의 골격을 가장 선명하게 보이기 위함이고, 가드 패턴 세 번째 등장의 진짜 감각은 직접 정리해보는 데에 있어요.

한 가지 짚어둘 — 학생 실습 부분 에서는 Gemini 무료 티어 + 브라우저 SpeechSynthesis 로 가니까 비용 0 원 곳이에요. 위 비용 감각은 졸업 후 OpenAI 라인업으로 운영 배포할 때 모습이고요. 오늘은 감각만 무료로 익히고, 비용은 머리에 정리해두는 호흡으로 가요.

8. 💡 튜터의 결론 — Step 1 한 줄

"음성은 STT · TTS 두 개로 쪼개진다. 본 강의는 Gemini 2.5 Flash 무료 티어 (STT) + 브라우저 SpeechSynthesis (TTS) 학습용 폴백 으로 비용 0 원에 흐르되, 추상화 감각은 OpenAI 스타터의 TranscriptionModel · TextToSpeechModel 인터페이스 위에서 정리한다. 추상화 vs 실제 호출의 두 방식이 — 다른 빈 으로 분리되어 운영 배포 시 프로바이더 스위칭의 자유 가 살아있는 그림. Day 2 의 프로바이더 추상화가 세 번째 모달리티에서 다시 등장 되는 자리이다."

자, 어느 라인업으로 갈지 가 익히셨으니 — 이제 그 라인업을 어떤 인터페이스로 부를 것인가 가 다음 주제예요. Spring AI 1.1.x 가 우리한테 던져주는 두 자매 인터페이스 — TranscriptionModel · TextToSpeechModel. 그리고. 브라우저에서 마이크로 음성을 어떻게 녹음해서 백엔드로 흘릴지 의 첫 단계인 MediaRecorder API 시연. Step 2 에서 자매 추상화의 시그니처와 마이크 녹음의 첫 입자 부터 펼쳐봅시다.


Step 2. 자매 추상화 두 빈 + 마이크 녹음 감각 (약 25분)

자, Step 1 에서 어느 라인업으로 갈지 의 결정이 들어왔으니 — 이제 그 라인업을 어떤 모양으로 부를지가 본 Step 의 미션이에요. 결론부터 한 줄로 정리할게요.

"Spring AI 1.1.x 는 음성 도메인을 두 빈 으로 쪼개 추상화했어요. TranscriptionModel (듣는 빈) 과 TextToSpeechModel (말하는 빈) 이 나란히 들어가요. Day 2 의 ChatModel 옆에, Day 7 의 ImageModel 옆에 — 세 번째로 자매 추상화가 한 번 더 등장하는 지점이에요."

오늘 펼칠 감각은 두 가지예요. 첫째는 — Spring AI 의 두 자매 인터페이스 시그니처를 머릿속에 정리해두는. 둘째는 — 브라우저의 마이크 녹음 (MediaRecorder) 으로 첫 음성 입자를 익히어보는. 둘 다 Step 3 이후의 자바 코드 가 흐를 단계을 위에서 내려다보며 미리 그려두는 호흡이에요.

1. 세 번째 등장 — 자매 추상화 라는 진행

본 Step 의 진짜 호흡 을 이해하려면 — 익혀둔 지난 시간까지의 자매 추상화를 한 번 다시 떠올려봐야 해요. 우리는 지난 8 일 동안 같은 모양의 추상화 를 세 번 만났어요.

(1) Day 2 — ChatModel 의 첫 등장

기억나시죠? Day 2 첫날 "OpenAI 든 Gemini 든 Ollama 든, 코드 한 줄도 안 바꾸고 .env 한 줄로 프로바이더를 갈아끼울 수 있다" 는 감각이 들어갔어요. ChatModel 인터페이스 하나로 — 어느 프로바이더가 떨어져도 컨트롤러는 그대로 흐르는. 프로바이더 추상화 의 첫 회수였어요.

(2) Day 7 — ImageModel 의 자매 추상화

Day 7 에선 모달리티가 한 부분 더 추가됐어요.

그림 생성 이라는 다른 모달리티에서도 — ImageModel.call(imagePrompt) → ImageResponse같은 방식 이 등장했죠.

ChatModel.call(prompt) → ChatResponse그대로 복제 된 자매.

모달리티 자매 추상화 의 두 번째 등장였어요.

(3) Day 9 — TranscriptionModel · TextToSpeechModel 두 자매 한꺼번에

그리고 오늘 — 세 번째 등장 가 와요.

이번엔 두 자매가 동시에 등장해요.

TranscriptionModel (듣는 빈) 과 TextToSpeechModel (말하는 빈).

이 둘은 입력 모달리티 (음성 → 텍스트)출력 모달리티 (텍스트 → 음성) 가 — 완전히 반대 방향 인 자매예요.

그런데 Spring AI 의 인터페이스 모양은 둘 다 chatModel.call(prompt) 의을 그대로 따르고 있어요.

이 방식이 들어오면 — Step 3 부터 흐를 자바 코드의 낯섦 이 사라져요. 완전히 새로 외우는 게 아니라, Day 2 의 감각이 음성 도메인에 한 번 더 복제된 부분니까요.

2. TranscriptionModel듣는 빈 의 시그니처 👂

자, 첫 번째 자매 — 듣는 빈 모습이에요. Spring AI 1.1.x 의 표준 인터페이스 풀네임은 이거예요.

org.springframework.ai.audio.transcription.TranscriptionModel

핵심 메서드는 한 줄이에요.

AudioTranscriptionResponse call(AudioTranscriptionPrompt prompt)

ChatModel.call(Prompt)완전히 같은 모양 이죠? 입력은 Prompt 자루 하나, 출력은 Response 자루 하나. 자매 추상화가 그대로 살아 있어요.

세부 구조를 한 단계 더 펼쳐볼게요.

  • 입력 (AudioTranscriptionPrompt)Spring core 의 Resource 한 개를 받아요. new AudioTranscriptionPrompt(audioResource) 같은 생성자.
    • 오디오 바이트 를 어떤 모양으로든 Resource 로 감싸기만 하면 STT 모델에 던질 준비 끝.
    • 파일이면 FileSystemResource, 메모리 바이트면 ByteArrayResource, 클래스패스 안의 샘플이면 ClassPathResource — 어느 모양이든 같은 인터페이스 예요.
  • 출력 (AudioTranscriptionResponse).getResult().getOutput() 한 줄로 텍스트 String 이 떨어져요. ChatResponse.getResult().getOutput().getText()같은 방식 의 추출 패턴. 손이 한 번에 들어와요.

빈 등록은 — Step 1 에서 정리해둔 그 방식 그대로예요. OpenAI 스타터의 OpenAiAudioTranscriptionAutoConfiguration자동으로 TranscriptionModel 빈을 등록해줘요. 컨트롤러에선 그냥 생성자 주입 한 줄.

// Step 3 에서 본격 펼칠 흐름 — 미리 머리에 박아두는 시그니처
private final TranscriptionModel transcriptionModel;

public String transcribe(Resource audioResource) {
    AudioTranscriptionPrompt prompt = new AudioTranscriptionPrompt(audioResource);
    AudioTranscriptionResponse response = transcriptionModel.call(prompt);
    return response.getResult().getOutput();
}

위 스니펫은 Step 3 에서 실제로 코드베이스에 박을 곳의 미리보기예요. Spring AI 1.1.x 의 정확한 시그니처 (특히 TextToSpeechModel 쪽) 는 Step 5 의 TDD 검증 단계에서 코드베이스의 build.gradle 의존성과 함께 한 번 더 확인 할 거예요. 학습 호흡상 시그니처를 미리 머릿속에 그려두는 부분라고 생각해주세요.

3. TextToSpeechModel말하는 빈 의 시그니처 🗣️

두 번째 자매 — 말하는 빈 자리이에요. Spring AI 1.1.x 의 공통 추상화 패키지 에 들어간 인터페이스가 풀네임은 이거예요.

org.springframework.ai.audio.tts.TextToSpeechModel (1.1.x 공통 추상화)

OpenAI 구현체는 org.springframework.ai.openai.OpenAiAudioSpeechModelOpenAiAudioSpeechAutoConfiguration위 인터페이스의 빈으로 자동 등록해줘요. 컨트롤러 / 서비스 부분에선 인터페이스 (TextToSpeechModel) 로만 받으면 — 어느 구현체가 떨어져도 같은 방식으로 흐릅니다 (본 강의의 프로바이더 추상화 원칙).

핵심 메서드도 같은 이에요.

TextToSpeechResponse call(TextToSpeechPrompt prompt)

세부 구조를 한 단계 더 펼쳐볼게요.

  • 입력 (TextToSpeechPrompt)합성할 텍스트 String 한 줄을 받아요. new TextToSpeechPrompt("안녕하세요, 저는 쿠로미예요!") 같은 모양.
    • 옵션을 더 박고 싶으면 new TextToSpeechPrompt(text, OpenAiAudioSpeechOptions.builder().voice("alloy").build()) 처럼 음성 캐릭터·포맷·속도 를 추가로 지정할 수 있어요.
  • 출력 (TextToSpeechResponse).getResult()Speech 하나가 떨어지고, .getOutput() 한 줄로 byte[] 가 떨어져요. 바이트 배열 자체 가 음성 데이터예요 (디폴트로 mp3 인코딩). 그대로 컨트롤러의 응답 본문에 흘려보내면 — 브라우저의 <audio> 태그가 받아서 재생해요.
// Step 5 에서 본격 펼칠 흐름 — 미리 머리에 박아두는 시그니처
private final TextToSpeechModel textToSpeechModel;

public byte[] synthesize(String text) {
    TextToSpeechResponse response = textToSpeechModel.call(new TextToSpeechPrompt(text));
    return response.getResult().getOutput();
}

빈 등록은 — OpenAI 스타터의 OpenAiAudioSpeechAutoConfiguration 이 자동으로 TextToSpeechModel 빈(구현체는 OpenAiAudioSpeechModel) 을 등록해줘요. 듣는 빈말하는 빈 두 자매가 같은 스타터 안에서 나란히 들어가 있는 그림이에요. 부엌의 도마 가 한 부분에 놓이듯, 서로을 보완하는 두 도구가 한 모습에 들어와 있죠.

4. 왜 두 빈 으로 쪼개졌을까

자, 여기서 결정적인 질문 한 줄을 던져볼게요.

"튜터님, 음성 모달리티는 — 한 빈 (AudioModel 같은 자루) 안에 듣기 + 말하기 를 다 묶을 수도 있었잖아요? 왜 굳이 두 빈 으로 쪼갰나요?"

이 질문이 익히어지면 — Spring AI 의 추상화가 왜 이렇게 들어갔는가 라는 깊은에 닿아요. 세 가지 방식으로 풀어드릴게요.

(1) 입력 모달리티 ↔ 출력 모달리티의 처리이 완전히 달라요

STT 호출은 오디오 바이트 → 텍스트 의 변환이에요. 입력 payload 가 수 MB ~ 수십 MB, 처리 시간이 3~10 초, 비용이 분당 과금 (시간 기반) 의. TTS 호출은 정반대예요. 텍스트 String → 오디오 바이트 입력 payload 가 수 KB 이하, 처리 시간이 0.5~3 초, 비용이 글자 수 기반 (1K chars 단위) 의. latency · 비용 모델 · 호출 패턴 이 — 세 축 모두에서 다른 곳이에요. 한 빈으로 묶으면 — 추상화가 흐려져요.

(2) 한 모델이 한쪽만 잘하는 자리이 흔해요

Step 1 의 라인업 매트릭스를 다시 떠올려보세요. WhisperSTT 전용 이에요 — TTS 는 안 해요. 반대로 tts-1 · gpt-4o-mini-ttsTTS 전용 이에요 — STT 는 안 해요. 그리고. 브라우저 SpeechSynthesisTTS 만 가능, whisper.cppSTT 만 가능. 어느 프로바이더가 어느 자루를 들고 있는가 가 — 모델 단위로 다른 지점이에요. 한 빈에 묶어두면 — 반쪽만 구현된 빈 이라는 어색한 방식이 자꾸 등장해요. 두 빈으로 쪼개두면

각 빈이 자기 자루만 책임지면 끝.

(3) 5 단 파이프라인이 자연스럽게 풀려요

오늘 우리가 만들 부분은 — 마이크 → STT → ChatModel → TTS → 스피커 의 5 단 파이프라인이에요.

대화의 핵심 자루 는 결국 텍스트 예요.

ChatModel 이 텍스트를 입력 받아 텍스트를 출력 하니까요.

STT 와 TTS 는 그 텍스트 자루의 입출력 양쪽 끝어댑터 처럼 박히는.

두 빈이 완전히 분리 되어 있으니. 어댑터처럼 끼우고 빼는 자유가 살아있어요. STT 만 끼우고 TTS 는 안 끼우면 → 음성 입력 + 텍스트 출력. TTS 만 끼우고 STT 는 안 끼우면 → 텍스트 입력 + 음성 출력. 두 빈을 모두 끼우면 → 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 로 갈 때는 호출 코드가 한 단계 우회 해야 해요. 추상화의 감각은 살아있되, 프로바이더 생태계의 성숙도가 텍스트 도메인보단 한 박자 늦은 부분이라고 정리해두면 정확해요.

5. 마이크 녹음 — 브라우저의 MediaRecorder API

자, 자매 추상화의 시그니처를 머리에 정리했으니 — 이제 백엔드로 흘려보낼 첫 음성 입자 를 어디서 따올지 한 번 만져봐요. 브라우저의 마이크 녹음 단계이에요. 본 강의는 백엔드 중심 이지만, 이 곳은 5 단 파이프라인의 첫 단 이라 — 짧게라도 익히어둘 가치가 있어요.

브라우저가 우리한테 던져주는 도구는 — Web API 표준 의 두 개예요.

  • navigator.mediaDevices.getUserMedia({ audio: true }) — 사용자에게 마이크 권한 을 요청해요. 첫 호출 시 브라우저가 "이 사이트가 마이크 사용을 요청합니다" 팝업을 띄우고 — 사용자가 허용하면 MediaStream 이 떨어져요. 오디오 스트림의 핸들 부분이에요.
  • MediaRecorderMediaStream 을 받아서 바이너리 오디오 청크 를 토해내는 녹음기예요. recorder.start() 로 녹음 시작, recorder.stop() 으로 끝, 그 사이에 ondataavailable 콜백이 청크 단위로 데이터를 넘겨줘요.
// 마이크 녹음 → Blob 변환 (Step 7 통합 시연에서 사용 예정)
async function recordAudio(maxSeconds = 10) {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
    const chunks = [];
    recorder.ondataavailable = (e) => chunks.push(e.data);
    recorder.start();

    return new Promise((resolve) => {
        setTimeout(() => recorder.stop(), maxSeconds * 1000);
        recorder.onstop = () => {
            stream.getTracks().forEach(t => t.stop());
            resolve(new Blob(chunks, { type: 'audio/webm' }));
        };
    });
}

// 녹음한 Blob 을 백엔드로 업로드 (Step 4 의 /api/voice/transcribe 와 연결)
async function uploadAudio(blob) {
    const form = new FormData();
    form.append('audio', blob, 'recording.webm');
    const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form });
    return await res.json();
}

이 스니펫의 감각을 한 줄씩 짚어볼게요.

  • { mimeType: 'audio/webm' } — 녹음 포맷을 명시적으로 정리해요.
    • 안 정리하면 브라우저 디폴트가 Chrome 은 audio/webm;codecs=opus, Safari 는 audio/mp4서로 달라요.
    • 백엔드에서 어떤 포맷을 받아도 처리할 수 있게 해두든가, 프론트에서 미리 한 포맷으로 강제 해두든가 둘 중 하나가 필요해요. 본 강의는 후자 (강제) + 백엔드 검증 의 안전이에요.
  • chunks.push(e.data) — 녹음 중에 청크가 조각조각 떨어져요. Blob 으로 모아 두에요.
  • stream.getTracks().forEach(t => t.stop())마이크 권한을 명시적으로 닫아주는 한 줄. 안 닫아두면 브라우저 탭의 마이크 표시 빨간 점이 계속 켜진 채 로 남아요. 사용자 신뢰를 위해 반드시 정리해두에요.
  • new FormData() + multipart/form-data — Blob 을 멀티파트 업로드 로 백엔드에 흘려보내요. 백엔드 컨트롤러는 — Step 4 에서 박을 @RequestParam("audio") MultipartFile audio 한 줄로 받아내요. 여기가 자바 코드의 첫 발 이 들어올 부분고요.

6. 포맷 한 줄 — OpenAI STT API 가 받는 자루들

여기서 포맷 결정 한 줄을 정리해두고 갈게요. OpenAI Audio Transcription API 가 받아주는 오디오 포맷은 — 제한된 자루 예요 (gpt-4o-transcribe · gpt-4o-mini-transcribe · whisper-1 모두 같은 엔드포인트 라 동일 목록).

mp3, mp4, mpeg, mpga, m4a, wav, webm — 이 7 가지가 OpenAI STT 가 받는 표준 자루예요.

브라우저 MediaRecorder디폴트로 떨어뜨리는 포맷Chrome 은 webm/opus, Safari 는 mp4/aac 라고 위에 정리해뒀죠? 둘 다 OpenAI STT 의 허용 자루 안 에 들어가요.

그래서 프론트에서 굳이 변환하지 않아도 백엔드가 그대로 받아서 흘려보낼 수 있어요.

호환성이 잘 들어가 있는 부분 라. 학생 실습 단계에선 추가 변환 코드 없이 바로 흘려도 OK.

단 한 가지 정리해둘 — 모바일 브라우저 (특히 iOS Safari)MediaRecorder 지원이 2024 년 이후에 합류 한 모습예요. 본 강의 실습은 데스크톱 Chrome / Edge / Firefox 를 1 순위로 안내하고, 모바일은 Step 7 통합 시연 시 별도 검증 하는 방식으로 갑니다.

7. <audio> 태그 — TTS 응답을 재생하는 진행

자매의 반대쪽 — TTS 응답을 브라우저가 받아 재생하는 도 한 줄 정리할게요. 이게 5 단 파이프라인의 마지막 단 자리이에요.

// /api/voice/speak 의 byte[] 응답을 받아 재생 (Step 6 컨트롤러 + Step 7 통합 시연에서 사용)
async function playTts(text) {
    const res = await fetch('/api/voice/speak', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text })
    });
    const audioBlob = await res.blob();
    const url = URL.createObjectURL(audioBlob);
    new Audio(url).play();
}

감각을 한 줄씩 짚어볼게요.

  • await res.blob() — 백엔드가 byte[] 로 던진 응답을 — 브라우저가 Blob 으로 받아내요. 이진 응답 이라 res.json() 이 아니라 res.blob() 이에요.
  • URL.createObjectURL(audioBlob)메모리 안의 Blob임시 URL 을 발급하는 한 줄. 이 URL 은 현재 페이지가 살아있는 동안만 유효해요. 페이지가 닫히면 자동으로 가비지 컬렉트.
  • new Audio(url).play() — HTML5 의 <audio> 태그를 자바스크립트로 즉석에서 만들어 재생. 사용자에게 보이는 UI 는 없고, 소리만 흘러요.

8. 💡 튜터의 결론 — Step 2 한 줄

"Spring AI 1.1.x 는 음성 도메인을 두 빈 으로 쪼갰다 — TranscriptionModel (듣는 빈) 과 TextToSpeechModel (말하는 빈). Day 2 의 ChatModel · Day 7 의 ImageModel 에 이은 세 번째 자매 추상화 이고, 같은 모양의 .call(prompt) → response 시그니처가 그대로 살아있다. 두 빈으로 쪼개진 이유는 — 입출력 처리이 완전히 다르고, 프로바이더가 한쪽만 잘하는 곳이 흔하며, 5 단 파이프라인이 어댑터처럼 모듈러하게 풀리기 때문. Day 2 의 .env 한 줄 스위칭 감각이 — 음성 도메인에서도 (OpenAI 스타터 위에선) 그대로 살아있다."

이론은 여기까지. 실제로 익숙해질 자매 추상화의 첫 빈 — TranscriptionModel 을 Step 3 에서 직접 호출해봅니다. 오디오 하나를 Resource 로 감싸서 — STT 모델에 던지는 첫 자바 코드가 흐를 부분이에요.


Step 3. `VoiceTranscriptionService` — 첫 자매 빈을 손으로 잡는 모습 (약 25분)

자, Step 2 에서 손으로 그렸던 자매 추상화 — 그 시그니처가 진짜 빈으로 주입되는 부분 가 바로 여기예요.

머리 속에서만 굴러다니던 TranscriptionModel.call(AudioTranscriptionPrompt) 의 한 줄이 — 실제 @Service 클래스의 한 메서드 안에서 흐르는.

Day 8 에서 VisionChatService 를 손에 정리해뒀던 결과 완전히 같은 호흡 으로 갑니다.

본 Step 의 미션은 한 줄로 정리해두면 — "음성 파일(Resource) 하나를 받아 STT 모델에 던지고 → 텍스트 하나로 돌려주는 서비스 빈" 을 익히는 거예요.

코드 자체는 Day 8 의 VisionChatService흐름이 똑같이 흐르는데, 모달리티 한 자리만 음성으로 바뀐.

지난 시간까지의 감각이 한 모달리티만 옆으로 슬쩍 옮겨가는이라 — 손이 가볍게 들어와요.

1. VoiceTranscriptionService — 전체 코드 하나

먼저 완성된 방식 을 한 번에 들여다볼게요. Day 8 의 VisionChatService 와 같은 호흡이라 — 낯선 부분은 두 줄도 안 돼요.

package kr.spartaclub.aifriends.voice.service;

import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt;
import org.springframework.ai.audio.transcription.AudioTranscriptionResponse;
import org.springframework.ai.audio.transcription.TranscriptionModel;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;

/**
 * Day 9 Step 3 — Voice (STT, Speech-to-Text) 도메인 진입점.
 *
 * <p>음성 파일({@link Resource}) 한 개를 받아 {@link TranscriptionModel} 에 전달하고
 * 텍스트 결과를 그대로 돌려준다. {@link TranscriptionModel} 은 인터페이스로만 주입받으므로
 * (본 강의의 프로바이더 추상화 원칙), 빈은 OpenAI `gpt-4o-transcribe` (디폴트) / `whisper-1` (레거시) · 추후 다른 STT 프로바이더 어떤 것이든
 * 될 수 있다 — 호출자는 모른다. 활성 STT 프로바이더는 {@code application.yml} 의
 * {@code spring.ai.model.audio.transcription} 프로퍼티로만 결정된다.</p>
 *
 * <p>응답 텍스트가 null/빈 문자열이면 빈 문자열을 반환한다 (Day 8 {@code VisionChatService}
 * 와 동일한 방어 패턴). null 입력 등 명백한 입력 오류는 {@link VoiceException} 으로
 * 래핑해 {@code GlobalExceptionHandler} 가 ApiResponse.fail 형태로 변환하게 한다.</p>
 *
 * <p>실제 STT 호출은 텍스트 LLM 대비 토큰 비용이 크다 (음성/이미지/비디오 비용 급등 경고). 단위 테스트는
 * Mockito 모킹으로만 검증하고, 실제 호출 smoke 는 강사가 수동으로 한다.</p>
 */
@Slf4j
@Service
public class VoiceTranscriptionService {

    private final TranscriptionModel transcriptionModel;

    public VoiceTranscriptionService(TranscriptionModel transcriptionModel) {
        this.transcriptionModel = transcriptionModel;
    }

    /**
     * 음성 파일을 STT 모델에 보내 인식된 텍스트를 반환한다.
     *
     * @param audio 음성 파일 리소스 (MultipartFile 의 Resource 변환 또는 ClassPathResource)
     * @return 인식된 텍스트. 응답이 비어 있으면 빈 문자열.
     * @throws VoiceException audio 가 null 인 경우 (VOICE_AUDIO_REQUIRED)
     */
    public String transcribe(Resource audio) {
        if (audio == null) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
        }

        AudioTranscriptionResponse response = transcriptionModel.call(new AudioTranscriptionPrompt(audio));

        if (response == null || response.getResult() == null) {
            return "";
        }
        String text = response.getResult().getOutput();
        return text == null ? "" : text;
    }
}

자, 이 코드가 왜 이렇게 들어갔는지 한 줄씩 짚어볼게요.

(1) @Slf4j + @Service — 평범한 빈 등록

가장 위 두 줄은 — Spring 의 보편적인 이에요. @Service 로 빈 자동 등록, @Slf4j 로 Lombok 의 SLF4J 로거를 빈 안에 정리해둬요. 본 코드는 호출 결과를 굳이 INFO 로 찍진 않지만, 추후 가드/예외에서 log.warn(...) 한 줄을 박을 부분 를 미리 열어두에요.

(2) 생성자 주입 — Lombok 없이 명시 생성자

Day 8 의 VisionChatService같은 방식 이에요. @RequiredArgsConstructor 도 한 줄 박을 수 있는데 — 명시 생성자가 한 줄 이라 오히려 가독성이 더 또렷 해요. 그리고 — 이 빈이 어떤 의존성을 주입받는지클래스 모양에서 한눈에 보이는 결이라. 학생 학습용 코드에선 명시 생성자 가 1 순위. Day 8 → Day 9 의 톤 통일 도 같이 가져가는 셈이고요.

private final TranscriptionModel transcriptionModel;

public VoiceTranscriptionService(TranscriptionModel transcriptionModel) {
    this.transcriptionModel = transcriptionModel;
}

여기서 결정적인 한 줄 은 — 주입받는 타입이 TranscriptionModel 인터페이스 라는 점이에요. OpenAiAudioTranscriptionModel 같은 구현체 타입으로 박지 않아요. 본 강의의 프로바이더 추상화 원칙이 — 세 번째 모달리티에서도 그대로 살아있는 곳이에요. 호출자(이 서비스) 는 활성 STT 프로바이더가 누구인지 모른다. application.ymlspring.ai.model.audio.transcription 한 줄이 그 결정을 쥐고 있고, 이 빈은 그 결정을 신경 안 써도 흐르는 지점이에요.

(3) transcribe(Resource audio) — 핵심 메서드의 시그니처 👂

메서드 하나의 모양을 보세요. 입력은 Resource 한 개, 출력은 String 한 줄. Spring core 의 Resource 인터페이스 위에 완전히 추상화 돼요. 이게 왜 중요 한지는 — 다음 박스에서 한 학생이 바로 그 부분를 찔러줄 거예요.

(4) null 가드 — VoiceException(VOICE_AUDIO_REQUIRED)

if (audio == null) {
    throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
}

여기에 본 강의의 예외 처리 규약 이 또 다시 만나요 — IllegalArgumentException 직접 던지지 않는다 는. 도메인별 커스텀 예외(VoiceException) 로 래핑해서. GlobalExceptionHandler자동으로 ApiResponse.fail(ErrorResponse) 형태로 변환하게 만들어요. Day 8 의 VisionException (V001~V005) 과 완전히 같은 방식 모습이에요. 세 번째로 다시 등장하는이고요.

(5) transcriptionModel.call(new AudioTranscriptionPrompt(audio)) — 자매 추상화의 진짜 한 줄

AudioTranscriptionResponse response = transcriptionModel.call(new AudioTranscriptionPrompt(audio));

이 한 줄이 — Step 2 에서 머리에 그려둔 시그니처가 진짜로 흐르는 자리이에요. Resource 하나를 — AudioTranscriptionPrompt 라는 자루에 한 번 더 포장 하고, TranscriptionModel.call() 한 줄에 던져요. 출력은 AudioTranscriptionResponse Day 2 의 ChatModel.call(prompt) → ChatResponse · Day 7 의 ImageModel.call(imagePrompt) → ImageResponse완전히 같은 모양의 시그니처 가 — 세 번째로 익히셨어요.

(6) 응답 추출 — null 안전 빈 문자열 폴백

if (response == null || response.getResult() == null) {
    return "";
}
String text = response.getResult().getOutput();
return text == null ? "" : text;

세 단계 방어막이 들어가 있어요.

response 가 null 인 경우, response.getResult() 가 null 인 경우, 마지막으로 getOutput() 이 null 인 경우 — 셋 다 빈 문자열로 폴백.

이게 Day 8 VisionChatService동일한 방어 패턴 단계이에요.

프로바이더가 (간헐적으로) 빈 응답을 흘릴 때 NullPointerException 으로 호출 체인이 무너지지 않도록 막아두는 결.

💡 튜터의 결론 — Step 3 의 호흡 한 줄

Step 2 에서 머리로 그렸던 자매 추상화의 시그니처가 — 한 메서드 안에 그림처럼 응축되는. Resource → AudioTranscriptionPrompt → TranscriptionModel.call → AudioTranscriptionResponse → String 의 5 단 변환이 — 단 한 줄 의 호출로 묶이고, 호출자는 프로바이더가 누구인지 모른다. Day 8 VisionChatService감각이 음성 도메인으로 그대로 옮겨온.

2. VoiceException — 도메인별 예외의 세 번째 등장

VoiceTranscriptionService 가 던지는 예외 — VoiceException 의 본문을 하나 펼쳐볼게요. 이 방식이 왜 굳이 도메인별로 쪼개졌는가 가 본 Step 의 또 다른 곳이에요.

package kr.spartaclub.aifriends.voice.exception;

import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;

/**
 * Day 9 Step 3 — Voice (STT/TTS) 도메인 커스텀 예외.
 *
 * <p>{@code IllegalArgumentException} / {@code RuntimeException} 직접 throw 금지 규칙을 따라,
 * 음성 파일 업로드 검증 실패 · STT 호출 실패 · TTS 합성 실패 등은 모두 이 예외로 래핑한다.
 * {@link ErrorCode} 의 {@code VOICE_*} 항목과 함께 던지면 {@code GlobalExceptionHandler}
 * 가 ApiResponse.fail 형태로 자동 변환해준다.</p>
 */
public class VoiceException extends BusinessException {

    public VoiceException(ErrorCode errorCode) {
        super(errorCode);
    }
}

본문은 놀랍도록 짧아요. 부모 클래스 BusinessException 의을 그대로 상속받아 — 생성자 한 줄만 추가.

그런데 바로 이 짧음이 — 패턴을 또렷하게 만들어요.

Day 7 의 ImageException, Day 8 의 VisionException, 그리고 오늘의 VoiceException세 도메인의 예외가 모두 같은 모양의 그릇 인 부분이에요.

도메인별로 예외 패키지를 왜 굳이 쪼개는가 — 한 줄로 짚어두면 추후 도메인이 늘어날 때를 위한 포석이에요. Vision 의 예외와 Voice 의 예외가 한 패키지에 섞여 있으면 — Day 9 시점엔 두 도메인뿐이라 별 차이 없는 듯 보이지만, Day 11 에 Tool, Day 15 에 RAG, Day 17 에 MCP 가 들어오면. 예외 클래스 한 패키지에 30 개씩 쌓여요. "이 예외가 어느 도메인의 책임인가" 가 흐려지고, 수정 시 영향 범위 도 같이 흐려져요. 지금부터 도메인별로 쪼개두는 결이 — 나중에 감각이 살아남는 비결입니다.

3. ErrorCode.VOICE_AUDIO_REQUIRED — 음성 도메인의 첫 코드

VoiceException 이 들고 다니는 ErrorCode 의 도 한 줄 정리할게요. Day 7 (Image, I001~I005) · Day 8 (Vision, V001~V005) 에 이어 Day 9 (Voice) 가 세 번째로 자기 prefix 를 받는 모습이에요.

// kr.spartaclub.aifriends.common.exception.ErrorCode (Day 9 Step 3 시점 발췌)

// Voice / STT (Day 9)
VOICE_AUDIO_REQUIRED(HttpStatus.BAD_REQUEST, "VC001", "음성 파일을 첨부해 주세요."),

코드베이스의 ErrorCode 에는 이미 VC001~VC007 이 한꺼번에 들어가 있어요. 본 Step 은 서비스 부분의 입력 가드 (null 체크) 만 다루기 때문에, 오늘 컨트롤러 단계에서 다시 다룰 VC002~VC004 (Step 4) · TTS 쪽 VC005~VC007 (Step 5~6) 는 각 Step 의 코드 블록에서 점진적으로 펼칠게요. 한 번에 7 개를 다 보여드리지 않는 이유는 — 각 코드가 「어느에서 던져지는지」 를 Step 단위로 또렷하게 박기 위함 자리이에요.

VC001Voice 도메인의 첫 코드. prefix 가 V 가 아니라 VC 인이 묘하죠? Day 8 의 Vision 이 이미 V prefix 를 점유했기 때문에 — 충돌을 피하기 위한 선택 모습이에요. 음성(Voice/Audio) 의을 살리되, Vision 과 한 글자 어긋나게 —

VC (Voice Chat / Voice Code 의 약자 흐름) 로 정리해뒀어요.

HttpStatus.BAD_REQUEST (400)클라이언트의 잘못 임을 명시. 음성 파일을 안 보내놓고 STT 를 요청한 그림이라 — 서버가 처리할 수 없는 요청 이라는 진행을 status 코드로 신호.

메시지 — 사용자에게 그대로 보여줄 한 줄"음성 파일을 첨부해 주세요." 학생이 아니라 최종 사용자 가 읽는 곳이에요. 짧고 명료 하게.

VC004 (VOICE_TRANSCRIPTION_FAILED)Step 4 의 컨트롤러 지점 또는 운영 흐름 에서 다시 등장할 코드예요.

STT 호출 자체가 실패할 때 (네트워크 단절 · 프로바이더 5xx) 의.

오늘 Step 3 은 입력 가드 한 자리만 박고, 멀티파트 검증 (VC002·VC003) 과 호출 실패 가드 (VC004) 는 Step 4 에서 한 번 더 펼칠게요.

4. Resource vs MultipartFile — 서비스가 받는 자루의 정체

서비스의 시그니처가 Resource 인지가 — Day 8 에서 MultipartFile 을 받았던 컨트롤러와 다른 모습 예요. 한 번 손에 짚어두면 Step 4 에서 변환 부분 가 깔끔하게 보입니다.

🙋 한 학생의 날카로운 질문

"튜터님, Resource 가 뭐예요? 지난 시간 Day 8 에서 컨트롤러에 MultipartFile 받았던 거랑 같은 건가요?"

정확히 핵심을 찔러주셨어요. 두 개는 — 완전히 다른 추상화 층 자리이에요. 짧게 풀어드릴게요.

Resource 는 — Spring core 의 인터페이스 예요 (org.springframework.core.io.Resource). 바이트 하나를 어디서 어떻게 읽을지에 무관하게 추상화한 부분. 파일이면 FileSystemResource, 클래스패스 안의 샘플이면 ClassPathResource, 메모리 바이트면 ByteArrayResource, URL 이면 UrlResource어느 출처든 같은 인터페이스 로 다뤄요. Spring AI 의 AudioTranscriptionPrompt 가 받는 자루의 모양 이 바로 이 Resource 예요.

MultipartFile 은 — Spring Web 의 멀티파트 업로드 추상화 예요 (org.springframework.web.multipart.MultipartFile). HTTP 요청의 multipart/form-data 한 칸에 들어온 파일 을 다루는. 컨트롤러 진입 부분 에 한정돼 있어요.

두 개를 한 줄로 정리하면 — MultipartFile 은 HTTP 입구의 자루, Resource 는 서비스/비즈니스 로직 안의 자루. 그래서 — 컨트롤러에서 MultipartFile 을 받아 → 서비스로 넘기기 전에 Resource 로 변환 하는 단계이 반드시 한 부분 등장해요. 그 변환 부분 가 — Step 4 의 VoiceTranscriptionController 에서 만나게 될 이에요. 오늘 서비스 부분이미 변환된 Resource 만 받는 방식으로 깔끔하게 정리해두고, 변환의 책임은 컨트롤러에 두는 분리예요.

💡 서비스가 자매 추상화대로 도는지 (Resource 가 통째로 모델에 전달되는지 · 응답 텍스트가 그대로 흘러나오는지 · null 응답에서 빈 문자열로 폴백하는지 · 입력 가드가 VoiceException 을 던지는지) 는 코드베이스의 VoiceTranscriptionServiceTest 가 4 시나리오로 검증해뒀어요.

5. 다음 부분로

자, 서비스 빈 은 익히셨어요. Resource 하나를 받아 — 자매 추상화의 한 줄을 통과시키고텍스트 한 줄을 반환 하는 그림. 이게 5 단 파이프라인의 가장 핵심 노드 예요.

다음 부분은 — 사용자가 마이크로 녹음한 webm Blob 이 → MultipartFile 로 백엔드에 도착하고 → Resource 로 변환되어 → 이 서비스에 흘러들어가는. 컨트롤러 곳이에요. 오늘 정리한 서비스가 — HTTP 입구와 어떻게 만나는가 이 Step 4 의 감각입니다.


Step 4. `VoiceTranscriptionController` — 마이크 음성을 받아내는 첫 엔드포인트 (약 25분)

자, Step 3 에서 서비스 빈 이 들어왔으니 — 이제 그 빈 앞에 HTTP 입구를 다는 모습이에요. 지난 시간 (Day 8) 우리는 VisionUploadController에서 이미지 MultipartFile 을 받아 정적 URL 로 박는 그림을 손에 정리했죠. 오늘은. 오디오 MultipartFile 을 받아 STT 모델로 흘려보내는으로 한 모달리티만 옆으로 슬쩍 옮긴 자리이에요. 지난 시간과 같은 호흡세 번째 등장 예요.

본 Step 의 미션을 한 줄로 정리해두면 — "브라우저 MediaRecorder 가 흘려보낸 오디오 멀티파트 업로드 하나를 받아, 검증 → Resource 변환 → 서비스 위임 → ApiResponse 래핑 의 4 단 파이프라인을 한 메서드 안에 응축한다." 지난 시간 익숙해진이 —

오디오 도메인에서 한 박자 더 또렷해지는 곳이에요.

1. VoiceTranscriptionController — 전체 코드 하나

먼저 완성된 방식 을 한 번에 들여다볼게요. Day 8 의 VisionUploadController호흡이 같아요 — 검증 4 단 + Resource 변환 + 서비스 위임 + ApiResponse 래핑.

package kr.spartaclub.aifriends.voice.controller;

import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.common.response.ApiResponse;
import kr.spartaclub.aifriends.voice.dto.VoiceTranscriptionResponse;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import kr.spartaclub.aifriends.voice.service.VoiceTranscriptionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.Locale;
import java.util.Set;

/**
 * Day 9 Step 4 — 음성 파일 업로드 → STT 변환 엔드포인트.
 *
 * <p>{@code multipart/form-data} 의 {@code audio} 파트로 받은 음성 파일을 검증하고
 * {@link VoiceTranscriptionService} 에 흘려보낸다. {@link MultipartFile#getResource()} 가
 * Spring 내장 {@code MultipartFileResource} 를 돌려주므로, Spring AI 의
 * {@code AudioTranscriptionPrompt(Resource)} 와 자연스럽게 연결된다.</p>
 *
 * <p>응답은 {@link ApiResponse} 로 래핑한다 (본 강의의 ApiResponse 표준 패턴). 검증 실패는 {@link VoiceException}
 * 으로만 던지고 {@code GlobalExceptionHandler} 가 표준 에러 응답으로 변환한다.</p>
 *
 * <p>허용 확장자는 OpenAI Audio Transcription API 의 공식 지원 목록을 그대로 따른다
 * (mp3, mp4, mpeg, mpga, m4a, wav, webm — gpt-4o-transcribe · gpt-4o-mini-transcribe · whisper-1
 * 모두 동일 엔드포인트라 같은 자루를 받는다). 학습 단순도를 위해 파일명 확장자만 보고 거른다.
 * 최대 크기는 10MB — OpenAI 의 25MB 한도보다 보수적으로 잡아 학생 실습 비용을 억제한다.</p>
 */
@Slf4j
@RestController
@RequestMapping("/api/voice")
public class VoiceTranscriptionController {

    /** OpenAI Audio Transcription API 가 공식적으로 받아들이는 오디오 확장자. */
    private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
            "mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"
    );

    /** 학습용 최대 업로드 크기 — 10MB. OpenAI 한도(25MB) 보다 보수적. */
    private static final long MAX_BYTES = 10L * 1024 * 1024;

    private final VoiceTranscriptionService voiceTranscriptionService;

    public VoiceTranscriptionController(VoiceTranscriptionService voiceTranscriptionService) {
        this.voiceTranscriptionService = voiceTranscriptionService;
    }

    @PostMapping("/transcribe")
    public ResponseEntity<ApiResponse<VoiceTranscriptionResponse>> transcribe(
            @RequestParam("audio") MultipartFile audio) {

        validate(audio);

        Resource resource = audio.getResource();
        String text = voiceTranscriptionService.transcribe(resource);

        log.info("[VoiceTranscription] success: filename={}, size={}, textLength={}",
                audio.getOriginalFilename(), audio.getSize(), text.length());

        return ResponseEntity.ok(ApiResponse.success(new VoiceTranscriptionResponse(text)));
    }

    /**
     * 업로드 검증.
     *
     * <ol>
     *   <li>비어 있으면 {@link ErrorCode#VOICE_AUDIO_REQUIRED}</li>
     *   <li>확장자 화이트리스트에 없으면 {@link ErrorCode#VOICE_AUDIO_FORMAT_INVALID}</li>
     *   <li>10MB 초과면 {@link ErrorCode#VOICE_AUDIO_TOO_LARGE}</li>
     * </ol>
     */
    private void validate(MultipartFile audio) {
        if (audio == null || audio.isEmpty()) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
        }

        String extension = extractExtension(audio.getOriginalFilename());
        if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
        }

        if (audio.getSize() > MAX_BYTES) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
        }
    }

    /**
     * 파일명에서 확장자만 떼어내 소문자로 돌려준다. 점이 없거나 마지막 점 뒤가 비어 있으면 null.
     */
    private String extractExtension(String filename) {
        if (filename == null) return null;
        int dot = filename.lastIndexOf('.');
        if (dot < 0 || dot == filename.length() - 1) return null;
        return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
    }
}

자, 이 코드가 왜 이렇게 들어갔는지 한 부분씩 짚어볼게요.

2. 두 상수 — 학습용 화이트리스트 의 흐름

가장 위에 들어간 두 상수가 본 컨트롤러의 정책 결정 부분 예요.

private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
        "mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"
);

private static final long MAX_BYTES = 10L * 1024 * 1024;

(1) ALLOWED_EXTENSIONS — OpenAI STT 공식 7 자루 그대로

Step 2 의 포맷 한 줄 에서 정리해둔을 기억하시죠? — mp3, mp4, mpeg, mpga, m4a, wav, webm 7 가지가 OpenAI Audio Transcription API 가 받아주는 표준 자루였어요 (gpt-4o-transcribe · whisper-1 모두 동일).

공식 목록그대로 코드 상수로 정리했어요. 우리가 임의로 줄이지도, 늘리지도 않았다는 곳이에요. 프로바이더의 한계가 — 우리 도메인의 한계로 그대로 옮겨오는.

Set.of(...) 한 줄로 불변 Set 을 만들었어요. 런타임에 누군가 add 하지 못하게 정리해둔이고요. 상수의 본질이 「변하지 않는 값」 이라는 진행을 자료구조 자체로 신호하는 부분이에요.

(2) MAX_BYTES = 10L * 1024 * 1024 — 학습 비용 억제 의 보수적 결

OpenAI Audio Transcription API 의 공식 한도는 25MB. 그런데 우리는 10MB 로 잘라뒀어요. 공식 한도보다 60% 더 보수적인 모습이에요. 왜 이렇게 좁게 잘랐는가 — 이게 본 Step 의 결정적 정책 한 줄 자리이에요.

1 분짜리 mp3 음성 ≈ 약 1MB, 10 분짜리 ≈ 약 10MB. 즉 우리 한도는 대략 10 분 분량 의 음성을 받아낼 수 있어요. 학생 실습용으로는 충분히 넉넉한 모습이에요. 그런데 — 25MB 까지 열어두면 학생 한 명이 실수로 30 분짜리 회의 녹음 을 던지는 진행이 한 번이라도 생기면. 수업 한 번에 수천 원이 빠지는 사고가 박힐 수 있어요. 학습 비용을 가두는 게 — 학생을 보호하는 진행 곳이에요.

🙋 한 학생의 날카로운 질문

"튜터님, 왜 25MB 가 아니라 10MB 로 잘랐어요? OpenAI 공식 한도까지 열어두면 더 자연스러울 것 같은데요."

정확히 핵심을 찔러주셨어요. 답은 두에 들어가 있어요.

첫째 — 학습 비용 억제 의 관점. 위에서 짚었듯, 10MB 면 약 10 분 분량 이에요. 학생 실습에서 한 번에 던질 음성은 길어야 수십 초 라 — 10MB 도 사실 굉장히 넉넉한 한도예요. 공식 한도까지 열어두는 건 — 학습용으론 과도한 신뢰 예요. 실수로 큰 파일을 던졌을 때 — 검증 한 줄이 미리 막아주는 방식비용을 가두는 자리고요.

둘째 — 운영 환경에선 더 보수적이어야 한다는 점. 실무 코드라면 — 10MB 도 큰 편 이에요. 일반적으로 Voice Memo 하나는 1~3MB 가 평균. 운영 환경에선 3~5MB 로 더 좁히는 방식이 흔해요. 프로바이더의 공식 한도는 — 실무의 한도가 아니에요. 공식 한도는 프로바이더가 받아줄 수 있는 최대치, 우리 한도는 우리 도메인이 받아낼 적정치두 자리는 다른 결정 이에요. 본 강의는 학습용 + 운영 감각 의 방식으로 — OpenAI 공식 한도의 절반 이하인 10MB 를 정리해뒀어요.

한 줄로 정리하면 — 프로바이더가 받아줄 수 있다고 우리가 받아야 하는 건 아니다. 우리 도메인의 한도는 우리가 다시 결정 해요.

3. transcribe 메서드 — 4 단 파이프라인의 한 응축

이 컨트롤러의 핵심 하나 가 바로 이 메서드예요. 4 단 파이프라인5 줄 안에 응축돼 있어요.

@PostMapping("/transcribe")
public ResponseEntity<ApiResponse<VoiceTranscriptionResponse>> transcribe(
        @RequestParam("audio") MultipartFile audio) {

    validate(audio);

    Resource resource = audio.getResource();
    String text = voiceTranscriptionService.transcribe(resource);

    log.info("[VoiceTranscription] success: filename={}, size={}, textLength={}",
            audio.getOriginalFilename(), audio.getSize(), text.length());

    return ResponseEntity.ok(ApiResponse.success(new VoiceTranscriptionResponse(text)));
}

(1) @PostMapping("/transcribe") + @RequestParam("audio") MultipartFile audio

URL 은 — /api/voice/transcribe (클래스 위 @RequestMapping("/api/voice") 와 합쳐져요). HTTP 메서드는 POST (멀티파트 업로드는 항상 POST 흐름). 파라미터 이름이 audio 라는 진행이 중요해요. Step 2 의 마이크 녹음 스니펫 끝부분의 form.append('audio', blob, 'recording.webm'); 한 줄과 정확히 일치 하는 키예요. 프론트와 백엔드의 멀티파트 키가 같은 단어 로 묶여야 — MultipartFile 이 자루를 받아낼 수 있거든요.

(2) validate(audio) — 검증 한 줄

검증 로직 자체는 다음 절에서 펼치고요. 컨트롤러 본문에서 봐야 할 흐름 은 — 검증이 가장 첫 줄에 들어가 있다 는 자리이에요. 어떤 비즈니스 로직보다 먼저 검증을 흘려서, 부적합 자루는 서비스 빈에 들어가지도 않게 막아두는.

(3) audio.getResource() — HTTP 자루 → 비즈니스 자루 변환의 한 줄

Resource resource = audio.getResource();

이 한 줄이 Step 3 의 학생 질문 의 답이에요.

MultipartFile 은 HTTP 입구의 자루, Resource 는 서비스 안의 자루두 개를 잇는 변환 이 바로 이 한 줄이에요.

Spring 이 내부적으로 MultipartFileResource 라는 내장 어댑터 를 만들어 돌려줘요.

우리가 손으로 변환 코드를 짤 필요가 없는 결Spring 의 감각 이에요.

이 한 줄이 또 결정적인 부분은 — Spring AI 의 AudioTranscriptionPrompt(Resource) 와 자연스럽게 잡히는 점이에요. 컨트롤러 → 서비스 → Spring AI 의 세 층을 같은 Resource 인터페이스 가 흘러 — 변환 손실 없이 자루가 흐르는 그림이에요.

(4) voiceTranscriptionService.transcribe(resource) — 서비스 위임 한 줄

Step 3 에서 정리해둔 빈에 그대로 위임 해요. 컨트롤러는 비즈니스 로직을 직접 들고 있지 않아요. HTTP 입구의 검증 + 응답 래핑만 책임지고, 진짜 일 은 서비스가 한다는. 책임 분리 (Layered Architecture) 의 감각이에요.

(5) log.info(...) — 관찰성 한 줄

log.info("[VoiceTranscription] success: filename={}, size={}, textLength={}",
        audio.getOriginalFilename(), audio.getSize(), text.length());

성공에 세 가지 자루 를 한 줄 찍어요 — 원본 파일명, 업로드 크기, 인식된 텍스트 길이. 비용 추적용 자료 예요.

운영 환경에서 — 어느 사용자가 얼마나 큰 음성을 던졌고, 얼마나 긴 텍스트가 떨어졌는가 를 시계열로 쌓아두면 비용 패턴 분석 에 그대로 쓸 수 있어요.

내용 (transcription text) 자체는 찍지 않는 도 — PII (개인정보) 보호 흐름의 감각이에요.

(6) ApiResponse.success(new VoiceTranscriptionResponse(text)) — ApiResponse 표준 패턴의 세 번째 등장

return ResponseEntity.ok(ApiResponse.success(new VoiceTranscriptionResponse(text)));

여기가 본 강의의 표준 응답 패턴이 또 한 번 회수 되는 모습이에요.

Day 4-1 에서 들어간 "새로 만드는 컨트롤러의 정상 응답은 반드시 ApiResponse<T> 로 래핑한다" 는 규약 — Day 7 (이미지) → Day 8 (Vision) → Day 9 (Voice) 까지 세 번째로 다시 만나는 부분예요.

raw 객체나 record 직접 반환 금지 의.

왜 이 규약이 결정적 인지 한 줄 더 정리해두면 — GlobalExceptionHandler에러 응답을 자동으로 ApiResponse.fail(ErrorResponse) 로 감싸기 때문에, 정상 응답을 raw 로 두면 같은 엔드포인트의 정상/에러 응답 형태가 비대칭 이 돼요.

학생들이 응답 표준을 학습할 때 일관된 패턴이 보여야 하거든요.

4. VoiceTranscriptionResponse한 필드 record 의 흐름

응답 DTO 는 놀랍도록 짧아요.

package kr.spartaclub.aifriends.voice.dto;

/**
 * Day 9 Step 4 — STT 응답 DTO.
 *
 * <p>{@code POST /api/voice/transcribe} 의 정상 응답 본문에 들어가는 record. 학생이 처음 마주치는
 * "음성 → 텍스트" 변환 결과이므로 일부러 필드를 한 개({@code text})만 둔다. 추후 confidence/segments
 * 같은 부가 정보가 필요해지면 필드를 추가해 확장한다.</p>
 *
 * @param text STT 모델이 인식한 텍스트. 비어 있는 음성이거나 모델이 아무것도 인식하지 못하면 빈 문자열.
 */
public record VoiceTranscriptionResponse(
        String text
) {
}

record 한 줄에 필드 하나만.짧음 이 의도예요. 학생이 처음 마주치는 「음성 → 텍스트」 변환 결과 라 — 일부러 필드를 한 개로 두고, 추후 confidence (신뢰도) · segments (구간 분리) 같은 부가 정보 가 필요해지면 그때 필드를 추가 하는 방식으로 정리해뒀어요. 기본은 단순하게, 확장은 필요할 때

의 감각이에요.

학생 입장에선 — $.data.text 하나로 모든 게 풀린다는 진행이 응답 구조의 결정적 이정표 예요. 프론트의 await res.json().data.text 한 줄이면 — STT 결과가 들어와요.

5. validate(MultipartFile) — 4 단 검증

transcribe 메서드의 첫 줄 validate(audio) 가 호출하는 진짜 일 자리이에요. 4 가지 시나리오를 순차로 흘려 검증해요.

private void validate(MultipartFile audio) {
    if (audio == null || audio.isEmpty()) {
        throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
    }

    String extension = extractExtension(audio.getOriginalFilename());
    if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
        throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
    }

    if (audio.getSize() > MAX_BYTES) {
        throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
    }
}

(1) null / empty — VOICE_AUDIO_REQUIRED (VC001)

가장 먼저 — 자루가 없거나 비어 있는지 검증.

audio == null 은 멀티파트 파라미터 자체가 없는 부분, audio.isEmpty()자루는 왔는데 안에 0 byte 인.

두 개 모두 같은 ErrorCode 로 묶었어요 — 사용자 입장에선 어느 쪽이든 "음성 파일을 첨부해 주세요" 라는 같은 메시지를 받는 게 자연스러우니까요.

이 코드는 — Step 3 에서 정리한 VC001컨트롤러 지점에서의 회수 예요.

(2) 확장자 화이트리스트 — VOICE_AUDIO_FORMAT_INVALID (VC002)

String extension = extractExtension(audio.getOriginalFilename());
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
    throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
}

파일명의 확장자만 떼어 ALLOWED_EXTENSIONS Set 과 매칭. .txt, .pdf, .png 같은 자루는 여기서 컷. extractExtension클래스 맨 아래 에 들어간 작은 헬퍼예요 —

마지막 점 뒤의 글자를 소문자로 떼어내요. 대소문자 구분 없이 (.MP3 도 OK) 매칭되도록 Locale.ROOT 로 lowercase 정리해둔이고요.

솔직한 방식 한 줄 — 파일명 확장자만 보고 거르는완벽한 검증은 아니에요. 누군가 evil.exe 의 이름을 evil.mp3 로 바꿔 던지면 — 이 검증은 통과해요. 진짜 컨텐츠 타입 검증 까지 하려면 — 파일 매직 넘버 (magic bytes) 를 봐야 하고, 그건 Apache Tika 같은 라이브러리 가 들어가야 해요. 본 강의는 학습 단순도 를 위해 확장자만 보고 — 프로바이더 (OpenAI STT) 가 알아서 거르는 방식을 신뢰해요. 운영 환경에선 — Tika 하나를 추가 하는 진행이 자연스러운 다음 단계예요.

(3) 크기 — VOICE_AUDIO_TOO_LARGE (VC003)

if (audio.getSize() > MAX_BYTES) {
    throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
}

마지막 — 10MB 초과면 컷. MultipartFile.getSize()byte 단위 long 을 돌려줘요. 이 검증이 위에서 정리해둔 「학습 비용 억제」 의 진짜 실행 부분 예요.

(4) VC004 (VOICE_TRANSCRIPTION_FAILED) 의 부분은 — Step 5 또는 Step 7 에서 회수

ErrorCode 에 정리해둔 4 종 중 — 오늘 컨트롤러는 3 종 (VC001, VC002, VC003) 만 회수하고, VC004 (음성 인식 실패)STT 호출 자체가 실패할 때 의 부분라 — Step 5 의 STT 통합 시연 또는 Step 7 의 운영 흐름 에서 본격적으로 다시 다룰게요.

오늘 컨트롤러는 입력 검증 부분 만 깔끔하게 정리해두에요.

6. 진입점 — 프론트 ↔ 컨트롤러 ↔ 검증 키워드 한 줄 정리

컨트롤러를 닫기 전에 한 줄짜리 정리 만 정리할게요. 멀티파트 키 "audio"세에 똑같이 들어가야이 닫혀요. 프론트 (form.append('audio', ...)) → 컨트롤러 (@RequestParam("audio")) → 통합 테스트의 MockMultipartFile("audio", ...) 의 세. 키 한 글자가 어긋나면 멀티파트 매칭이 깨져 400 도 아닌 지저분한 5xx 가 떨어져요.

💡 컨트롤러가 약속대로 흐르는지 (정상 mp3·wav 업로드 → 200 + $.data.text · 빈 파일 → VC001 · 미지원 확장자 → VC002 · 10MB+1 byte 경계값 → VC003) 는 코드베이스의 VoiceTranscriptionControllerTest 가 5 시나리오로 검증해뒀어요. Day 8 VisionUploadControllerTest 와 같은 호흡 으로 슬라이스 부팅 + GlobalExceptionHandler import 패턴이 들어가 있어 예외 응답이 ApiResponse.fail 로 자동 변환되는 진행 까지 함께 검증돼요.

💡 튜터의 결론 — Step 4 한 줄

MultipartFile.getResource()한 줄 이 — HTTP 멀티파트 자루를 Spring AI 의 AudioTranscriptionPrompt(Resource) 와 자연스럽게 잇는 다리예요. 컨트롤러는 검증과 응답 래핑만, 비즈니스 로직은 서비스에책임 분리 이 지난 시간 (Day 8 VisionUploadController) 와 완전히 같은 호흡 으로 흐르고, ApiResponse 표준 패턴 + 도메인별 ErrorCode + WebMvcTest + GlobalExceptionHandler import네 감각이 세 번째로 다시 만나는 부분. 마이크 음성 → 백엔드의 첫 진입 단계이 들어갔다.

7. 다음으로 — 듣는 빈 의 백엔드 부분이 닫혔다

자, 정리해보면 — 오늘 4 개 Step 을 거쳐 듣는 빈 (TranscriptionModel) 의 백엔드 곳이 완전히 닫혔어요.

  • Step 1 — 라인업 매트릭스 (어느 프로바이더로 갈지)
  • Step 2 — 자매 추상화 시그니처 (TranscriptionModel · TextToSpeechModel 의 모양)
  • Step 3 — VoiceTranscriptionService (Resource → 텍스트의 비즈니스 로직)
  • Step 4 — VoiceTranscriptionController (HTTP 입구의 검증 + Resource 변환 + 응답 래핑)

5 단 파이프라인왼쪽 절반 (마이크 → STT → 텍스트) 이 익히셨어요.

다음은 — 오른쪽 절반 부분이에요. 말하는 빈 (TextToSpeechModel) 의. 텍스트 한 줄을 던져서 → 음성 byte[] 가 떨어지고 → 브라우저의 <audio> 태그가 받아 재생하는. 5 단 파이프라인의 오른쪽이 닫히는 곳이에요.

Step 5 에서 말하는 빈 의 시그니처를 손에 잡고, 텍스트를 음성 byte[] 로 풀어내는 자리를 펼쳐봅시다.


Step 5. `VoiceSynthesisService` — 말하는 빈, 거울처럼 대칭의 모습 (약 25분)

자, 이제 오른쪽 절반 의 첫 자루를 익힐 차례예요.

Step 2 에서 우리는 자매 추상화 두 개 를 한 번에 머리에 정리했죠.

왼쪽이 듣는 빈 (TranscriptionModel), 오른쪽이 말하는 빈 (TextToSpeechModel).

오른쪽 빈 의 1.1.x 정확한 이름이 TextToSpeechModel 이에요.

그리고 — 그 빈을 직접 호출하는 부분 가 바로 오늘 이 Step 이에요.

Step 3 의 VoiceTranscriptionService 와 비교하면 입출력의 방향이 정반대 예요.

그쪽은 오디오 byte[] → 텍스트 였고, 이쪽은 텍스트 → 오디오 byte[].

그런데 코드의 모양은 거의 거울처럼 대칭 자리이에요.

같은 자매 추상화 패턴 이 두 번째로 박히는 지점이기 때문에 — 손이 한 번에 들어옵니다.

1. 말하는 빈 의 정확한 시그니처 — TextToSpeechModel

먼저 우리가 호출할 빈 의 모양부터 한 번 더 짚을게요.

Spring AI 1.1.x 의 음성 합성 추상화는 org.springframework.ai.audio.tts.TextToSpeechModel 이에요.

내부적으론 Model<TextToSpeechPrompt, TextToSpeechResponse> 의을 그대로 따르고, 추가로 StreamingTextToSpeechModel 까지 확장한 인터페이스예요. Day 2 의 Model<P, R> 자매 추상화가 — 세 번째 모달리티 (청각 출력) 에서 다시 등장되는 모습이에요.

호출 진행은 이래요.

단계 타입
입력 TextToSpeechPrompt(String text) — 내부적으로 TextToSpeechMessage(text) 로 감싸짐
호출 textToSpeechModel.call(prompt)
응답 TextToSpeechResponse
결과 1단계 추출 response.getResult()Speech
결과 2단계 추출 speech.getOutput()byte[]

총 5단 변환 자리이에요. 문자열 한 줄이 → 프롬프트로 감싸지고 → 모델 호출로 흘러가고 → 응답이 떨어지고 → Speech 객체에서 byte[] 가 빠져나오는. 한 메서드 안에서 5단 변환이 직선으로 흐르는 단계이에요.

그리고 자동 등록 은 — Spring AI OpenAI 스타터에 들어가 있는 OpenAiAudioSpeechAutoConfiguration 이 처리해줘요.

우리가 따로 빈을 등록할 필요 없이, 클래스패스에 OpenAI 스타터만 있으면 기본 모델 tts-1 · 포맷 mp3 · 보이스 alloy 로 빈이 자동으로 떠 있어요.

Day 2 의 자동 구성 패턴 그대로이에요.

2. VoiceSynthesisService — 거울처럼 대칭의 한 클래스

자, 이제 서비스 본체 를 한 번에 펼쳐 봅시다. 코드를 먼저 흘려보고 아래에서 한 줄씩 풀어볼게요.

package kr.spartaclub.aifriends.voice.service;

import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.audio.tts.Speech;
import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.audio.tts.TextToSpeechResponse;
import org.springframework.stereotype.Service;

/**
 * Day 9 Step 5 — Voice (TTS, Text-to-Speech) 도메인 진입점.
 *
 * <p>텍스트 한 문장을 받아 {@link TextToSpeechModel} 에 전달하고 합성된 오디오 바이트를
 * 그대로 돌려준다. {@link TextToSpeechModel} 은 인터페이스로만 주입받으므로
 * (프로바이더 추상화 원칙), 빈은 OpenAI tts-1 · 추후 다른 TTS 프로바이더 어떤 것이든
 * 될 수 있다 — 호출자는 모른다. 활성 TTS 프로바이더는 {@code application.yml} 의
 * {@code spring.ai.model.audio.speech} 프로퍼티로만 결정된다.</p>
 *
 * <p>응답 byte[] 가 null 이면 빈 byte[] 를 반환한다 (Step 3 {@code VoiceTranscriptionService}
 * 와 동일한 방어 패턴). null/빈 텍스트 등 명백한 입력 오류는 {@link VoiceException} 으로
 * 래핑해 {@code GlobalExceptionHandler} 가 ApiResponse.fail 형태로 변환하게 한다.</p>
 *
 * <p>실제 TTS 호출은 텍스트 LLM 대비 토큰 비용이 크다 (음성/이미지/비디오 비용 급등 경고). 단위 테스트는
 * Mockito 모킹으로만 검증하고, 실제 호출 smoke 는 강사가 수동으로 한다.</p>
 */
@Slf4j
@Service
public class VoiceSynthesisService {

    private final TextToSpeechModel textToSpeechModel;

    public VoiceSynthesisService(TextToSpeechModel textToSpeechModel) {
        this.textToSpeechModel = textToSpeechModel;
    }

    /**
     * 텍스트를 TTS 모델에 보내 합성된 오디오 바이트를 반환한다.
     *
     * @param text 합성할 텍스트 (null/빈 문자열 금지)
     * @return 합성된 오디오 byte[] (응답이 비어 있으면 빈 byte[])
     * @throws VoiceException text 가 null 또는 공백뿐인 경우 (VOICE_TEXT_REQUIRED)
     */
    public byte[] synthesize(String text) {
        if (text == null || text.isBlank()) {
            throw new VoiceException(ErrorCode.VOICE_TEXT_REQUIRED);
        }

        TextToSpeechResponse response = textToSpeechModel.call(new TextToSpeechPrompt(text));

        if (response == null || response.getResult() == null) {
            return new byte[0];
        }
        Speech speech = response.getResult();
        byte[] audio = speech.getOutput();
        return audio == null ? new byte[0] : audio;
    }
}

잠깐 — 교안 본문은 학습 단순도 를 위해 핵심 5 단 변환만 남긴 압축본 입니다. 여러분이 받아 가신 코드베이스의 실제 VoiceSynthesisService 에는 한 겹 더 가 박혀 있어요 —. 생성자에 @Value("${spring.ai.model.audio.speech}") 로 활성 프로바이더 식별자를 같이 주입 + mood key (bright/warm/calm/cheerful) → voice 식별자 매핑 표 두 자루 (OpenAI 용 / ElevenLabs 용) + synthesizeWithMeta(text, voice) 가 합성 결과 + 사용된 프로바이더/voice 메타를 한 record 로 반환. 이게 — 오늘 마무리 섹션에서 .env 한 줄로 OpenAI ↔ ElevenLabs swap 되는 토대 가 됩니다. 여기 본문에서는 코어 한 줄 만 익히고, swap 토대의 전체 풍경 은 마무리에서 한 번에 펼칠게요.

첫째 박자 — 의존성 주입의. 클래스 위에는 @Service 만 들어가 있고, 생성자는 명시 생성자TextToSpeechModel 하나를 받아요. Lombok 의 @RequiredArgsConstructor 가 아니라 명시 생성자 인 이유는. 학생이 한눈에 「이 빈은 TextToSpeechModel 하나에만 의존한다」 는 그림을 보게 하기 위함 곳이에요. Day 2 의 프로바이더 추상화 원칙 이 여기서도 그대로 살아있어요. 구체 타입 OpenAiAudioSpeechModel 로 주입받지 않고 인터페이스로만

그래서 프로바이더는 .env 한 줄로 스위칭 되는 진행이 유지돼요.

둘째 박자 — 가드 한 줄. 메서드 진입 직후 text == null || text.isBlank() 한 줄이 들어가 있어요. null 도, 빈 문자열도, 공백뿐인 문자열 (" ") 도 한꺼번에 걸러내는. 그리고. VoiceException(ErrorCode.VOICE_TEXT_REQUIRED) 한 줄로 던지면 끝이에요.

Step 6 컨트롤러까지 흘려보내지 않고 서비스 진입점에서 컷 부분이에요.

Day 8 의 VisionService.describe 가드Step 3 의 VoiceTranscriptionService.transcribe 가드 와 — 완전히 같은 호흡이에요.

셋째 박자 — 5단 변환 한 줄씩.

TextToSpeechResponse response = textToSpeechModel.call(new TextToSpeechPrompt(text));

이 한 줄이 — 5단 변환의 가운데 3단을 한 번에 묶어요. 텍스트를 TextToSpeechPrompt감싸고, call호출하고, 응답을 받아 변수에 담는. Spring AI 의 자매 추상화가 가장 빛나는 한 줄 이에요. 프로바이더가 OpenAI 든 다른 무엇이든 — 이 한 줄은 안 바뀌어요.

if (response == null || response.getResult() == null) {
    return new byte[0];
}
Speech speech = response.getResult();
byte[] audio = speech.getOutput();
return audio == null ? new byte[0] : audio;

뒤의 두 줄짜리 추출 이 — response.getResult()Speech 를 빼내고, speech.getOutput() 으로 오디오 byte[] 를 꺼내요. 그리고 세 군데에 null 안전 폴백 이 들어가 있어요.

response 자체가 null 이거나, result 가 null 이거나, audio byte[] 가 null 이거나. 어느에서 비어 있어도 빈 byte[] 로 폴백 해요. 예외를 던지지 않고 빈 자루를 반환 하에요. 컨트롤러가 Content-Length: 0 으로 흘려보내면 그만 이고, 상위 레이어가 별도 분기 없이 똑같이 처리 할 수 있어요.

Step 3 의 text == null ? "" : text완전히 같은 방어 패턴 모습이에요.

💡 튜터의 결론 — 거울처럼 대칭의 모양

Step 3 의 VoiceTranscriptionService 와 이 Step 5 의 VoiceSynthesisService나란히 놓고 보면 코드가 거울처럼 대칭 자리이에요.

입력/출력의 방향만 정반대일 뿐, 의존성 주입 · 가드 · 호출 한 줄 · null 폴백 의 네 가지 감각이 완전히 같은 방식으로 들어 있죠. 같은 자매 추상화 패턴이 두 번째로 들어간 곳 — 손이 한 번에 들어와요.

3. 듣는 빈 ↔ 말하는 빈 — 한 표로 정리하는 대칭

자, 얼마나 거울처럼 대칭인지 를 — 한 표로 펼쳐 볼게요.

항목 VoiceTranscriptionService (Step 3) VoiceSynthesisService (Step 5)
인터페이스 TranscriptionModel TextToSpeechModel
입력 타입 Resource (audio bytes) String (text)
호출 한 줄 model.call(new AudioTranscriptionPrompt(audio)) model.call(new TextToSpeechPrompt(text))
응답 결과 String (transcribed text) byte[] (audio bytes)
가드 ErrorCode VC001 VOICE_AUDIO_REQUIRED VC005 VOICE_TEXT_REQUIRED
자동 등록 OpenAiAudioTranscriptionAutoConfiguration OpenAiAudioSpeechAutoConfiguration
null 폴백 text == null ? "" : text audio == null ? new byte[0] : audio

일곱 줄의 모양 이 — 완벽히 좌우 대칭 부분이에요. Spring AI 가 자매 추상화를 얼마나 균형 있게 정리해뒀는지 가 한눈에 보이죠. 입력만 「오디오 ↔ 텍스트」 로 뒤집고, 출력만 「텍스트 ↔ 오디오」 로 뒤집으면 — 나머지는 같은 방식 곳이에요.

4. ErrorCode 추가분 — VC005 · VC006 두 개

서비스가 던지는 VOICE_TEXT_REQUIRED 가 어디 사는 지 보여드릴게요. kr.spartaclub.aifriends.common.exception.ErrorCode 의 Voice 섹션을 보면 — Step 3 의 VC001~VC004 뒤에 VC005 · VC006 두 줄이 새로 들어가 있어요.

// Voice / STT (Day 9)
VOICE_AUDIO_REQUIRED(HttpStatus.BAD_REQUEST, "VC001", "음성 파일을 첨부해 주세요."),
VOICE_AUDIO_FORMAT_INVALID(HttpStatus.BAD_REQUEST, "VC002", "지원하지 않는 음성 파일 형식입니다. (mp3, mp4, mpeg, mpga, m4a, wav, webm)"),
VOICE_AUDIO_TOO_LARGE(HttpStatus.BAD_REQUEST, "VC003", "음성 파일 크기가 허용 한도(10MB)를 초과했습니다."),
VOICE_TRANSCRIPTION_FAILED(HttpStatus.BAD_GATEWAY, "VC004", "음성 인식에 실패했습니다."),
VOICE_TEXT_REQUIRED(HttpStatus.BAD_REQUEST, "VC005", "음성 합성을 위한 텍스트를 입력해 주세요."),
VOICE_SYNTHESIS_FAILED(HttpStatus.BAD_GATEWAY, "VC006", "음성 합성에 실패했습니다.");

두 개이 달라요.

VC005 — VOICE_TEXT_REQUIRED. 상태 400 BAD_REQUEST. 명백한 입력 오류 (텍스트 없음) 모습이에요. 클라이언트의 잘못이니 클라이언트한테 돌려보내는. 방금 본 synthesize 의 첫 가드 에서 던지고 있죠.

VC006 — VOICE_SYNTHESIS_FAILED. 상태 502 BAD_GATEWAY. 외부 TTS 프로바이더가 실패했을 때 모습이에요. 우리 잘못이 아니라 외부 서비스 측 문제 라는. 지금 이 Service 코드에서는 — 안 던져요. 그럼 왜 미리 정리해뒀냐? —

Step 6 컨트롤러에서 다시 다룰 부분 이기 때문이에요.

Step 3 의 VC004 VOICE_TRANSCRIPTION_FAILED 가 컨트롤러까지 흘러간 흐름 그대로 — VC006 도 Step 6 컨트롤러에서 다시 등장 돼요. 서비스가 비어 있는 byte[] 를 반환했을 때 → 컨트롤러가 「합성 실패」 로 분기 하는. 그 부분은 Step 6 에서 만나요.

지금은 「ErrorCode 자루 두 개가 미리 들어가 있다」 는 그림만 머리에 두면 됩니다.

5. TextToSpeechPrompt 의 instructions 자루 — Step 3 와 정확히 같은 패턴

TextToSpeechPrompt 의 instructions 는 내부적으로 TextToSpeechMessage 인데, 거기서 .getText() 로 원본 문자열을 빼내요. Step 3 의 AudioTranscriptionPrompt.getInstructions()Resource 를 빼내는 결과 — 정확히 같은 패턴 이에요. 듣는 빈은 Resource 자루를, 말하는 빈은 String 자루를 같은 시그니처로 감싸요.

💡 서비스가 약속대로 흐르는지 (텍스트가 통째로 TextToSpeechPrompt 에 전달되는지 · 응답 byte[] 가 null 이면 빈 byte[] 폴백 · null 입력은 VOICE_TEXT_REQUIRED · 공백만 있는 문자열도 isBlank() 로 잡혀 같은 코드) 는 코드베이스의 VoiceSynthesisServiceTest 가 4 시나리오로 검증해뒀어요. 학생이 isEmpty()isBlank() 를 헷갈려 한 글자만 잘못 정리하면 그 테스트가 깨져요 — 공백 가드의 감각이 거기서 들어가요.

6. 솔직한 트레이드오프 — 추상화는 OpenAI 스타터, 실제 호출은 무료 대안

자, 솔직한 트레이드오프 를 한 박스로 짚고 넘어갈게요. Step 1 의 「추상화는 OpenAI 스타터 / 실제 호출은 무료 옵션」 결정 이 — 오늘 이 Step 에서 세 번째로 회수 되는 자리이에요. (Step 1 — 라인업 매트릭스, Step 3 — STT, Step 5 — TTS.)

우리가 방금 정리한 코드 는 — TextToSpeechModel 인터페이스로 추상화돼 있고, 자동 구성에 의해 기본 빈은 OpenAI tts-1 으로 떠요.

이대로 실제 호출하면 — 돈이 듭니다. OpenAI tts-1 기준으로 1,000 자당 약 $0.015 정도. 한 학생이 짧은 문장 100 번 만 시연해도 $1.5 ~ $3 정도가 사라지는 자리이에요.

그래서 본 강의의 디폴트 호흡 은 이래요.

테스트는 모킹으로 0 원. 방금 본 4 개 시나리오전부 Mockito 모킹 곳이에요. 실제 OpenAI 호출이 한 번도 일어나지 않아요. 그래서 학생이 ./gradlew test 를 백번 천번 돌려도 — 비용이 0 원 이에요.

시연은 강사 사전 녹화. 실제 합성된 음성 결과물은 강사가 사전에 한 번 호출해서 녹화한 클립 으로 보여드릴게요. 학생이 자기 손으로 OpenAI TTS 를 호출하지 않아도 — 결과물은 완전히 같은 형태 으로 들어와요.

자기 손으로 돌려보고 싶다면 — 무료 대안. 두 개를 추천해요.

edge-tts Python CLI — Microsoft Edge 의 TTS 엔진을 무료로 호출하는 OSS.

pip install edge-ttsedge-tts --voice ko-KR-SunHiNeural --text "안녕하세요" --write-media out.mp3 한 줄로 한국어 자연스러운 음성 이 떨어져요. 2. 브라우저 SpeechSynthesis Web API — 프론트엔드에서 new SpeechSynthesisUtterance("안녕하세요") 한 줄로 호출 비용 0 원 의 음성 합성. 백엔드를 거치지 않고 브라우저가 직접 말함 이라는. Step 7 의 통합 시연 에서 한 번 흘려볼 자루예요.

🚨 잊지 말 한 줄

추상화 (TextToSpeechModel) 는 OpenAI 스타터를 그대로 쓰되, 실제 호출만 무료 대안으로 우회한다 — 이 방식이 본 강의의 비용 가이드 핵심이에요. 코드의 모양은 그대로 두고, 비용만 가두는. 이미지 · 음성 · 비디오 비용 급등 경고 가 한 박스로 다시 만나는 부분예요.

7. 🙋 학생 질문 박스

"튜터님, 솔직히 한 가지 의문이 들어요. 왜 byte[] 를 그대로 반환 해요? Resource 로 감싸지 않고요? 지난 시간 (Day 8) MultipartFile.getResource() 로 감싼을 봤더니 — 같은 패턴으로 가야 하지 않나 싶어서요. "

아주 좋은 의문 이에요! 이 질문은 Step 3 → Step 5 의 대칭이 살짝 흐트러져 보이는 부분 에서 정확히 짚어낸 거예요. 진짜로 「Resource 로 감싸야 하는 이유」 가 있는지 를 한 번 따져봅시다.

결론부터 — 안 감싸요. 이유는 두 가지예요.

첫째, 컨트롤러가 그대로 응답 body 에 흘려보내면 끝이기 때문이에요. Step 6 에서 만들 컨트롤러는. ResponseEntity<byte[]> 를 반환하면서 Content-Type: audio/mpeg 헤더만 정리하면 그만 이에요. Spring MVC 의 ByteArrayHttpMessageConverter 가 byte[] 를 그대로 응답 스트림에 흘려보내는 방식이에요.

Resource 로 감싸도 결국 ByteArrayResource → byte[] 로 다시 풀리는 한 단 거꾸로 가는 일이 돼요.

둘째, Resource 추상화가 빛나는 부분은 「입력」 쪽이에요. Day 8 VisionUploadController 와 Step 4 VoiceTranscriptionController멀티파트 → Resource 변환 으로 감싼 건. Spring AI 의 입력 API 가 Resource 를 요구 하기 때문이었어요. (AudioTranscriptionPrompt(Resource)) 지금은 출력이고, 출력의 소비자 (HTTP 응답) 는 byte[] 를 그대로 좋아해요. 그래서 추상화를 한 겹 더 입히면 — 오버엔지니어링 지점이에요.

요약하자면 — Resource 는 「외부에서 들어오는 입력 자루」 의 추상화, byte[] 는 「외부로 나가는 출력 자루」 의 가장 단순한. 두 개를 목적에 맞게 다르게 쓰는 게 옳아요.

8. 다음 주제로 — 말하는 빈 도 들어왔다

자, 정리하면 — 오늘 5 번째 Step 에서 말하는 빈 (TextToSpeechModel) 도 백엔드 비즈니스 로직 단계이 완전히 익히셨어요.

  • 듣는 빈 (Step 3 VoiceTranscriptionService) ↔ 말하는 빈 (Step 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 표준 패턴의 **예외 절** (약 25분)

자, 이제 말하는 빈 의 byte[] 결과물이 — HTTP 응답으로 흘러나가는 부분 가 펼쳐질 차례예요.

Step 5 에서 손에 정리한 방식을 한 번 다시 떠올려 봅시다.

voiceSynthesisService.synthesize("안녕하세요") 한 줄이 떨어지면 — 모델이 합성한 mp3 byte[] 가 들어왔죠.

그 byte[] 가 — 오늘 이 Step 에서 JSON 래핑 없이, 추가 변환 없이, 모델이 만들어준 그대로 — 사용자에게 도착하는 그림이에요.

날 것 그대로 흘러나가는 자루의.

그리고 — 본 Step 에는 Day 1~8 동안 한 번도 안 다뤘던 흐름 이 들어가 있어요. 바이너리 응답의 첫 부분 예요. Day 1~8 의 모든 컨트롤러는 JSON 응답 을 돌려줬고, 그래서 ApiResponse 표준 패턴 으로 한 방식으로 묶여 있었죠. 그런데 오늘 이 컨트롤러는. JSON 이 아닌 binary (audio/mpeg) 를 돌려줘요. 표준 패턴의 명시된 예외 절처음으로 발동 되는 곳이에요.

이게 오늘 Step 의 진짜 학습 포인트 예요. 컨트롤러 코드 자체 보다도 — "왜 어떤 컨트롤러는 ApiResponse 로 감싸고, 어떤 컨트롤러는 안 감싸지?" 라는 분류 기준 이 익혀히는.

1. VoiceSpeechController — 한 클래스를 한 번에 펼치기

자, 컨트롤러 본체 를 먼저 한 번에 펼쳐볼게요. 코드를 흘려보고 아래에서 한 줄씩 풀어요.

package kr.spartaclub.aifriends.voice.controller;

import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.dto.VoiceSpeechRequest;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import kr.spartaclub.aifriends.voice.service.VoiceSynthesisService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * Day 9 Step 6 — 텍스트 → TTS → audio/mpeg 바이너리 응답 엔드포인트.
 *
 * <p>JSON 본문 {@code {"text": "..."}} 를 받아 {@link VoiceSynthesisService} 로 합성하고
 * 합성된 mp3 바이트를 그대로 응답 바디로 흘려보낸다. {@code Content-Type} 은
 * {@code audio/mpeg} 이고 응답은 byte[] — 브라우저의 {@code <audio>} 태그가 그대로 재생할 수 있다.</p>
 *
 * <p><b>ApiResponse 표준 패턴의 예외 결정</b>: 본 컨트롤러의 정상 응답은 {@code ApiResponse<T>}
 * 로 래핑하지 않는다. 응답이 바이너리(audio/mpeg)라 JSON 래핑이 부적합하기 때문이다 — 표준 패턴의
 * "디버그 전용 평문 출력" 예외 절의 바이너리 버전이다. 다만 <b>에러 응답은
 * {@code GlobalExceptionHandler} 가 자동으로 ApiResponse JSON 으로 래핑</b>하므로,
 * 같은 엔드포인트의 정상/에러 응답 형태가 비대칭(정상=binary, 에러=JSON)이다 — 이는 의도된 흐름.</p>
 */
@Slf4j
@RestController
@RequestMapping("/api/voice")
public class VoiceSpeechController {

    /** OpenAI TTS API 가 한 번의 호출로 받아들이는 텍스트 길이 권장 한도. */
    private static final int MAX_TEXT_LENGTH = 4000;

    private final VoiceSynthesisService voiceSynthesisService;

    public VoiceSpeechController(VoiceSynthesisService voiceSynthesisService) {
        this.voiceSynthesisService = voiceSynthesisService;
    }

    @PostMapping(value = "/speak", produces = "audio/mpeg")
    public ResponseEntity<byte[]> speak(@RequestBody VoiceSpeechRequest request) {
        validate(request);

        byte[] audio = voiceSynthesisService.synthesize(request.text());

        log.info("[VoiceSpeech] success: textLength={}, audioBytes={}",
                request.text().length(), audio.length);

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("audio/mpeg"))
                .header("Content-Disposition", "inline; filename=\"speech.mp3\"")
                .contentLength(audio.length)
                .body(audio);
    }

    /**
     * 요청 검증.
     *
     * <ol>
     *   <li>request 또는 text 가 null/공백이면 {@link ErrorCode#VOICE_TEXT_REQUIRED}</li>
     *   <li>4000자 초과면 {@link ErrorCode#VOICE_TEXT_TOO_LONG}</li>
     * </ol>
     */
    private void validate(VoiceSpeechRequest request) {
        if (request == null || request.text() == null || request.text().isBlank()) {
            throw new VoiceException(ErrorCode.VOICE_TEXT_REQUIRED);
        }
        if (request.text().length() > MAX_TEXT_LENGTH) {
            throw new VoiceException(ErrorCode.VOICE_TEXT_TOO_LONG);
        }
    }
}

자, 이 한 클래스가 왜 이런 모양으로 들어갔는지 를 — 클래스 javadoc 부터 한 줄씩 풀어볼게요.

2. 클래스 javadoc 의 ApiResponse 표준 패턴 예외 결정 — 본 Step 의 진짜 핵심

/**
 * <p><b>ApiResponse 표준 패턴의 예외 결정</b>: 본 컨트롤러의 정상 응답은 {@code ApiResponse<T>}
 * 로 래핑하지 않는다. 응답이 바이너리(audio/mpeg)라 JSON 래핑이 부적합하기 때문이다 — 표준 패턴의
 * "디버그 전용 평문 출력" 예외 절의 바이너리 버전이다. 다만 <b>에러 응답은
 * {@code GlobalExceptionHandler} 가 자동으로 ApiResponse JSON 으로 래핑</b>하므로,
 * 같은 엔드포인트의 정상/에러 응답 형태가 비대칭(정상=binary, 에러=JSON)이다 — 이는 의도된 흐름.</p>
 */

자, javadoc 한 단락이 — 본 Step 의 학습 포인트를 한 부분에 모아둔 부분 예요. 세 줄로 풀어요.

첫째 — 정상 응답은 ApiResponse 미사용. Day 7 (이미지) · Day 8 (Vision) · 직전 Step 4 (STT) 까지 — 모든 컨트롤러의 정상 응답은 ApiResponse<T> 로 감쌌어요. 그게 ApiResponse 표준 패턴의 디폴트 흐름 이었죠. 그런데 오늘은. 감싸지 않아요. ResponseEntity<byte[]> 그대로 흘려보내요. 그러냐 — 응답이 binary (audio/mpeg) 라 JSON 래핑이 부적합 하기 때문이에요. byte[] 를 ApiResponse 로 감싸려면 Base64 인코딩 → JSON 문자열 필드에 박기 → 클라이언트가 다시 디코드세 단 우회 가 들어가야 해요.

바이너리의 본질을 흐트리는 모습이에요.

둘째 — 표준 패턴의 「디버그 전용 평문 출력」 예외 절의 바이너리 버전. Day 4 의 ApiResponse 표준 패턴 을 처음 박을 때 — 예외 한 줄 이 같이 들어갔어요. "학습용 디버그 엔드포인트 (예: /api/structured/quote/format-debug) 는 text/plain raw 로 둔다" 는 절.

그게. 정상 응답을 ApiResponse 로 안 감싸는 첫 부분 였어요. 오늘 이 binary 응답이 — 그 예외 절의 바이너리 버전 자리이에요. 플레인 텍스트 (text/plain) 와 바이너리 (audio/mpeg) 두 개가 — 같은 「ApiResponse 미사용」 예외 절에 묶이는 구조예요.

셋째 — 에러 응답은 여전히 ApiResponse JSON. 결정적인 한 줄 이 여기 들어가 있어요. 정상 응답은 binary 인데, 에러 응답은 JSON 곳이에요. 그러냐. GlobalExceptionHandler모든 예외를 가로채서 ApiResponse.fail(ErrorResponse) 로 변환 하기 때문이에요.

우리가 컨트롤러에서 binary 를 흘리든 말든 — 예외가 터지는 순간 GlobalExceptionHandler 가 끼어들어 JSON 응답으로 바꿔치기 해요.

그래서 같은 엔드포인트의 정상/에러 응답 형태가 비대칭 인 거예요.

이게 의도된 결 이에요.

🙋 한 학생의 날카로운 질문

"튜터님, 그럼 ApiResponse 로 감싸야 하는지 말아야 하는지 어떻게 결정하나요? 컨트롤러 만들 때마다 헷갈릴 것 같아요. "

정확히 핵심을 찔러주셨어요. 분류 기준 한 줄 로 정리해드릴게요.

응답 종류 ApiResponse 사용 여부 예시
JSON 응답 (디폴트) ✅ 사용 (감싼다) Day 7 ImageGen · Day 8 Vision · Step 4 VoiceTranscription · 일반 CRUD
바이너리 응답 ❌ 미사용 (예외 절) 오늘의 audio/mpeg · 추후 image/png 다운로드 · video/mp4 등
디버그 전용 평문 출력 ❌ 미사용 (예외 절) Day 4 /api/structured/quote/format-debug (text/plain)
학습용 raw 출력 ❌ 미사용 (예외 절) 학생에게 raw 형태를 그대로 보여주는 곳

디폴트는 JSON + ApiResponse 예요. 예외 두 모습 — 바이너리 / 디버그 평문 / 학습용 raw — 만 미사용 부분이고요. "이 응답이 JSON 인가? 그럼 ApiResponse 로 감싸" 한 줄로 결정 끝이에요.

그리고 — 예외 절을 쓸 때조차도 에러 응답은 GlobalExceptionHandler 가 자동으로 ApiResponse JSON 으로 감싼다 는 점은 변하지 않아요. 정상은 자루의 본질에 맞게, 에러는 항상 일관된 JSON 으로 — 이 두 방식이 ApiResponse 표준 패턴의 진짜 모양 이에요.

3. MAX_TEXT_LENGTH = 4000 — 학습 비용 억제의, 한 번 더

private static final int MAX_TEXT_LENGTH = 4000;

상수 한 줄이 클래스 맨 위에 들어가 있어요. 4000 자가 한도 라는.

왜 4000 이냐 — OpenAI TTS API 의 권장 한도한 번의 호출당 약 4096 토큰 이고, 그걸 문자 수 기준으로 안전하게 잘라낸 흐름 이 4000 자예요. 이 방식은 — Step 4 의 MAX_BYTES = 10MB (학습 비용 억제)완전히 같은 호흡이에요. 프로바이더가 받아줄 수 있는 한계 ≠ 우리 도메인이 받아낼 적정치

두 모습는 다른 결정 이라는.

4000 자대략 A4 두 페이지 분량 자리이에요. 학생이 하나의 캐릭터 답변을 음성으로 합성 할 때 — 충분히 넉넉하나 예요. 그런데 — 학생이 실수로 「소설 한 챕터」 를 던지는 진행 이 한 번이라도 박히면 —

수업 한 번에 수천 원이 빠지는 사고가 생길 수 있어요. Step 4 의 10MB 결정과 정확히 같은 보호 흐름 자리이에요.

4. @PostMapping(value = "/speak", produces = "audio/mpeg") — Content-Type 미리 박기

@PostMapping(value = "/speak", produces = "audio/mpeg")
public ResponseEntity<byte[]> speak(@RequestBody VoiceSpeechRequest request) {

어노테이션의 두 개 가 — 결정적인 방식 곳이에요.

produces = "audio/mpeg". Content-Type 을 어노테이션에 미리 박는 지점이에요. Spring MVC 가 디스패치 시점에 「이 핸들러가 audio/mpeg 응답을 약속한다」 는 시그널을 받아두는 모습이에요.

클라이언트가 Accept 헤더로 다른 자루 (예: application/json) 를 요청 하면. Spring 이 자동으로 406 Not Acceptable 을 돌려줘요. 컨트롤러 본문이 실행되기도 전에 컷. 그리고 — 나중에 OpenAPI / Swagger UI 가 이 컨트롤러를 문서화할 때produces 값을 그대로 응답 스키마에 정리해넣어요. 문서 자동 생성에도 한 번에 흐르는 자리이에요.

@RequestBody VoiceSpeechRequest request. 입력은 JSON 단계이에요. Step 4 의 VoiceTranscriptionController멀티파트 입력 (@RequestParam("audio") MultipartFile) 이었던 것과 대조적인 방식 곳이에요.

입력은 JSON, 출력은 binary 입출력의 자루 형태가 비대칭 인. 텍스트는 문자열 한 줄 이라 JSON 으로 받는 게 자연스럽고, 음성은 바이트 묶음 이라 binary 로 흘리는 게 자연스러워요. 각 자루의 본질에 맞게 다른 방식으로 들어간 부분이에요.

5. ResponseEntity 빌더 체인 — 4 단 응답 조립

return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType("audio/mpeg"))
        .header("Content-Disposition", "inline; filename=\"speech.mp3\"")
        .contentLength(audio.length)
        .body(audio);

응답 하나를 조립하는 4 단 체인 모습이에요. 한 줄씩 풀어요.

(1) .contentType(MediaType.parseMediaType("audio/mpeg"))

Content-Type 헤더 한 줄. 어노테이션의 produces같은 값을 한 번 더 정리하에요. 어노테이션 부분은 디스패치 시점의 약속, 빌더 부분은 실제 응답에 박히는 헤더두에 같은 값이 들어가 일관성을 만드는 자리이에요.

(2) .header("Content-Disposition", "inline; filename=\"speech.mp3\"")

결정적인 한 줄 곳이에요. 이 한 줄이 — 브라우저의 동작을 결정하는 부분 예요.

  • inline브라우저 내에서 그대로 재생 하라는 신호. 브라우저가 받자마자 <audio> 태그가 자동 재생 그림이 펼쳐져요.
  • 만약 attachment 였다면 — 다운로드 다이얼로그가 떴을 거예요. 사용자가 파일을 저장 하는 방식으로 흘렀겠죠.
  • filename="speech.mp3"브라우저가 다운로드할 때 파일명을 무엇으로 저장할지 의 힌트. inline 이라도 사용자가 우클릭 → 저장 하면 — speech.mp3 이름으로 저장 돼요.

이 한 줄이 — 우리 캐릭터의 음성을 「자동 재생」 으로 흘릴지 「다운로드」 로 흘릴지 를 결정하는 곳이에요. 대화의 자연스러움 을 위해선 자동 재생 (inline) 이 맞는이고요.

(3) .contentLength(audio.length)

Content-Length 헤더 한 줄. byte[] 의 길이를 그대로 정리해요. 왜 명시적으로 박냐 — 클라이언트가 다운로드 진행률을 정확히 표시할 수 있게 하기 위함이에요. 진행률 바가 0%에서 100%까지 부드럽게 흐르는 결이 이 한 줄에 달려 있어요.

없으면 — 브라우저가 「전송이 끝났는가」 를 추측하게 되고, 진행률 바가 「알 수 없음」 으로 떠요. 작은 한 줄이지만. 사용자 경험의 결정적 자리 예요.

(4) .body(audio)

마지막 한 줄byte[] 를 그대로 응답 바디에 정리해요. Spring MVC 의 ByteArrayHttpMessageConverterbyte[] 를 추가 변환 없이 응답 스트림에 흘려보내는 부분이에요. 모델이 만든 mp3 byte 가 — 한 번의 변환도 없이 — 사용자 브라우저에 도착하는.

본문은 4 단 체인 만 — 코드베이스에는 헤더 두 줄이 더 박혀 있어요 💡

Step 5 의 복선과 짝패예요. 받아 가신 코드베이스의 실제 VoiceSpeechController.speak(...) 에는 응답 헤더 두 자루추가로 박혀 있어요.

// 코드베이스의 실제 컨트롤러 (본문 압축본에선 생략) — 마무리 섹션에서 회수
.header("X-TTS-Provider", result.provider()) // 예: "openai" / "elevenlabs"
.header("X-TTS-Voice", result.voice()) // 예: "marin" / UUID

그리고 — 형제 엔드포인트 한 자루가 더 있어요. GET /api/voice/info현재 활성 TTS 프로바이더만 단독 확인 하는 한 줄짜리 디버그 엔드포인트예요. DevTools Network 탭 에서 방금 부른 합성이 어떤 프로바이더 / 어떤 voice 로 떨어졌는지 즉시 보이고, .envTTS_PROVIDER 를 갈아끼고 재기동한 뒤 /api/voice/info 한 번 찍으면swap 이 살아 있는지 단번에 확인돼요. 오늘 본문 컨트롤러의 모양 은 — 이 학습 시연 메타데이터 두 자루덜어낸 압축본 모습이에요. 본격 swap 시연은 마무리에서.

6. validate(VoiceSpeechRequest) — 2 단 검증

private void validate(VoiceSpeechRequest request) {
    if (request == null || request.text() == null || request.text().isBlank()) {
        throw new VoiceException(ErrorCode.VOICE_TEXT_REQUIRED);
    }
    if (request.text().length() > MAX_TEXT_LENGTH) {
        throw new VoiceException(ErrorCode.VOICE_TEXT_TOO_LONG);
    }
}

두 가지 시나리오순차로 흘러요.

**(1) null / null text / blank text — VOICE_TEXT_REQUIRED (VC005) **

세 개가 같은 ErrorCode 로 묶였어요. request == null@RequestBody 가 빈 자루로 들어올 때, request.text() == nullJSON 에 text 필드가 없거나 null 일 때,. request.text().isBlank()공백뿐일 때. 사용자 입장에선 어느 쪽이든 "음성 합성을 위한 텍스트를 입력해 주세요" 라는 같은 메시지가 자연스러우니까요. — Step 5 의 VoiceSynthesisService.synthesize 가드완전히 같은 호흡이에요.

사실 — 서비스에도 같은 가드가 들어가 있어요. 컨트롤러가 한 번, 서비스가 한 번 — 두에 같은 가드 가 들어간 부분예요. 방어 깊이 (defense in depth) 의 감각이에요.

**(2) 4000 자 초과 — VOICE_TEXT_TOO_LONG (VC007) **

길이 컷. 위에서 정리해둔 학습 비용 억제 의 진짜 실행 자리이에요. 4001 자부터 컷 — 이 경계값 (boundary)Step 4 의 10MB 컷같은 방식 모습이에요.

7. VoiceSpeechRequest — 한 필드 record 의 짧음

요청 DTO 도 놀랍도록 짧아요.

package kr.spartaclub.aifriends.voice.dto;

/**
 * Day 9 Step 6 — TTS 요청 DTO.
 *
 * <p>{@code POST /api/voice/speak} 의 JSON 본문을 매핑한다.
 * {@code text} 필드 한 개만 받는 단순한 record. 검증(null/blank, 최대 길이) 은
 * 컨트롤러에서 {@link kr.spartaclub.aifriends.voice.exception.VoiceException}
 * 으로 던져 표준 에러 응답으로 변환된다.</p>
 */
public record VoiceSpeechRequest(String text) {
}

record 한 줄, 필드 하나만. — Day 4 의 Structured Output 에서 정리한 record 의 짧음세 번째로 회수 되는 곳이에요. Quote (Day 4) → VoiceTranscriptionResponse (Step 4) → VoiceSpeechRequest (Step 6). DTO 의 본질이 「변하지 않는 자루」 라는 진행이 한 줄로 압축 되는. getter / equals / hashCode / toString 이 자동으로 박히는 결정적 감각이에요.

입력은 단순하게, 확장은 필요할 때추후 보이스 (alloy / nova / shimmer) 나 속도 (speed) 같은 옵션이 필요해지면필드를 추가하면 그만 자리이에요. 기본은 가장 단순하게 정리해두에요.

8. ErrorCode 추가분 — VC005 (회수) · VC007 (신규) · VC006 (앞으로의 부분)

ErrorCode 의 Voice 섹션을 다시 보면 — Step 5 에서 들어간 VC005 · VC006 뒤로 VC007 한 줄이 새로 들어가 있어요.

// Voice / STT (Day 9)
VOICE_AUDIO_REQUIRED(HttpStatus.BAD_REQUEST, "VC001", "음성 파일을 첨부해 주세요."),
VOICE_AUDIO_FORMAT_INVALID(HttpStatus.BAD_REQUEST, "VC002", "지원하지 않는 음성 파일 형식입니다. (mp3, mp4, mpeg, mpga, m4a, wav, webm)"),
VOICE_AUDIO_TOO_LARGE(HttpStatus.BAD_REQUEST, "VC003", "음성 파일 크기가 허용 한도(10MB)를 초과했습니다."),
VOICE_TRANSCRIPTION_FAILED(HttpStatus.BAD_GATEWAY, "VC004", "음성 인식에 실패했습니다."),
VOICE_TEXT_REQUIRED(HttpStatus.BAD_REQUEST, "VC005", "음성 합성을 위한 텍스트를 입력해 주세요."),
VOICE_SYNTHESIS_FAILED(HttpStatus.BAD_GATEWAY, "VC006", "음성 합성에 실패했습니다."),
VOICE_TEXT_TOO_LONG(HttpStatus.BAD_REQUEST, "VC007", "음성 합성 텍스트가 허용 길이(4000자)를 초과했습니다.");

세 개를 짚어요.

VC005 — VOICE_TEXT_REQUIRED 의 재등장. Step 5 에서 미리 정리한 코드 가 — 오늘 컨트롤러에서 다시 등장 돼요. 400 BAD_REQUEST. 명백한 입력 오류의.

VC007 — VOICE_TEXT_TOO_LONG 신규. 오늘 새로 들어간 자루. 400 BAD_REQUEST. 4000 자 한도 의을 ErrorCode으로 끌어올린 자루예요. 학습 비용 억제 → 검증 한 줄 → ErrorCode세에 같은 정책이 들어간.

VC006 — VOICE_SYNTHESIS_FAILED 는 오늘은 직접 사용 안 함. 502 BAD_GATEWAY. 외부 TTS 프로바이더가 실패할 때 의 자리. 그런데 — 지금 컨트롤러 코드에서는 안 던져요. 그럼 어디서 회수 되냐 — Spring AI 가 OpenAI / Gemini TTS 호출 시 던지는 예외를 VoiceException 으로 래핑하는 부분앞으로 모습이에요.

일반적으로 Spring AI 의 retry 프로퍼티(spring.ai.retry.**) · Fallback 같은 운영 흐름 에서 자연스럽게 박히는 지점이고, 본 강의 범위는 Day 9 의 핵심 학습 (자매 추상화 + 검증 한 줄 + 5 단 파이프라인) 에 집중하기 위해. VC006 은 ErrorCode 자루로만 정리해두고, 실사용은 학습 외 방식으로 두에요.

9. 정상 vs 에러 응답의 의도적인 비대칭 — binary ↔ JSON

오늘 학습의 핵심을 한 줄 더 정리할게요. 정상 응답은 audio/mpeg byte[], 에러 응답은 ApiResponse.fail(...) JSON 같은 엔드포인트의 응답 형태가 비대칭 자리이에요. 이게 컨트롤러 javadoc 의 「의도적인 비대칭」진짜로 살아 있는 부분 예요. 바이너리 본질을 흐트리지 않으면서 (정상 응답을 Base64 우회 안 시키면서), 에러 응답은 GlobalExceptionHandler 의 자동 변환으로 표준 JSON 을 유지 하는 ApiResponse 표준 패턴의 예외 절 그대로의.

💡 컨트롤러가 약속대로 흐르는지 (정상 텍스트 → 200 + Content-Type: audio/mpeg + byte[] 그대로 · 빈 텍스트/null → 400 + VC005 JSON · 4001 자 → 400 + VC007 JSON) 는 코드베이스의 VoiceSpeechControllerTest 가 4 시나리오로 검증해뒀어요. 정상 응답은 content().bytes(...) 로, 에러 응답은 jsonPath("$.error.code") 로 — 테스트의 단언 방식까지 비대칭 으로 들어있어요. 경계값 "가".repeat(4001) 한 줄이 Step 4 의 new byte[10 * 1024 ** 1024 + 1] 와 같은 호흡 으로 들어가, 비교 연산자 한 글자 (>>=) 만 잘못 정리해도 깨지는.

10. Day 6 텍스트 스트리밍 vs Day 9 binary 응답 — 한 표로 펼치기

자, 본 Step 마지막에 결정적 비교 한 부분를 정리할게요. Day 6 의 텍스트 토큰 스트리밍 (SSE)Day 9 의 binary 응답 이 — 둘 다 「표준 JSON 응답이 아닌」 자루이지만,은 정반대 예요.

항목 Day 6 텍스트 스트리밍 Day 9 음성 binary 응답
응답 타입 토큰 단위 (SSE / Flux<String>) 전체 byte[] 한 번에 (ResponseEntity<byte[]>)
사용자 체감 점진적 출력 — 첫 글자가 빨리 나옴 전체 합성 후 재생 — 합성 끝날 때까지 대기
Spring 패턴 Flux<String> / SseEmitter / text/event-stream ResponseEntity<byte[]> / audio/mpeg
장점 빠른 첫 토큰 — UX 가 살아 있음 단순 + 캐시 가능 — CDN 결합 쉬움
단점 클라이언트 복잡 — SSE 파싱 필요 합성 완료까지 대기 — 첫 음 떨어지기까지 시간

같은 「긴 응답」 자루를 — 둘은 정반대 방식으로 푼 부분 예요. Day 6 은 흘려보내며 (점진적), Day 9 는 모아서 한 번에. 어느 쪽이 옳다 그르다는 진행이 아니에요. 콘텐츠의 본질에 맞는 진행을 고른 단계이고, 둘 다 ApiResponse 표준 패턴의 예외 절 에 묶여 있는 그림이에요.

💡 튜터의 결론 — Step 6 한 줄

바이너리 응답 (audio/mpeg) 은 — ApiResponse 표준 패턴의 명시된 예외 절 이에요. 디폴트는 JSON + ApiResponse 이고, 예외 두 부분 (바이너리 / 디버그 평문) 만 미사용. 그리고 — 이 예외 절에 묶여도 에러 응답은 여전히 ApiResponse JSON 으로 자동 변환 돼요. 정상은 자루의 본질에 맞게, 에러는 항상 일관된 JSON 으로같은 엔드포인트의 정상/에러 응답 형태가 비대칭인 게 의도된 진행. produces = "audio/mpeg" + Content-Disposition: inline + ResponseEntity<byte[]>세 줄 이 — 모델의 mp3 byte 를 사용자 브라우저까지 한 번의 변환도 없이 흘려보내는 곳이에요.

그리고 한 줄 더 — Spring AI 1.1.x 에는 StreamingTextToSpeechModel 도 있어요. TTS 를 토큰 단위 (정확히는 오디오 청크 단위) 로 스트리밍 으로 흘리는. 본 강의는 학습 단순도 를 우선해 binary 한 번에 의 방식으로 정리해두고, 스트리밍 TTS심화 레퍼런스 로만 짚고 넘어가요. Day 6 에서 손에 넣은 SSE을 — 추후 학생이 직접 TTS 에도 적용해볼 수 있는으로 열어둡니다.

11. 다음으로 — 5 단 파이프라인의 마지막 곳이 남았다

자, 정리해보면 — Step 6 까지 거쳐 온 부분 는 이래요.

  • Step 1~2 — 라인업 매트릭스 + 자매 추상화 시그니처
  • Step 3~4 — 듣는 빈 (VoiceTranscriptionService + VoiceTranscriptionController)
  • Step 5~6 — 말하는 빈 (VoiceSynthesisService + VoiceSpeechController)

이로써 — 듣는 빈말하는 빈 두 컨트롤러 가 모두 익히셨어요. 그런데 — 각자 떨어져 있을 뿐 부분이에요. POST /api/voice/transcribePOST /api/voice/speak 두 개가 나란히 놓여 있지만, 서로 손을 잡지 않은 상태예요.

사용자가 마이크에 대고 말하면 — 캐릭터의 답이 음성으로 돌아오는. 그게 5 단 파이프라인의 진짜 모양 이에요 — 마이크 → STT → ChatClient → TTS → 스피커. 그런데 지금 우리 손에는 — STT 와 TTS 두 개만, ChatClient 가 가운데에 끼어 있지 않아요.

다음 Step 에서 — 세 추상화가 손을 잡는 부분 가 펼쳐져요. TranscriptionModel · ChatModel · TextToSpeechModel 세 자매한 서비스 빈 안에서 차례로 호출되는. Day 5 의 ChatMemory 도 이때 대화의 맥락이 음성에서도 유지되는 부분로로 다시 등장돼요.

Step 7 에서 마지막 부분 를 짓습니다.


Step 7. 5 단 파이프라인 통합 — 세 추상화가 한 모습에서 손을 잡는 피날레 (약 25분)

자, 드디어 오늘의 피날레에 왔어요. Step 1 부터 Step 6 까지 — 듣는 빈 (VoiceTranscriptionService)말하는 빈 (VoiceSynthesisService)각자의 컨트롤러 와 함께 손에 정리했죠. 그런데 — 두 개가 아직 서로 손을 잡지 않은 상태였어요.

오늘 손에 정리한 듣는 빈 (TranscriptionModel 자매 추상화)말하는 빈 (TextToSpeechModel 자매 추상화), 그리고 Day 1 부터 함께 걸어온 생각하는 빈 (ChatModel)세 추상화가 처음으로 한 줄 위에서 손을 잡는 부분 예요.

사용자가 마이크에 대고 말하면 — 캐릭터가 자기 목소리로 답하는 그림이 진짜로 살아나는.

1. 도입 — 세 추상화가 손을 잡는 진행

"오늘 손에 정리한 듣는 빈 (TranscriptionModel) + 말하는 빈 (TextToSpeechModel), 그리고 Day 1 부터 함께한 생각하는 빈 (ChatModel) — 세 추상화가 처음으로 한 줄 위에서 손을 잡는 자리이에요."

5 단 파이프라인의 전체 모양을 한 번 더 정리할게요. Step 2 의 도식과 같은 결인데 — 진짜로 코드 한 줄 위에서 펼쳐지는 부분라는 진행이 다른 곳이에요.

사용자 마이크 → STT → 페르소나 + 사용자 텍스트 → ChatModel → AI 텍스트 → TTS → 캐릭터 음성
              (1)              (2~3)                (4)              (5)

다섯 단계예요.

(1) 음성 → 텍스트 / (2~3) 페르소나 + 사용자 텍스트로 Prompt 조립 / (4) AI 응답 / (5) 텍스트 → 음성. Day 8 의 CharacterVisionController.introducevision (Media) + chat 두 추상화 를 한 모습에 모았다면 —

오늘은 세 추상화가 모이는 한 단계 더 깊은 자리이에요.

2. CharacterVoiceService — 5 단 파이프라인의 응축

자, 오늘의 핵심 빈 자리이에요. 4 개의 의존성 이 한 지점에 모이고, 5 단 흐름한 메서드 안에 응축 돼 있어요.

package kr.spartaclub.aifriends.voice.service;

import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.domain.Soulmate;
import kr.spartaclub.aifriends.repository.SoulmateRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Service
public class CharacterVoiceService {

    private final SoulmateRepository soulmateRepository;
    private final VoiceTranscriptionService voiceTranscriptionService;
    private final VoiceSynthesisService voiceSynthesisService;
    private final ChatModel chatModel;

    public CharacterVoiceService(SoulmateRepository soulmateRepository,
                                 VoiceTranscriptionService voiceTranscriptionService,
                                 VoiceSynthesisService voiceSynthesisService,
                                 ChatModel chatModel) {
        this.soulmateRepository = soulmateRepository;
        this.voiceTranscriptionService = voiceTranscriptionService;
        this.voiceSynthesisService = voiceSynthesisService;
        this.chatModel = chatModel;
    }

    @Transactional(readOnly = true)
    public byte[] converse(Long soulmateId, Resource audio) {
        Soulmate soulmate = soulmateRepository.findById(soulmateId)
                .orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));

        // 1) STT
        String userText = voiceTranscriptionService.transcribe(audio);
        if (userText == null || userText.isBlank()) {
            log.info("[CharacterVoice] STT empty — skip Chat/TTS, soulmateId={}", soulmateId);
            return new byte[0];
        }

        // 2~3) 페르소나 + 사용자 텍스트 → ChatModel
        Prompt prompt = new Prompt(List.of(
                new SystemMessage(buildPersonaSystemMessage(soulmate)),
                new UserMessage(userText)
        ));
        ChatResponse response = chatModel.call(prompt);

        String aiText = (response == null || response.getResult() == null
                || response.getResult().getOutput() == null
                || response.getResult().getOutput().getText() == null)
                ? ""
                : response.getResult().getOutput().getText();

        if (aiText.isBlank()) {
            log.info("[CharacterVoice] ChatModel empty response — skip TTS, soulmateId={}", soulmateId);
            return new byte[0];
        }

        log.info("[CharacterVoice] converse: soulmateId={}, name={}, userTextLen={}, aiTextLen={}",
                soulmate.getId(), soulmate.getName(), userText.length(), aiText.length());

        // 4~5) TTS
        return voiceSynthesisService.synthesize(aiText);
    }

    private String buildPersonaSystemMessage(Soulmate soulmate) {
        return "당신은 '" + soulmate.getName() + "' 라는 이름의 캐릭터예요. "
                + "성격: " + soulmate.getPersonalityKeywords() + ". "
                + "취미: " + soulmate.getHobbies() + ". "
                + "말투: " + soulmate.getSpeechStyles() + ". "
                + "사용자의 말에 한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요.";
    }
}

자, 이 빈을 한 겹씩 펼쳐볼게요.

3. 의존성 4 자루 — 세 모달리티의 빈이 한 생성자에 모이는 진행

private final SoulmateRepository soulmateRepository;
private final VoiceTranscriptionService voiceTranscriptionService;
private final VoiceSynthesisService voiceSynthesisService;
private final ChatModel chatModel;

네 자루의 의존성 곳이에요. 하나씩을 짚어볼게요.

(1) SoulmateRepository대화 상대 캐릭터를 조회하는 자루. Day 8 의 CharacterVisionService완전히 같은 방식 단계이에요. 어떤 캐릭터로 대화할지 의 정체성을 먼저 잡아내는.

(2) VoiceTranscriptionServiceStep 3 에서 손에 정리한 듣는 빈. 오디오 자루를 받아 텍스트를 돌려주는.

(3) VoiceSynthesisServiceStep 5 에서 손에 정리한 말하는 빈. 텍스트 자루를 받아 byte[] 음성을 돌려주는.

(4) ChatModelDay 1 부터 함께한 생각하는 빈. import org.springframework.ai.chat.model.ChatModel; 인터페이스 타입 으로 주입받았다는 진행이 결정적이에요. Day 2 의 프로바이더 추상화 원칙오늘도 그대로 살아있는.

OpenAiChatModel · OllamaChatModel 같은 구체 타입을 박지 않은.

세 추상화가 한 생성자에서 잡히는 진행

Day 2 의 프로바이더 추상화 (ChatModel) → Day 7 의 자매 추상화 (ImageModel) → Day 9 의 두 자매 (TranscriptionModel 기반의 VoiceTranscriptionService + TextToSpeechModel 기반의 VoiceSynthesisService)세 추상화가 한 자리에서 다 모이는 진행 모습이에요.

chatModel.call(prompt) / voiceTranscriptionService.transcribe(audio) / voiceSynthesisService.synthesize(text) — 세 호출이 같은 모양의 인터페이스 위에서 일렬로 잡혀요. 프레임워크 하나, 감각 하나, 모달리티는 셋.

4. converse(Long soulmateId, Resource audio) — 5 단 한 메서드

이 메서드의 결정적 모양 곳이에요. 5 단계한 부분에 응축 돼 있어요. 한 단계씩 풀어볼게요.

(1) 캐릭터 조회 + 가드 한 줄

Soulmate soulmate = soulmateRepository.findById(soulmateId)
        .orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));

가장 먼저 — 대화 상대가 진짜 존재하는지 검증.

Optional.orElseThrow 한 줄로 없으면 즉시 BusinessException 을 던져요.

Day 8 의 CharacterVisionService.introduce완전히 같은 호흡이에요.

ErrorCode 까지 — SOULMATE_NOT_FOUND 그대로 재등장.

이 한 줄이 결정적인 이유는 — 나머지 4 단계가 모두 외부 프로바이더 호출 (STT · ChatModel · TTS) 인데, 존재하지 않는 캐릭터로 5 단을 흘리면 — 비용만 빠지고 의미 있는 결과는 0 곳이에요. 가장 싼 검증 (DB 한 번) 을 가장 먼저 정리하에요.

(2) STT 한 줄 + 빈 폴백

String userText = voiceTranscriptionService.transcribe(audio);
if (userText == null || userText.isBlank()) {
    log.info("[CharacterVoice] STT empty — skip Chat/TTS, soulmateId={}", soulmateId);
    return new byte[0];
}

Step 3 에서 정리한 빈을 한 줄 호출. audio 라는 Resource 자루가 — 텍스트 한 줄로 변신. 그리고 — 결정적인 폴백 한 부분 가 들어가 있어요.

STT 결과가 null 이거나 공백뿐이면ChatModel 도 TTS 도 호출하지 않고 빈 byte[] 를 즉시 반환 해요. 무성/노이즈만 들어온 자루 일 때 비용만 빠지는 호출을 막는 부분이에요. 조용히 들어주기 의 감각이에요. 학생이 마이크를 잘못 켜뒀거나, 아무 말도 안 하고 멈췄을 때 수업 한 번에 ChatModel + TTS 두 개의 비용이 빠지는 사고입구에서 막는 모습이에요.

(3) Prompt 조립 — 페르소나 + 사용자 텍스트 두 메시지

Prompt prompt = new Prompt(List.of(
        new SystemMessage(buildPersonaSystemMessage(soulmate)),
        new UserMessage(userText)
));

여기가 — Day 1~3 의 패턴이 세 번째로 다시 등장하는 자리이에요. Prompt(List.of(SystemMessage, UserMessage)) 의 두 메시지 구성. Day 1 의 ChatModel.call(prompt) → Day 3 의 PromptTemplate → 오늘 세 번째 재등장.

SystemMessage 부분에는 — 캐릭터의 정체성 (이름 · 성격 · 취미 · 말투) 을 자연어 한 덩어리로 정리해요. UserMessage 부분에는 — 방금 STT 가 인식한 사용자 텍스트 를 그대로 정리해요. 두 메시지가 분리된 결정적 이유 는. 페르소나는 시스템의 약속 (변하지 않는 자루), 사용자 발화는 매 호출마다 다른 자루. 프로바이더가 두 개의 가중치를 다르게 다룰 수 있게 분리해두에요.

(4) ChatModel 호출 + null-safe 추출

ChatResponse response = chatModel.call(prompt);

String aiText = (response == null || response.getResult() == null
        || response.getResult().getOutput() == null
        || response.getResult().getOutput().getText() == null)
        ? ""
        : response.getResult().getOutput().getText();

if (aiText.isBlank()) {
    log.info("[CharacterVoice] ChatModel empty response — skip TTS, soulmateId={}", soulmateId);
    return new byte[0];
}

ChatResponse 자루의 4 단 null 체크 가 들어가 있어요. 왜 이렇게까지 방어적 이냐 — 프로바이더가 빈 응답을 돌려주는 케이스 (rate limit · safety filter · 모델 오작동) 가 실제로 발생하기 때문이에요.

그럴 때 NPE 가 떨어지면 — 5 단 파이프라인 전체가 죽어 버려요. 명시적 null 체크 → 빈 문자열 폴백 이 더이 자연스럽고요.

그리고 — AI 텍스트가 빈 자루이면 TTS 도 건너뛰어요. 빈 텍스트로 TTS 를 호출하면 — 빈 음성 byte[] 를 받기 위해 비용을 지불 하는 진행이 들어가요. 조용히 끝내고 빈 byte[] 를 돌려주는 진행이 비용 + 사용자 경험 둘 다 자연스러운 모습이에요.

(5) TTS 호출 — 마지막 한 줄

return voiceSynthesisService.synthesize(aiText);

마지막 한 줄. AI 가 만든 텍스트 응답 이 — byte[] 음성 자루 로 변신해서 그대로 반환돼요. 컨트롤러 → 서비스 → 컨트롤러 의 자루가 한 번의 변환도 없이 흘러요.

@Transactional(readOnly = true)클래스가 아닌 메서드 위에 들어간 도 짚고 갈게요. Soulmate 조회만 있는 부분읽기 전용 트랜잭션 으로 충분해요. 외부 프로바이더 호출 (STT · ChatModel · TTS) 은 트랜잭션 밖에서 의미 없는 자루 이지만. DB 조회 자리만 명시적으로 readOnly 로 정리해두에요. Day 8 의 CharacterVisionService 와 같은 호흡 이고요.

5. buildPersonaSystemMessage(Soulmate) — 페르소나 자연어 한 덩어리

private String buildPersonaSystemMessage(Soulmate soulmate) {
    return "당신은 '" + soulmate.getName() + "' 라는 이름의 캐릭터예요. "
            + "성격: " + soulmate.getPersonalityKeywords() + ". "
            + "취미: " + soulmate.getHobbies() + ". "
            + "말투: " + soulmate.getSpeechStyles() + ". "
            + "사용자의 말에 한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요.";
}

캐릭터의 정체성 4 자루 (이름 · 성격 · 취미 · 말투)자연어 한 덩어리 로 정리하는 헬퍼예요. 한 가지을 짚어두고 갈게요.

PromptTemplate 을 안 썼는가학습 단순도 곳이에요. Day 3 에서 손에 정리한 PromptTemplate("...{name}...{personality}...")변수 치환에 더 정교한 제어가 필요할 때 의 도구예요. 오늘은. 4 자루를 한 줄로 자연어로 정리하면 그만 인 부분라, 문자열 연결 (+) 만으로 충분 한 방식으로 두었어요. 기본은 단순하게, 필요할 때 PromptTemplate 으로 승격 — 의 감각이에요.

마지막 한 줄 — "한국어 1~2 문장으로, 캐릭터 말투를 유지하며 짧게 답해 주세요." — 여기가 결정적 가드 예요. 없으면 — 모델이 5~10 문장의 긴 답 을 돌려줄 수 있고, 그게 그대로 TTS에 흘러서 비용이 5~10 배로 부풀어 요.

프롬프트 한 줄로 답변 길이를 가두는 진행비용 가드의 가장 저렴한 부분 예요.

6. 세 추상화가 한 메서드에 들어간 — 진화의 한 발 더

결정적 한 박스

Day 2 의 프로바이더 추상화 → Day 7 의 자매 추상화 → Day 9 의 두 자매가 한 지점에서 다 모이는 그림.

chatModel.call(prompt) ← Day 1~3 의 자루
voiceTranscriptionService.transcribe(audio) ← Day 9 의 자루 (TranscriptionModel 기반)
voiceSynthesisService.synthesize(aiText) ← Day 9 의 자루 (TextToSpeechModel 기반)

세 호출이 — 같은 모양의 인터페이스 위에서 일렬로 잡혀요. 프레임워크 하나, 감각 하나, 모달리티는 셋.

Day 8 의 CharacterVisionController.introducevision (Media) + chat 두 추상화 를 한 모습에 모았다면 — 오늘은 세 추상화가 모이는 진화의 한 발 더 들어간 부분예요.

7. Day 5 ChatMemory 의 의도된 미적용

자, 한 가지 — 눈치 빠른 학생은 이미 발견했을 단계을 짚고 갈게요. Day 5 에서 손에 정리한 JdbcChatMemoryRepository + ChatMemory오늘 코드 어디에도 없어요.

솔직한 방식 한 박스 — 학습 단순도 vs 실제 운영 의 트레이드오프

본 Step 은 ChatMemory 를 일부러 안 정리했어요. 한 번 호출 = 한 번 대화 의 방식으로 두면, 5 단 파이프라인의 골격 이 더 선명하게 보여요. 세 추상화가 손을 잡는 부분대화 맥락이라는 또 하나의 복잡도 에 묻히지 않는이고요.

그런데 — 운영 환경에선 이 방식이 그대로 가면 안 돼요. 사용자가 이전 대화의 맥락을 기억하는 캐릭터AI 친구진짜 모양 이거든요. "오늘 뭐했어?" → "책 읽었어요." → "어떤 책?" → "..."이 세 번째 발화가 — 직전 대화를 기억할 때만 자연스럽게 흘러요.

학습 단순도와 실제 운영이 다른 자리 가 — 오늘의 솔직한 부분 예요. 5 단 파이프라인의 골격은 오늘 박고, ChatMemory 결합은 다음 부분 — 이게 본 Step 의 정직한 결정이에요.

🙋 한 학생의 날카로운 질문

"튜터님, 그럼 Day 5 의 ChatMemory언제 다시 등장 하나요? 오늘 안 정리하면 — 캐릭터가 매번 사용자를 처음 만난 듯이 답하는 건 좀 어색한 장면 같은데요... "

정확히 흐름의 핵심을 찔러주셨어요. 두 곳 에서 다시 만나요.

첫째 — 오늘의 과제. 오늘 마무리 에서 만나게 될 과제 중 하나가 바로 그 부분 예요. 5 단 파이프라인에 ChatMemory 를 결합 하는 학생 손으로 한 번 정리해보는. ChatClient 위에 MessageChatMemoryAdvisor 를 정리하는 진행 — Day 5 의 패턴 그대로요.

둘째 — Day 11 (Tool Calling). 에이전트가 캐릭터의 과거 발화를 도구로 검색 하는 진행이 펼쳐질 거예요. ChatMemory 가 단순 기억 에서 도구 호출 진행 로 한 번 더 진화하는 부분고요.

한 줄로 정리하면 — 오늘은 5 단 파이프라인의 골격, 다음 두 모습에서 ChatMemory 가 자연스럽게 합류 해요. 학습은 한 번에 두 가지를 박지 않아요하나씩, 그 다음에 두 자리이 만나는 부분이에요.

8. CharacterVoiceController — 진입점 한 컨트롤러

이제 — 컨트롤러 부분 예요. Step 4 의 검증 패턴 + Step 6 의 binary 응답 패턴 두 개가 한 곳에서 만나는 곳이에요.

package kr.spartaclub.aifriends.voice.controller;

import kr.spartaclub.aifriends.common.exception.ErrorCode;
import kr.spartaclub.aifriends.voice.exception.VoiceException;
import kr.spartaclub.aifriends.voice.service.CharacterVoiceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.Locale;
import java.util.Set;

@Slf4j
@RestController
@RequestMapping("/api/voice")
public class CharacterVoiceController {

    private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
            "mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"
    );

    private static final long MAX_BYTES = 10L * 1024 * 1024;

    private final CharacterVoiceService characterVoiceService;

    public CharacterVoiceController(CharacterVoiceService characterVoiceService) {
        this.characterVoiceService = characterVoiceService;
    }

    @PostMapping(value = "/characters/{soulmateId}/converse", produces = "audio/mpeg")
    public ResponseEntity<byte[]> converse(
            @PathVariable Long soulmateId,
            @RequestParam("audio") MultipartFile audio) {

        validate(audio);

        Resource resource = audio.getResource();
        byte[] responseAudio = characterVoiceService.converse(soulmateId, resource);

        log.info("[CharacterVoice] success: soulmateId={}, filename={}, inSize={}, outBytes={}",
                soulmateId, audio.getOriginalFilename(), audio.getSize(), responseAudio.length);

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType("audio/mpeg"))
                .header("Content-Disposition", "inline; filename=\"reply.mp3\"")
                .contentLength(responseAudio.length)
                .body(responseAudio);
    }

    private void validate(MultipartFile audio) {
        if (audio == null || audio.isEmpty()) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_REQUIRED);
        }

        String extension = extractExtension(audio.getOriginalFilename());
        if (extension == null || !ALLOWED_EXTENSIONS.contains(extension)) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_FORMAT_INVALID);
        }

        if (audio.getSize() > MAX_BYTES) {
            throw new VoiceException(ErrorCode.VOICE_AUDIO_TOO_LARGE);
        }
    }

    private String extractExtension(String filename) {
        if (filename == null) return null;
        int dot = filename.lastIndexOf('.');
        if (dot < 0 || dot == filename.length() - 1) return null;
        return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
    }
}

이 컨트롤러의 결정적인 결 4 자루 만 짚을게요.

(1) URL — /api/voice/characters/{soulmateId}/converse

Step 4 (/api/voice/transcribe) · Step 6 (/api/voice/speak)같은 prefix 아래 세 번째 자루 가 들어갔어요. 그런데 — 경로 모양이 달라요. /characters/{soulmateId}/converse 캐릭터 도메인 자루가 가운데에 들어갔어요. Step 4·6 은 「음성 도메인의 변환 자루」, Step 7 은 「캐릭터와의 대화」 라는 진행의 차이를 URL 모양 으로도 신호하는 부분이에요.

(2) 입력 — 멀티파트 + PathVariable 두 개

@PathVariable Long soulmateId,
@RequestParam("audio") MultipartFile audio

PathVariable 로 캐릭터 정체성, RequestParam 으로 오디오 자루 — 두 개를 한 메서드에서 같이 받는 모습이에요. Day 8 의 CharacterVisionController 가 같은 호흡 이었고요. RESTful 하나의 모양 자리이에요.

(3) validate(audio) — Step 4 패턴의 답습

Step 4 의 VoiceTranscriptionController.validate 와 거의 같은 자루. null/empty → VC001, 확장자 화이트리스트 → VC002, 10MB 초과 → VC003. 공통 헬퍼로 추출하지 않고 — 컨트롤러마다 명시적으로 박는 모습이에요.

왜 헬퍼로 추출 안 하는가학습 단순도 때문이에요. 학생이 컨트롤러 하나만 봐도 — 어떤 검증이 들어가 있는지 한눈에 보이는 결추상화를 한 겹 더 정리하는 결보다 학습엔 자연스러워요. 운영 환경에선 — BaseAudioValidator 같은 공통 헬퍼로 승격 하는 게 자연스러운 다음이고요.

(4) Content-Disposition: inline; filename="reply.mp3" — 답변 의 흐름

Step 6 의 speech.mp3 와 다른 파일명이에요. reply.mp3캐릭터의 답변 이라는 진행을 파일명에 정리하는. 우클릭 → 저장 하면 —

reply.mp3 로 떨어져요. Step 6 (단순 TTS, 어떤 텍스트든) 과 Step 7 (캐릭터의 대답) 의 흐름의 차이파일명 하나로 신호되는 그림이에요.

9. 5 단 흐름의 세 가지 약속 — 페르소나 박힘 · 가드 short-circuit · STT 폴백

5 단이 진짜로 약속대로 도는지는 세 개의 약속 으로 묶여요. 코드베이스의 CharacterVoiceServiceTest3 시나리오로 각각 하나씩 검증해뒀어요.

첫째 — 페르소나 + 사용자 텍스트가 한 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, 에러는 JSONApiResponse 표준 패턴의 의도적인 비대칭이 한 컨트롤러 테스트 안에서 다시 등장 돼요.

10. 💡 튜터의 결론 — Step 7 한 줄

💡 튜터의 결론 — 세 추상화가 한 곳에서 손을 잡는 피날레

Day 9 의 마지막 핵심은 — 듣는 빈 + 생각하는 빈 + 말하는 빈, 세 추상화가 한 메서드 안에서 차례로 손을 잡는 그림이에요. Day 2 의 프로바이더 추상화세 번째 모달리티 (청각) 에서 다시 등장되고, Day 1~3 의 Prompt(SystemMessage + UserMessage) 패턴페르소나의 부분 에서 다시 들어갔어요.

학습 단순도를 위해 Day 5 의 ChatMemory 는 의도적으로 비워뒀어요. 5 단 파이프라인의 골격이 가장 선명하게 보이는 진행 을 우선했고, 대화 맥락 결합오늘의 과제Day 11 (Tool Calling) 두 지점에서 자연스럽게 다시 만나요.

프레임워크 하나, 감각 하나, 모달리티는 셋같은 모양의 인터페이스 위에서 세 개가 일렬로 잡히는 그림이 Spring AI 의 진짜 모양 모습이에요.

이로써 Day 9 의 모든 빈이 들어갔고, 세 추상화가 한 자리에서 손을 잡는 주제까지 완성됐어요. 마지막 부분은 — 비용 가드 + 브랜치 세이브 + Day 10 (비디오) 복선.


마무리 — 오늘 익힌 · 브랜치 세이브 · Day 10 (비디오) 복선 (약 10분)

1. 오늘 손에 정리한 장면 — 일곱 가지 자루를 한 줄씩

Day 9 의 3 시간을 한 문장으로 요약하면 — "캐릭터에게 귀와 입 을 달아준 하루 — TranscriptionModel / TextToSpeechModel 자매 추상화가 세 번째로 등장해서 들어간, 그리고 세 추상화가 한 메서드 안에서 손을 잡는 5 단 파이프라인의 첫 자리" 였어요.

지난 시간 (Day 8) 캐릭터에게 을 달아준 손이, 오늘은 귀와 입 두 부분를 한꺼번에 정리해 — 시각 입출력 (Day 7~8) → 청각 입출력 (Day 9) 의 모달리티 진화를 한 단계 더 끌어올렸어요. 그리고 결정적으로. Step 7 에서 세 자매 추상화 (TranscriptionModel · ChatModel · TextToSpeechModel) 가 처음으로 한 줄 위에서 손을 잡는 단계이 펼쳐졌죠.

오늘 익힌 일곱 가지 그림 을 한 줄씩 묶을게요.

Step 한 줄 요약
Step 1 음성 모델 라인업 4 옵션 "Gemini · OpenAI · 로컬 · 브라우저 — 추상화는 OpenAI 스타터 / 실제 호출은 무료 라는 트레이드오프 결정. 음성은 텍스트의 30~50배 비용 감각 첫 각인"
Step 2 자매 추상화 두 빈 + 마이크 녹음 "TranscriptionModel / TextToSpeechModel 두 빈이 ChatModel 옆에 나란히 놓이는 — 자매 추상화의 세 번째 등장. 브라우저 MediaRecorder 시연"
Step 3 VoiceTranscriptionService "Resource → AudioTranscriptionPrompt → call → String 의 5 단 변환. VoiceException + VC001 의 첫 곳"
Step 4 VoiceTranscriptionController "MultipartFile.getResource() 한 줄 변환 + 화이트리스트 7 자루 + 10MB 가드. ApiResponse 표준 패턴의 세 번째 등장"
Step 5 VoiceSynthesisService "Step 3 와 거울처럼 대칭String → TextToSpeechPrompt → call → byte[] 의 5 단. 듣는 빈 옆에 말하는 빈"
Step 6 VoiceSpeechController "바이너리 응답의 첫 부분audio/mpeg + ResponseEntity<byte[]>. ApiResponse 표준 패턴의 명시된 예외 절 — 정상은 binary, 에러는 JSON 의 의도적인 비대칭"
Step 7 5 단 파이프라인 통합 "세 자매 추상화가 한 메서드 안에서 차례로 손을 잡는 피날레. CharacterVoiceService.converse(...) 한 부분에 STT → ChatModel → TTS 의 5 단이 응축. ChatMemory 는 의도적 미적용"

이 일곱 개를 다 외우라는 게 아니에요.

"TranscriptionModelTextToSpeechModelChatModel자매 추상화 — 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 단 파이프라인이 — 모달리티 추상화의 일관성을 가장 선명하게 보여주는 진행이다."

2. 비용 가드 — 음성은 텍스트의 30~50배 의 한 번 더

자, 솔직한 한 박자 정리할게요. Step 1 에서 처음 던진 비용 감각 — 1 분짜리 음성 STT 한 번 ≈ 텍스트 호출 30~50 회 분량, 짧은 TTS 한 번 ≈ 텍스트 호출 5~10 회 분량. Vision (Day 8) 이 텍스트의 1.5~2 배 였다면, 음성은 한 부분수 더 위 예요.

본 강의는 — 모킹 + 강사 사전 녹화 시연 의 방식으로 정리해뒀어요. 학생 비용은 0 원 으로 학습이 흐르도록 디폴트가 Mockito 위에서 펼쳐지고, 실제 프로바이더 호출은 강사 데모 영상 으로만 보여드렸어요. 들어간 패턴은 그대로지만, 지갑은 안전한.

솔직한 방식 — 이 코드 그대로 운영에 올리면 비용 폭발

오늘 정리한 5 단 파이프라인학습용 골격 모습이에요. 운영 환경에 그대로 올리면악의적 사용자가 4000 자 TTS 를 초당 10 회씩 던지면 하루 만에 청구서가 수십만 원 이 될 수 있어요. Day 7 의 ImageDailyQuotaGuard · Day 8 의 VisionDailyQuotaGuard 자매 패턴이 — 오늘의 주제에선 의도적으로 비워뒀어요. 5 단 파이프라인의 골격을 가장 선명하게 보이게 하기 위함이고, VoiceDailyQuotaGuard 를 직접 정리하는 진행 은 — 오늘의 과제 부분 로 미뤄뒀어요. 학생 손에서 직접 한 번 정리해보는가드 패턴 세 번째 등장 의 진짜 감각이에요.

운영 환경 체크리스트로 정리해둘 세 개(1) 일일 호출 횟수 제한 (사용자 단위 + 글로벌), (2) 단일 요청 최대 길이 (오늘은 4000 자, 운영은 더 짧게), (3) 비용 모니터링 알람 (월 예산의 70% 경계). 오늘의 과제 에서 한 부분, Day 19 Harness 에서 본격적으로 —

비용 가드의 모양 이 한 단계씩 펼쳐져요.

3. 🎤 더 미연시스러운 캐릭터 보이스 — voice 매트릭스 + ElevenLabs swap 토대

자, 여기서 학생들이 자주 던지는 진짜 한 마디 가 있어요.

🙋 "튜터님, OpenAI gpt-4o-mini-tts 로 합성된 캐릭터 답을 들어보면 — 깔끔한데 미연시 캐릭터 톤은 아니에요. 더 캐릭터답게 들리는 voice 가 없을까요?"

맞는 진단입니다.

OpenAI TTS 라인업(marin/cedar/coral/ash 등) 은 narrator + 자연스러운 한국어 발음 에 강하지, 캐릭터 연기 는 약해요.

미연시 같은 반말 + 끝에 "ㅎㅎ" + 살짝 들뜬 톤 을 살리려면 다른 라인업으로 갈아 끼우는 게 답이에요. 2026 시점의 voice 프로바이더 매트릭스 를 한 표로 펼쳐드릴게요. Step 1 의 라인업이 간략 버전 이었다면 — 이건 7 자루까지 펼쳐낸 완성 버전 자리이에요.

프로바이더 강점 약점 한국어 Spring AI 1.1 공식 starter
OpenAI gpt-4o-mini-tts (오늘 디폴트) 한국어 발음 자연 · narrator 톤 안정 · 단가 저렴 캐릭터 연기 약함 · voice 13종으로 한정 · 감정 표현 단조로움 ✅ 자연 spring-ai-starter-model-openai
Azure Neural TTS SSML style/styledegree기쁨/슬픔/속삭임 등 감정 톤 표현. 한국어 voice 다수 가격이 OpenAI 대비 살짝 높음 · 키 발급 + Azure 포털 진입장벽 ✅ 자연 + 감정 ⚠️ 비공식 (RestClient 직접)
ElevenLabs eleven_multilingual_v2 캐릭터 클로닝 + prebuilt voice 수백 종 · 미연시/오디오북에 가장 많이 쓰이는 라인업 한국어 억양은 voice 운에 가까움 (특정 voice 만 자연스러움) · UUID 기반 voice 식별 ⚠️ voice 따라 다름 spring-ai-starter-model-elevenlabs
VOICEVOX / Style-Bert-VITS2 anime / 미연시 톤의 정석. JP 캐릭터 보이스 라이브러리가 압도적 JP 위주 — 한국어는 거의 안 됨. 셀프호스트 (GPU 권장) ❌ community/REST
Naver Clova Voice / Typecast 한국어 자연도가 가장 높은 라인업. 광고/내레이션 품질 외국어 약함 · 국내 결제/계약 절차가 글로벌 SaaS 대비 무거움 ✅✅ 1티어 ❌ community/REST
Cartesia 저지연 스트리밍 에 특화 — 첫 음절이 ~100ms 안에 떨어짐 (실시간 통화 모드) 한국어 voice 풀이 좁음 · 신생 라인업이라 가격/지원 변동 가능 ⚠️ ❌ community/REST
Coqui XTTS-v2 / Piper (셀프호스트) 호출 단가 0 + voice cloning 가능 · GDPR/오프라인 시나리오 강점 GPU 자원 + 운영 부담 + 모델 품질이 SaaS 1티어 대비 한 계단 아래 ⚠️ XTTS 는 ok / Piper 는 한국어 약함 ❌ 직접

💡 Spring AI 1.1.x 의 공식 TTS starter 는 OpenAI / ElevenLabs 두 자루뿐. 나머지 프로바이더는 community starter 또는 RestClient 로 직접 호출해야 하는 현재의 한계 가 있어요. (Spring AI 가 2.0 / 2.x 에서 라인업을 더 넓힐지는 로드맵 자리)

본 코드베이스에 이미 들어가 있는 swap 토대

오늘 강의 본문에서는 한 가지 path (OpenAI) 만 코드로 펼쳤어요.

그런데 — 여러분이 받아 가신 코드베이스에는 이미 .env 한 줄로 ElevenLabs 로 갈아끼울 수 있는 토대 가 들어 있어요. Step 5 · Step 6 의 복선 박스 두 자루가 여기서 회수됩니다.

학습 단순도를 위해 본문에선 덜어낸 압축본 으로 흘렸지만, 코드베이스의 실제 클래스 는 더 부피가 큰 자루였죠.

(1) .env 한 줄 swap

# .env
TTS_PROVIDER=openai        # ← 디폴트. 오늘 강의 본문에서 본 그 path
# TTS_PROVIDER=elevenlabs   # ← 이 한 줄로 즉시 ElevenLabs 라인업으로 swap
ELEVENLABS_API_KEY=         # https://elevenlabs.io 에서 발급 (무료 티어 月 10,000자)
ELEVENLABS_TTS_MODEL=eleven_multilingual_v2
ELEVENLABS_DEFAULT_VOICE_ID=21m00Tcm4TlvDq8ikWAM   # 매핑 표 누락 시 폴백 voice

이 한 줄 swap 이 동작하는 진짜 이유 는 — TextToSpeechModel 인터페이스 추상화 의 효과예요. Step 5 의 VoiceSynthesisService어떤 프로바이더 구현체가 들어올지 모른 채 인터페이스만 부르고 있던 그 구조. 그 구조가 그대로 살아 있어서, 코드 0 줄 수정으로 백엔드의 voice 가 갈리는 모습이에요. application.yml 에는 두 프로바이더의 default 모델/voice 가 동시에 박혀 있고, spring.ai.model.audio.speech selector 가 .envTTS_PROVIDER 값을 받아 둘 중 하나의 빈만 활성화 해요.

(2) mood key → voice 식별자 매핑 표 (코드베이스의 VoiceSynthesisService 안)

코드베이스의 실제 서비스에는 추상 mood 키프로바이더별 voice 식별자 의 매핑 표가 두 자루 박혀 있어요. 프론트는 추상 키만 던지고, 실제 voice 식별자는 활성 프로바이더에 따라 자동 매핑 곳이에요.

mood key OpenAI voice (gpt-4o-mini-tts) ElevenLabs voice UUID (prebuilt)
bright marin 5I7B1di44aCL15NkP0jn (Kanna — narrator 여성)
warm coral 5n5gqmaQi9Ewevrz7bOS (Sian — calm/soft 여성)
calm cedar pNInz6obpgDQGcFmaJgB (Adam — deep narrator 남성)
cheerful ash ErXwobaYiN019PkySvjV (Antoni — 활기 narrator 남성)

⚠️ Spring AI 1.1 의 ElevenLabs 모델은 portable TextToSpeechOptions.voice() 슬롯을 무시 해요. instanceof ElevenLabsTextToSpeechOptions 체크로만 runtime options 를 인식하기 때문이에요 (OpenAI 도 동일 패턴). 그래서 코드베이스의 VoiceSynthesisService 는 — 활성 프로바이더에 맞춰 ElevenLabsTextToSpeechOptions 또는 OpenAiAudioSpeechOptions 를 명시 빌드 하는 한 단 분기 가 들어가 있어요. portable 인터페이스만 믿고 voice 를 박으면 — type 이 안 맞아서 무시되고 application.yml 의 default voice 로 폴백 되는 사고가 나거든요. 이게 — Spring AI 1.1 의 가장 미묘한 결 자리이에요.

(3) swap 결과 한눈에 확인하는 두 가지 방법

받자마자 손에 익혀두면 어떤 voice 로 합성됐는지 즉시 알 수 있어요.

  • DevTools Network 탭/api/voice/speak 응답 헤더의 X-TTS-Provider (openai / elevenlabs) + X-TTS-Voice (marin / UUID) 가 그대로 보여요. Step 6 의 복선 박스에서 짚었던 그 두 헤더예요.
  • GET /api/voice/info 한 번 찍기 — {"success": true, "data": {"provider": "elevenlabs"}} 같은 한 줄 JSON 으로 현재 활성 프로바이더 만 단독 확인.

🎯 한 번 직접 들어보기 — 같은 텍스트가 완전히 다른 톤으로

받아가신 코드베이스를 띄운 뒤, ElevenLabs 키를 발급받아 다음 두 줄만 해보세요.

# 1) ElevenLabs 가입 → 키 발급 (https://elevenlabs.io)
echo 'ELEVENLABS_API_KEY=sk_...' >> .env
sed -i '' 's/TTS_PROVIDER=openai/TTS_PROVIDER=elevenlabs/' .env   # mac 기준
./run.sh up

같은 캐릭터, 같은 텍스트로 /api/voice/speak 를 부르면 — 완전히 다른 톤 의 목소리가 떨어져요. Step 5 에서 박은 chatModel.call(prompt) 한 줄도, Step 6 에서 박은 ResponseEntity<byte[]> 한 줄도 변하지 않았어요. 인터페이스 추상화 한 단의 진짜 효과지금 손끝에서 살아나는 모습이에요. Day 2 의 프로바이더 추상화가 — 청각 출력 모달리티에서도 같은 방식으로 흐르는 결정적 풍경이고요.

숙제 한 자루 (선택): 코드베이스의 VoiceSynthesisService 안엔 mood key → voice 식별자 매핑 표가 두 자루 박혀 있어요. 오늘 ai-friends 의 캐릭터 4 명을 각각 어떤 mood 에 넣을지 는 정답이 없어요. 본인의 게임 시나리오에 맞게 — ElevenLabs Voices 탭에서 voice UUID 를 골라 매핑 표를 갈아끼워 보세요. "하리 = bright = Kanna UUID" 같은 결정이 캐릭터 정체성의 한 자루 가 되는 자리이에요.

4. 브랜치 박제 — day09-voice (Day 별 브랜치 박제 규약)

오늘 코드베이스 상태를 브랜치로 박제해두세요. Step 단위 커밋이 5 개 들어가 있어야 해요. 본 강의의 오늘의 브랜치 박제 지점이에요.

cd lecture-source-code/ai-friends
git log --oneline -10                # Day 9 의 Step 단위 커밋 흐름 확인
git push origin day09-voice          # 원격 저장소가 있다면

오늘의 박제된 5 개 커밋 (Step 1~2 는 이론 Step 이라 코드 변경 0 — Step 단위 커밋 규약의 이론 Step 예외 — 그래서 커밋 생략):

브랜치: day09-voice

박제 커밋:
  Step 3: cba078a feat: add VoiceTranscriptionService with TranscriptionModel + AudioTranscriptionPrompt (Day 9 Step 3)
  Step 4: f28c731 feat: add VoiceTranscriptionController with whitelist + 10MB guard + ApiResponse (Day 9 Step 4)
  Step 5: ee6d7ec feat: add VoiceSynthesisService with TextToSpeechModel + 4000-char guard (Day 9 Step 5)
  Step 6: ddc2246 feat: add VoiceSpeechController POST /api/voice/speak with audio/mpeg binary response (Day 9 Step 6)
  Step 7: 2380055 feat: add CharacterVoiceService + Controller for 5-stage voice pipeline (Day 9 Step 7)

Step 1~2 에 커밋이 없는 진행모델 라인업 매트릭스 (Step 1) + 자매 추상화 시그니처 + 마이크 녹음 시연 (Step 2)코드 변경이 한 줄도 없는 이론 Step 곳이에요. Step 단위 커밋 규약의 예외 — 코드 변경 0 인 Step 은 커밋 생략 이 그대로 다시 등장한. 모든 Step 이 커밋을 만들어야 하는 건 아니다 는, Day 8 의 단계 그대로예요.

이후 어떤 Day 든 git checkout day09-voice 로 오늘 상태로 돌아올 수 있어요. Step 7 의 CharacterVoiceService.converse(...) 시점만 보고 싶다 같은 요구도 한 줄로 가능해요.

오늘 정리한 코드베이스의 voice 패키지 구성 을 한 부분에 정리해둘게요.

src/main/java/kr/spartaclub/aifriends/voice/
├── service/
│   ├── VoiceTranscriptionService.java    (Step 3)
│   ├── VoiceSynthesisService.java        (Step 5)
│   └── CharacterVoiceService.java        (Step 7)
├── controller/
│   ├── VoiceTranscriptionController.java (Step 4)
│   ├── VoiceSpeechController.java        (Step 6)
│   └── CharacterVoiceController.java     (Step 7)
├── dto/
│   ├── TranscribeResponse.java           (Step 3~4)
│   └── SpeakRequest.java                 (Step 5~6)
└── exception/
    └── VoiceException.java               (Step 3, VC001~VC007)

src/test/java/kr/spartaclub/aifriends/voice/
├── service/
│   ├── VoiceTranscriptionServiceTest.java
│   ├── VoiceSynthesisServiceTest.java
│   └── CharacterVoiceServiceTest.java
└── controller/
    ├── VoiceTranscriptionControllerTest.java
    ├── VoiceSpeechControllerTest.java
    └── CharacterVoiceControllerTest.java

Service 3 개 + Controller 3 개 + DTO 2 개 + Exception 1 개 + Test 6 개오늘의 주제에 들어간 모든 파일 모습이에요.

5. 중간 합류 학생 가이드 (./run.sh 한 줄 실행 표준)

오늘 처음 합류하셨거나, 지난 Day 를 못 따라오셨다면 이 4 단계만 따라오세요.

# 1. Day 9 시작 기준점으로 체크아웃
git clone <repo-url> ai-friends
cd ai-friends/lecture-source-code/ai-friends
git checkout day09-voice

# 2. 환경 변수 세팅
cp .env.example .env

# 3. .env 채우기
# - OPENAI_API_KEY (gpt-4o-transcribe STT + gpt-4o-mini-tts — 본 강의는 모킹 디폴트, 실호출 시에만 필요)
# - GEMINI_API_KEY (ChatModel — Day 1 부터의 베이스 키, 살아 있어야 함)
# - GEMINI_MODEL=gemini-2.5-flash-lite (코드베이스 디폴트)
# - MYSQL 비밀번호 (Day 5 ChatMemory 회귀 안 깨지게)

# 4. 앱 기동 — ./run.sh 가 1 순위 (본 강의의 실행 표준)
./run.sh up

💡 중간 합류 팁 — Day 9 의 학생 실습은 모킹 기반 자리이에요. 실제 OpenAI gpt-4o-transcribe / TTS 호출강사 시연 + 본인 카드로 굳이 돌리고 싶은 학생만 의. 유닛 테스트는 OPENAI_API_KEY 없이도 모두 그린 이고 (모킹 100%), 통합 시연만 키가 필요 해요. 지갑 안전 + 감각 100% 의 디폴트예요.

6. Day 10 (비디오) 복선 — 비용 충격비동기 폴링 의 첫 부분

자, 오늘의 마지막 주제이자 다음 시간의 첫 글자.

오늘 우리는 — 음성이 텍스트의 30~50 배 라는 비용 감각을 손에 정리했어요. 그런데 — 다음 시간 만날 모달리티는 그보다 또 몇 십 배 예요. 비디오. 5 초짜리 짧은 클립 한 개 = 텍스트 호출 수천 회 분량 이 가능한 곳예요.

그래서 Day 10 은 — 시연 중심 + 선택 실습 으로 정리했어요. 학생 본인 카드로 무턱대고 돌리는 곳이 아니에요. 강사가 강의장 스튜디오 키로 1~2 개의 짧은 클립 을 시연으로 풀고, 「원하는 학생만 본인 판단하에」 손에 정리하는. Day 10 의 시연 중심 + 선택 실습 흐름 이 그대로 다시 만나요.

모달리티 Day 입력 출력 비용 (텍스트 호출 1 회 기준)
텍스트 Day 1~6 텍스트 텍스트 1 배
이미지 출력 Day 7 텍스트 이미지 5~20 배
이미지 입력 (Vision) Day 8 이미지+텍스트 텍스트 1.5~2 배
음성 입출력 Day 9 (오늘) 음성/텍스트 음성/텍스트 30~50 배
비디오 (시연) Day 10 (다음 시간) 텍스트 비디오 수천 배

다음 시간 만날 결정적 새 패턴 한 가지 — 비동기 처리 폴링 (Async Polling). 비디오 생성은 한 번의 동기 호출로 떨어지지 않아요. Job 제출 → 폴링 → 완성 통지 의 방식으로 흐르는 부분이에요.

Day 6 SSE 스트리밍Day 9 binary 응답 도 아닌 — 세 번째 응답 패턴 이 처음으로 등장해요.

비디오 모델 라인업 미리 던져둘게요 (2026-04 기준).

  • Veo 3 (Google) — Gemini 패밀리, 사운드 포함 비디오, 가장 비싼 라인 중 하나
  • Sora (OpenAI) — 2024 말 공개 후 2026 에 API 가용, 최고 품질 + 최고 가격
  • Runway Gen-4 — 영상 업계 디폴트, API 안정적
  • Luma Dream Machine — 빠른 생성 + 중간 가격대
  • Kling (Kuaishou) — 중국 라인, 가성비 + 길이 강점

무료 대안도 정리할게요.

  • Stable Video Diffusion — 로컬 GPU 에서 짧은 클립 (4~25 프레임)
  • GIF + Ken Burns 효과 — 정적 이미지를 움직이는 듯한 방식으로 흘리는 가짜 비디오 의 감각
  • 이미지 시퀀스 → ffmpeg 합성 — Day 7 ImageModel 로 만든 이미지 여러 장을 비디오로 묶는 진행

💡 Day 10 클로저 한 줄

"음성도 텍스트의 30 배인데, 영상은 그보다 또 몇 십 배. 그래서 비디오는 — 시연으로 만나는 부분. 강사가 시연으로 풀고, 원하는 학생만 본인 판단하에 손에 정리하는. 그리고 — 비동기 폴링 이라는 세 번째 응답 패턴 이 처음으로 등장해요. Day 6 SSE 도 Day 9 binary 도 아닌 새. Day 10 에서 만나요."

7. 5 감 진화의 마지막 한 발 (Day 1~10)

자, Day 1 부터 Day 9 까지 — ai-friends 가 익힌 능력의 진화 를 한 도식에 정리할게요. 오늘이 청각 모습의 닫힘 이고, 다음 시간은 5 감 진화의 마지막 한 발 모습이에요.

Day 1  ChatModel        — 텍스트로 말 걸기 (오감 0)
Day 2  프로바이더 추상화  — 어느 모델이든 같은 인터페이스
Day 3  PromptTemplate   — 말의 흐름을 다듬는 손맛
Day 4  구조화 출력      — JSON 으로 약속된 답을 받기
Day 5  ChatMemory       — 지난 대화를 기억하는 끈
Day 6  Streaming        — 토큰이 흘러오는 풍경
Day 7  ImageModel       — 그림을 그려내는 손 (시각 출력)
Day 8  Vision           — 그림을 읽어내는 눈 (시각 입력)
Day 9  Voice (STT/TTS)  — 듣는 귀 + 말하는 입 (청각)  ← 오늘
Day 10 Video (시연)     — 영상을 만들어내는 흐름        ← 다음 시간

이 진화의에서 흥미로운 흐름 한 가지 — 모든 모달리티가 같은 자매 추상화 위에서 펼쳐진다 는 거예요. 새 모달리티가 등장할 때마다 완전히 새 프레임워크 를 배우는 게 아니라. Day 2 의 프로바이더 추상화 + 자매 추상화 (Day 7 ImageModel · Day 8 Media · Day 9 TranscriptionModel / TextToSpeechModel) 라는 통일된 방식 위에서 한 부분씩 익혀요. 오늘 세 추상화가 한 메서드 안에서 손을 잡는 부분까지 —

Spring AI 가 정리해둔 추상화의 일관성 이 진짜로 살아있는 모습이었어요.

그리고 Day 10 너머 한 줄만 더 정리할게요. 비디오까지 다 끝나면 — 이제 LLM 이 스스로 도구를 골라 쓰는으로 갑니다. 에이전트의 시작점. @Tool 어노테이션 한 줄로 — 내가 만든 함수를 LLM 이 직접 호출 하는 그림. Day 11 에서 만나요.

자, Day 9 의 모든이 닫혔어요. 세 자매 추상화가 한 자리에서 손을 잡는 지점 — 비용의 30~50 배 충격바이너리 응답의 첫 부분5 단 파이프라인의 응축 까지. 7 가지 그림 이 한 도시에 모였어요.

자, 마무리 섹션은 여기까지. 본격적으로 손을 더럽혀볼 시간입니다. Mission 섹션 으로 넘어가서 과제와 생각해볼 주제를 받아보세요!


Mission — 오늘의 과제

오늘 손에 정리한 여섯 가지를 내 손에서 한 번 더 펼쳐보는 곳이에요.

  • TranscriptionModel + TextToSpeechModel 의 자매 추상화
  • Resource ↔ MultipartFile 변환
  • ApiResponse 표준 패턴의 binary 예외 절
  • 거울 대칭의 두 서비스
  • 5 단 파이프라인 통합
  • 음성 30~50 배 비용 감각 Day 5 의 ChatMemory 를 5 단 파이프라인에 끼워넣는 진행, Day 7/8 가드 패턴의 세 번째 등장, 프로바이더 추상화의 음성 버전 마지막 회수 까지 — 세 가지 흐름의 도전 과제를 정리할게요.

💡 과제 작업 시 공통 가이드

  • 모든 과제는 ai-friends 프로젝트의 별도 브랜치 에서 작업하세요. (예: day09-assignment1-chat-memory)
  • 새 컨트롤러/서비스를 만들 때는 본 강의의 ApiResponse 표준 패턴 을 그대로 따르세요 — ResponseEntity<ApiResponse<T>>. (단, binary 응답 은 Step 4 의 예외 절 그대로 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 감각이 세 번째 모달리티 (음성) 에서도 그대로 동작함을 손에 정리하에요.

💡 왜 굳이 이 과제를 할까요?

  1. Day 5 ChatMemory + Day 9 5 단 파이프라인의 교차점 — 두 큰을 한 부분에 합치는 감각. 텍스트로 들어간 메모리 추상화음성 입출력의 한가운데 에서도 그대로 살아있다는 진행이 손에 단단히 들어가요.
  2. ChatModel vs ChatClient 의 결정 부분Day 11 부터 등장하는 ChatClient + MessageChatMemoryAdvisor 방식으로 가면 어드바이저 한 줄 로 풀려요.
    • 그런데 본 강의는 Day 11 까지 ChatClient 도입 안 함 이 원칙 — 그러니 수동으로 ChatMemory.add / chatMemory.get 하는 방식으로 가야 해요.
    • 손이 한 번 더 들어가야 ChatClient 의 추상화가 다음 Day 에 더 진하게 들어와요.

세션 ID 의 결정 부분어떤 conversationId 로 메모리를 갈라놓을지 가 핵심.

soulmateId 단독 방식이면 같은 캐릭터 = 같은 메모리. userId + soulmateId 조합 이면 유저별 + 캐릭터별 격리. 두 방식 모두 트레이드오프가 다른데, 그 결정의 근거를 PR description 에 한 줄로 정리는.

✅ 요구사항

  1. 시그니처 — 두 가지 중 하나 선택
  • (A) 기존 CharacterVoiceService.converse(soulmateId, audio) 를 그대로 유지하면서 세션 ID 는 soulmateId 그대로 방식으로 가는 진행
  • (B) (soulmateId, conversationId, audio) 로 시그니처 확장. 세션 격리 단위가 외부 결정 으로 풀리는 진행
  • 선택의 근거를 PR description 에 한 줄"단일 사용자 학습용이라 (A) 를 선택, 운영급에선 (B) 가 정석" 같은 방식으로 박기
  1. ChatMemory — 5 단 파이프라인의 3 단째에 박힘
  2. STT 결과 텍스트 획득 (기존 그대로)
  3. chatMemory.get(conversationId, lastN) — 직전 N 개 메시지 가져오기 (예: N=10)
  4. Prompt(List.of(SystemMessage, ...history, currentUserMessage))과거 메시지 prepend + 현재 메시지 append 의 흐름
  5. ChatModel 응답 획득 (기존 그대로)
  6. chatMemory.add(conversationId, userMessage) + chatMemory.add(conversationId, assistantMessage) — 양쪽 모두 누적
  7. TTS 변환 (기존 그대로)
  8. JdbcChatMemoryRepository 사용 — InMemoryChatMemoryRepository 회귀 금지 (Day 5 이후 영속 저장 원칙)
  9. 새 ErrorCode 불필요 — 기존 위에 데코레이션 만 정리하는 부분라 새 V/VC 코드는 안 써도 OK
  10. 테스트 추가 (필수 2 케이스)
  • 메모리 누적 검증 — Mockito spy 또는 Mock 으로 ChatMemory.add(...)userMessage + assistantMessage 두 번 호출됨을 verify
  • 메모리 prepend 검증ArgumentCaptor<Prompt> 로 ChatModel 의 인자를 잡아서 Prompt 안에 history 메시지가 들어가 있음 을 검증

확인 방법

./run.sh up

# 1) 첫 대화 — 캐릭터한테 자기 이름 알려주기
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
     -F "audio=@./test-audio/introduction.mp3" \
     --output reply1.mp3
# (재생) 캐릭터가 자기소개 듣고 답함

# 2) 두 번째 대화 — "내 이름 뭐였더라?" 물어보기
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
     -F "audio=@./test-audio/question.mp3" \
     --output reply2.mp3
# (재생) 캐릭터가 *첫 대화에서 들은 이름* 을 그대로 답해야 정답
# (DB 의 SPRING_AI_CHAT_MEMORY 테이블에 두 대화의 4 개 메시지가 누적되어 있는지 함께 확인)

# 3) 단위 테스트
./gradlew test --tests "*CharacterVoiceServiceTest*memory*"

💡 힌트

  • Day 5 의 두 메서드가 핵심chatMemory.get(conversationId, lastN) / chatMemory.add(conversationId, message) 두. 빈 주입은 ChatMemory chatMemory 인터페이스 그대로.
  • conversationId 의 학습용 결 — 단일 사용자 시나리오면 soulmateId 그대로 도 OK. 문자열 형식 이 정석이면 "soulmate-" + soulmateId 같은 방식으로.
  • Prompt 조립 모양Prompt(List.of(systemMessage, ...history, userMessage))List 의 spread 가 자연스럽게 들어가요. historyList 형식이면 그대로.
  • 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 배라 가드가 진짜 필요한 모습이에요.

💡 왜 굳이 이 과제를 할까요?

  1. Day 7/8 가드 패턴의 세 번째 등장 — 같은 모양의 세 번째 등장 이라 손에 단단히 박히는. 90% 이상 코드 재사용이 가능해서 손은 빠른데,은 깊어지는 학습.

STT vs TTS 의 분리 결 — 두 호출의 비용 단가가 다르고 (OpenAI STT gpt-4o-transcribe 는 분당 $0.006, OpenAI TTS 는 문자당 과금), 호출 패턴도 다름 (STT 는 음성 길이 비례, TTS 는 텍스트 길이 비례).

한 카운터로 묶기 vs 분리 의 결정이 들어가요. 3. Clock 주입의 세 번째 등장 — Day 8 에서 정리한 자정 경계 검증 의. 같은 패턴이 음성에서도 살아 있는 그림.

✅ 요구사항

  1. VoiceDailyQuotaGuard 클래스 신규 생성@Component + Clock 주입 (Day 8 패턴 답습)
  2. STT 와 TTS 한도 분리
  • STT 일일 한도 — 예: 30 회 (@Value("${aifriends.voice.stt.daily-limit:30}"))
  • TTS 일일 한도 — 예: 50 회 (@Value("${aifriends.voice.tts.daily-limit:50}"))
  • 두에 별도 카운터 (Map<LocalDate, AtomicInteger> sttCounters, ttsCounters)
  1. 두 메서드 노출
  • checkAndIncrementStt() — STT 호출 직전에 박힘
  • checkAndIncrementTts() — TTS 호출 직전에 박힘
  1. 새 ErrorCode 추가VC008 VOICE_DAILY_QUOTA_EXCEEDED (status 429)
  • STT 와 TTS 메시지를 분리 할지 통합할지 도 학생 선택 (한 ErrorCode + 메시지 결 vs 두 ErrorCode 분리)
  1. 두 서비스에 가드 호출 박기 — 단, 기존 코드 수정 금지
  • 새 서비스 (VoiceTranscriptionGuardedService, VoiceSynthesisGuardedService 같은 방식) 를 만들어 기존 서비스를 위임 호출 + 가드 첫 줄에 박기. 또는 AOP @Aspect 로 정리하는 도 OK (학습 보너스).
  1. 테스트 추가 (필수 3 케이스)
  • STT 한도 30 회 채우기 → 31 번째 호출에서 VC008 차단
  • TTS 한도 50 회 채우기 → 51 번째 호출에서 VC008 차단
  • 자정 경계 검증Clock.fixed()23:59:59 의 카운터00:00:00 에 0 으로 리셋 됨을 verify

확인 방법

./run.sh up

# application.yml 의 한도를 1 로 낮추고 재기동
# (또는 .env 에 AIFRIENDS_VOICE_STT_DAILY_LIMIT=1 박기)

# 1) STT 첫 호출 — 정상
curl -X POST "http://localhost:8080/api/voice/transcribe" \
     -F "audio=@./test-audio/sample.mp3"
# 200 OK + transcribedText

# 2) STT 두 번째 호출 — 한도 초과 차단
curl -X POST "http://localhost:8080/api/voice/transcribe" \
     -F "audio=@./test-audio/sample.mp3"
# 429 + VC008

# 3) 단위 테스트
./gradlew test --tests "*VoiceDailyQuotaGuardTest*"

💡 힌트

  • Day 8 VisionDailyQuotaGuard 의 코드를 그대로 복사 — 패키지 / 클래스명 / 카운터 자료구조 / Clock 주입 / 자정 경계 로직까지 90% 이상 재사용 가능. 들어간 패턴의 반복 학습 자리이에요.
  • Map<LocalDate, AtomicInteger> 인메모리 결 — 학습용으론 충분. 운영급은 Redis INCR + EXPIRE 가 정석 (생각해볼 주제 3 의 결과 일치).
  • 자정 경계 검증Clock.fixed(Instant.parse("2026-04-30T23:59:59Z"), ZoneOffset.UTC) → 카운터 채움 → Clock.fixed(Instant.parse("2026-05-01T00:00:01Z"), ...) → 카운터 0 확인.
  • AOP 방식으로 정리하는 보너스@Aspect @Around("@annotation(VoiceQuotaGuarded)") 같은 방식으로 풀면 기존 서비스 코드 수정 0 으로 가드가 들어가요. 시간이 남으면 시도해볼만한.

제약 / 금지

  • 기존 세 서비스 코드 수정 금지VoiceTranscriptionService · VoiceSynthesisService · CharacterVoiceService 의 코드를 한 줄도 건드리지 마세요. 새 서비스 분리 또는 AOP 로 박기.
  • InMemoryChatMemoryRepository 같은 회귀 금지 — 이건 ChatMemory 지점이지만, 가드도 분산 환경 의식 의 감각은 살려두기.
  • 새 ErrorCode 는 VC 시리즈 — Voice 도메인의 코드는 VC008, VC009... 의 방식으로 이어가세요. V (Vision) 시리즈와 섞이지 않게.

예상 소요 시간

60~90 분. (난이도 ⭐⭐⭐ — Day 8 패턴 회수이라 코드 작성은 빠른데, STT/TTS 카운터 분리 + 자정 경계 검증의 두 시점 보존 이 한 단계 더 깊은 감각.)

[구현 3] 프로바이더 스위칭 — 로컬 무료 (whisper.cpp 또는 edge-tts) 시연 ⭐⭐⭐⭐ 🦙

배경 시나리오

PM 이 마지막으로 한 가지를 부탁해요.

"튜터님, 음성 데모는 외부 API 차단된 망 에서도 시연되어야 해요. 회사 보안 정책상 클라우드 API 호출이 막힌 사내망 에서 시연을 해야 한다든지, 전시회 부스 에서 인터넷 없이 시연한다든지. whisper.cpp 로컬 STT + edge-tts 로컬 TTS 조합으로 코드 0 줄 수정 한 채 .env 한 줄로 갈아끼울 수 있게 해주실 수 있나요? Day 8 에서 정리한 Ollama 시연 의 음성 버전이라고 보시면 돼요."

Day 8 의 Ollama 시연 과제 의 음성 버전이에요. 외부 API 0 원 / 완전 오프라인 으로 같은 5 단 파이프라인을 돌리는. 프로바이더 추상화의 마지막 재등장 — 음성 부분 의 첫 감각이에요. 🦙

💡 왜 굳이 이 과제를 할까요?

  1. 자매 추상화의 진짜 가치 증명 — Step 1 에서 정리한 TranscriptionModel / TextToSpeechModel 인터페이스가 진짜로 새 구현체로 갈리는. Spring AI 가 안 가져다 주는 단계 (whisper.cpp / edge-tts)내가 어댑터로 박을 수 있다 는 감각.
  2. 외부 프로세스 호출 진행ProcessBuilderCLI 프로세스를 호출 → 결과 파일 읽기 의 패턴은 MCP (Day 17~18) · Tool Calling (Day 11) 에서도 같은 가족이 등장해요. 본 과제가 그 방식의 첫.
  3. 로컬 운영의 현실적 그림자모델 풀 시간 (~5GB), 첫 호출의 모델 로딩 지연, GPU 가속의 차이, 명령어 인자의 보안 (커맨드 인젝션) 같은 방식이 실무 운영의 진짜 흐름 로 들어가요. Day 8 Ollama에서 한 번 본 모습의 심화 버전.

✅ 요구사항

  1. 최소 한 가지 — STT 또는 TTS 중 한 부분만 — 학습 시간을 고려해서 한 쪽만 정리하면 ⭐⭐⭐⭐. 둘 다 정리하면 ⭐⭐⭐⭐⭐ (보너스 부분).
  2. STT 로컬 어댑터 — WhisperCppTranscriptionModel implements TranscriptionModel
  • whisper.cpp 모델 풀 (예: ggml-medium-q5_0.bin 또는 ggml-base.bin)
  • ProcessBuilderwhisper-cli -m model.bin -f input.wav -otxt -of out 같은 방식으로 호출
  • 결과 텍스트 파일 (out.txt) 읽어서 TranscriptionResponse 로 변환
  1. TTS 로컬 어댑터 — EdgeTtsSpeechModel implements TextToSpeechModel
  • edge-tts Python CLI 호출 (pip install edge-tts명령행 도구로)
  • ProcessBuilderedge-tts --voice ko-KR-SunHiNeural --text "안녕" --write-media out.mp3 방식으로 호출
  • 결과 mp3 파일 읽어서 byte[] 또는 Resource 로 변환
  1. .env 의 프로바이더 스위칭 한 줄
  • SPRING_AI_VOICE_PROVIDER=local (또는 gemini / openai)
  • WHISPER_CPP_PATH=/usr/local/bin/whisper-cli
  • WHISPER_MODEL_PATH=/path/to/ggml-base.bin
  • EDGE_TTS_PATH=/usr/local/bin/edge-tts
  1. Spring Bean 등록 — @ConditionalOnProperty
  • @ConditionalOnProperty(prefix="spring.ai.voice", name="provider", havingValue="local") 로 스위칭
  • 디폴트 (Gemini/OpenAI) 빈은 havingValue="gemini" 또는 matchIfMissing=true
  1. 기존 코드 0 줄 수정CharacterVoiceService / 컨트롤러 / 다른 서비스의 Java 코드 한 줄도 안 건드림. 인터페이스 주입의 추상화 가치가 진짜로 살아있어야 본 과제의 본질.
  2. 시연 비교 노트 (선택, ⭐⭐⭐⭐⭐ 보너스) — Gemini/OpenAI 응답 vs 로컬 응답의 지연 + 음질 + 비용 차이를 1 페이지로 정리. 표 한 장 + 짧은 후기 두 단락.

확인 방법

# 0) 로컬 도구 설치 (호스트에서 한 번)
brew install whisper-cpp                    # Mac
# 또는 git clone https://github.com/ggerganov/whisper.cpp && make
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin

pip install edge-tts                        # Python 3.8+
edge-tts --list-voices | grep ko-KR         # 한국어 보이스 확인

# 1) 클라우드 모드 시연 — 디폴트
echo "SPRING_AI_VOICE_PROVIDER=gemini" > .env.demo
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
     -F "audio=@./test-audio/sample.mp3" --output reply-cloud.mp3
# (재생) Gemini/OpenAI 응답 — 자연스러운 음질, 응답 시간 5~10 초

# 2) 로컬 모드로 갈아끼움 — .env 한 줄 변경 + 재기동
./run.sh down
echo "SPRING_AI_VOICE_PROVIDER=local" > .env.demo
echo "WHISPER_MODEL_PATH=/path/to/ggml-base.bin" >> .env.demo
echo "EDGE_TTS_PATH=$(which edge-tts)" >> .env.demo
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
     -F "audio=@./test-audio/sample.mp3" --output reply-local.mp3
# (재생) 로컬 응답 — 음질 약간 떨어지지만 *외부 API 0 호출* / 완전 오프라인

# 3) 단위 테스트
./gradlew test --tests "*WhisperCppTranscriptionModelTest*"
./gradlew test --tests "*EdgeTtsTextToSpeechModelTest*"

💡 힌트

  • whisper.cpp 빠른 시작 — Mac 이면 brew install whisper-cpp 한 줄. 모델은 ggml-base.bin (~140MB) 이 학습용 디폴트. medium 이상은 ~1.5GB 라 첫 풀 시간 들어가요.
  • edge-tts 빠른 시작pip install edge-tts 한 줄. Microsoft Edge 의 Read Aloud 음성을 무료로 호출 하는. 한국어 보이스 수십 종 가능 (ko-KR-SunHiNeural 여성, ko-KR-InJoonNeural 남성 등).
  • ProcessBuilder 안전 사용 패턴new ProcessBuilder("edge-tts", "--voice", voice, "--text", text, "--write-media", outPath) 의 방식으로 명령어 인자를 List 로 분리. .command("edge-tts --voice " + voice + ...) 같이 문자열 합치기 방식은 절대 금지 (커맨드 인젝션).
  • 임시 파일 디렉토리Files.createTempFile("audio-", ".wav") / Files.createTempFile("tts-", ".mp3") 방식으로 임시 파일 박고, 호출 후 Files.delete(...) 로 정리.
  • @ConditionalOnProperty — Day 2 에서 손에 정리한 패턴 그대로. 세 곳 이상의 빈 (cloud / local / mock) 이 같은 인터페이스 위에서 갈리는 그림.

제약 / 금지

  • CharacterVoiceService / 컨트롤러 코드 0 줄 수정인터페이스 주입의 가치가 진짜로 살아있어야 본 과제의 본질. Java 코드 한 줄이라도 건드리면이 깨짐.
  • ProcessBuilder 의 명령어 인자에 사용자 입력 직접 박기 금지커맨드 인젝션 의 정통. 반드시 List 로 분리 해서 박으세요. 셸 escape 우회 시도 금지.
  • 모델 파일 경로 하드코딩 금지.env 또는 application.yml${WHISPER_MODEL_PATH} 같은 방식으로 외부화. 학생 머신마다 경로가 다른이라 하드코딩하면 재현성 0.
  • 새 프로바이더 어댑터 작성을 회피하지 마세요 — Spring AI 가 기본 제공하지 않는 부분라 학생이 직접 어댑터를 만들어보는 게 본 과제의 본질. 기존 OpenAI/Gemini 의 옵션만 갈아끼우는 진행 금지.

예상 소요 시간

120~180 분. (난이도 ⭐⭐⭐⭐ — 외부 도구 설치 + 어댑터 작성 + ProcessBuilder 안전 호출 의 3이 한 곳에 모임. 한 번 익숙해지면 MCP / Tool Calling 까지 펼쳐지는 평생 자산.)

제출 방법

  • 세 과제는 ai-friends 프로젝트에서 각각 별도 브랜치 로 작업 권장.
  • 과제 1: day09-assignment1-chat-memory
  • 과제 2: day09-assignment2-quota-guard
  • 과제 3: day09-assignment3-local-voice
  • PR 에 (1) 음성 대화 시연 영상 또는 audio 파일 (reply1.mp3 + reply2.mp3 의 기억 회수 모습) 또는 (2) 가드 발동 테스트 통과 로그 + 자정 경계 검증 그린 캡처 또는 (3) 클라우드 vs 로컬 응답의 지연/음질/비용 비교 노트 를 첨부하면 리뷰가 훨씬 빨라집니다.

생각해볼 주제

이 섹션의 목적 — 정답이 정해져 있지 않은 질문들이에요. 오늘 배운 세 자매 추상화 · 5 단 파이프라인 · 음성 30~50 배 비용 · binary 응답 의 결정들을 한 발 떨어져 바라보고, "왜 이렇게 결정했지?""다른 길은 없었나?" 를 사고하는 모습이에요. 면접에서도 자주 등장하는 토픽들이라, 가능하면 문장으로 적어보세요. 머릿속 생각과 글로 적은 생각은 다릅니다.

주제 1 — 음성 + 캐릭터 페르소나의 감정 톤 — TTS 보이스 선택

Step 7 에서 우리는 캐릭터의 페르소나를 SystemMessage 의 자연어 로 정리했어요.

이름 / 성격 / 취미 / 말투. 그런데 — 음성으로 답하는 모습에서 가장 큰 체감 페르소나목소리 그 자체 예요.

같은 답변 텍스트라도 남자 저음 vs 여자 고음 vs 어린아이 톤 이 결정적으로 다른 캐릭터를 만들어요.

사용자가 "이 캐릭터는 분홍 토끼. 발랄하고 어린 흐름" 이라고 머릿속에 그린 페르소나가 — 낮은 남자 보이스 로 답이 흘러나오면 순간 캐릭터가 박살 나요.

OpenAI TTS 의 voice 옵션은 6 개 (alloy / echo / fable / onyx / nova / shimmer) 가 있고, edge-tts 는 언어별 수십 개 voice 가 있어요. 한국어만 해도 ko-KR-SunHiNeural (여성) / ko-KR-InJoonNeural (남성) / ko-KR-BongJinNeural (어린 톤) 등 여러. 그럼 — 각 캐릭터마다 어떤 voice 를 매핑할지 의 결정은 어디서 어떻게 정리해야 할까요?

가장 단순 한 방식은 — Soulmate.voiceModel 컬럼에 문자열로 정리해두기. 캐릭터 생성 시 입력 폼 에서 사용자가 직접 고르거나, 기본값 으로 박힘. 다음 단 은. VoiceProfileMapper 라는 별도 빈 으로 분리해서 캐릭터 성격 → voice 자동 매핑. ("발랄"BongJinNeural 어린 톤, "차분"SunHiNeural 여성 등).

그 다음LLM 자체가 voice 도 추천 하는 진행 — 캐릭터 생성 시 "이 캐릭터에 어울리는 보이스는?" 까지 LLM 에게 물어보는 그림.

각 결마다 유연성 / 운영 부담 / 캐릭터 일관성 의 트레이드오프가 다 달라요. 그리고 — voice 를 너무 자주 바꾸면 사용자에게 같은 캐릭터로 안 들리는 일이 생겨요. 모바일 게임의 성우 캐스팅 과 같은 무게가 AI 보이스 매핑 에서도 그대로 적용돼요.

🎯 핵심 질문 — 음성 페르소나 (voice) 를 캐릭터 정보에 정리하는 부분은 어디인가? Soulmate.voiceModel 컬럼? VoiceProfileMapper 라는 별도 빈? 또는 LLM 자체에게 voice 추천을 맡기는 진행? 그리고 voice 를 너무 자주 바꾸면 사용자에게 같은 캐릭터로 안 들리는 일 은 어떻게 막을 것인가? 한 번 들어간 voice 의 변경 가능성 을 어떻게 통제할 것인가?

생각해볼 자료:

  • Step 5 의 TextToSpeechPrompt(text)디폴트 보이스 만 사용. 옵션 (OpenAiAudioSpeechOptions.builder().voice(...)) 를 어떻게 박을지의 결정.
  • Day 7 portrait 의 — 그림이 캐릭터 정체성 이었던. 음성은 더 강한 정체성목소리 한 번 들으면 잊히지 않는 인지 무게.
  • 모바일 게임의 캐릭터 보이스 결정 트리 — 성우 캐스팅이 프로젝트 후반에 바뀌면 캐릭터가 죽는다 는 업계 격언. AI 보이스 매핑 도 같은 가족.

주제 2 — PII (개인식별정보) 가 음성에 들어가 있을 때 — 로깅과 저장

오늘 우리는 사용자 마이크 음성을 백엔드에 업로드 → STT → 로그 의을 정리했어요. Step 4 의 log.info("...") 한 줄에는 변환된 텍스트 그대로 가 박힐 수 있어요.

그런데 — 음성은 텍스트보다 PII 가 더 진하게 들어가요. 사용자가 자기 이름 / 주소 / 전화번호 / 카드 번호 / 주민등록번호 를 음성으로 말하면, STT 가 그걸 텍스트로 변환 해서 로그에 박히고 → ELK 로 흘러가고 → S3 백업으로 영구 저장 되는 자리이 있을 수 있어요. 그리고. 목소리 자체 도 PII 예요. 음성 지문 (voiceprint) 으로 화자를 식별할 수 있는 진행이라, GDPR / 개인정보보호법은 음성을 생체정보로 분류 해요.

업계의 정석은 다섯 가지 가드 가 있어요.

  • PII 마스킹 — 이름/번호 패턴을 정규식 또는 NER 로 마스킹. 예: "전화번호 010-XXXX-1234 입니다""전화번호 010-****-**** 입니다"
  • 오디오 자체 미저장 — 메모리 → STT → 즉시 폐기, 디스크에 떨어뜨리지 않기
  • 짧은 보존 기간 — S3 lifecycle 로 24~72 시간 후 자동 삭제
  • 암호화 — S3 SSE-KMS + 전송 구간 TLS
  • 동의 절차 — 음성 업로드 시 명시적 동의 체크박스

본 강의 코드는 어디까지 비어 있을까요? VoiceTranscriptionService.transcribe(...)변환된 텍스트를 그대로 응답 만 하고 DB 에 저장하지 않아요. 로깅도 길이만 박고 본문은 안 박는 방식으로 갈 여지.

그런데 — 학생들이 직접 박을 흐름 이 있어요. log.info("transcribed: {}", text) 한 줄을 무심코 정리하면. PII 가 로그에 그대로 흘러요.

🎯 핵심 질문 — 사용자 음성에 주민등록번호 가 들어가 있고, 우리 백엔드가 그걸 STT 로 텍스트화 해서 로그 에 들어갔다고 가정. 운영에서 이 방식을 막는 결정 트리는? PII 마스킹 / 오디오 미저장 / 보존 기간 제한 / 암호화 / 동의 절차 중 어느이 1 순위이고, 본 강의 코드는 어디까지 비어 있는가, 어디서부터 학생이 직접 정리해야 하는가? 그리고 목소리 지문 (voiceprint) 도 PII 라는 진행은 우리 코드의 어느에 영향 을 주는가?

생각해볼 자료:

  • Day 8 의 URL 기반 Vision SSRF 주제 — 보안 결정 트리이 음성에서도 그대로 살아있는 모양. 다른 모달리티, 같은.
  • GDPR / 개인정보보호법 — 음성 지문생체정보 로 분류한 결정. 보관 / 삭제 / 동의이 일반 PII 보다 한 단 더 깐깐.
  • Spring Boot 인스타그램 클론의 마스킹 패턴 — 같은 방식을 음성 에서 어떻게 적용할지. @JsonIgnore / Logback Pattern Layout / Spring AI Observability redactor 같은 자리들.

주제 3 — 5 단 파이프라인의 스트리밍 가능성 — 사용자 체감 지연 줄이기

오늘 우리는 5 단 파이프라인을 동기 + 일괄 방식으로 정리했어요.

사용자가 마이크에 1 분 말하면 — STT 5 초 + ChatModel 3 초 + TTS 4 초 = 총 12 초 후에야 첫 음성이 흘러나와요.

사용자 입장에선 답답한 침묵의 12 초. 진짜 사람과 대화하는에서 12 초 침묵은 대화의 죽음 지점이에요.

스트리밍 가능성은 몇 단 으로 펼쳐져요. 첫 단 은 — StreamingTextToSpeechModel (Spring AI 1.1.x 에 있음, Step 6 에서 한 줄 언급) 로 TTS 를 토큰 단위 스트리밍. 응답 전체가 완성되기 전에 문장 단위로 흘러나오는. 두 번째 단 은. Day 6 의 ChatModel 토큰 단위 응답 (Flux)문장 끝마다 (마침표 / 쉼표 단위) TTS 에 흘려보내는. 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 에서 구축한 JdbcChatMemoryRepository5 단 파이프라인의 3 단째얹는 과제입니다.

Step 7 에서 의도된 미적용으로 미뤄둔 회수의 시점. 🌱

핵심은 — ChatClient 는 Day 11 부터 이므로 본 과제에선 ChatModel 인터페이스 + 수동 ChatMemory.add / chatMemory.get 만 써서 풀어요.

직접 한 번 더 짜봐야 ChatClient 의 MessageChatMemoryAdvisor 한 줄의 추상화 가치가 다음 Day 에 더 진하게 들어와요.

2. 채점 포인트 표

# 항목 가중치 핵심
1 새 서비스 분리 (CharacterVoiceMemoryService) — 기존 CharacterVoiceService 0 줄 수정 중간 합류 학생의 기준 코드 보호 — 기존 파일 한 줄이라도 수정 시 감점
2 ChatMemory 빈 주입 (인터페이스 그대로, 구현체 직접 X) JdbcChatMemoryRepository 를 직접 주입하면 Day 5 이후 영속 저장 원칙 위반 — Spring AI 자동 등록 빈 (ChatMemory) 그대로
3 chatMemory.get(conversationId, lastN) → Prompt 의 history 위치에 prepend 가장 흔한 누락 — chatMemory.get(...) 만 호출하고 Prompt 에 안 넣는 사고
4 chatMemory.add(...)userMessage + assistantMessage 두 번 assistant 만 박고 user 를 빠뜨리거나, 그 반대 — 둘 다 누적되어야 다음 턴 prepend 가 자연스러움
5 conversationId 결정 근거 (PR description 한 줄) (A) soulmateId 단순 / (B) userId + ":" + soulmateId 격리 — 둘 중 근거가 박혀 있으면 OK
6 ChatModel 인터페이스 주입 OpenAiChatModel 직접 주입 시 감점 — Day 2 프로바이더 추상화 원칙
7 ResponseEntity<ApiResponse<...>> 응답 또는 binary byte[] 응답 — ApiResponse 표준 패턴 새 컨트롤러를 만들었다면 ApiResponse 래핑 또는 binary 예외 절 그대로
8 테스트 — ArgumentCaptor<Prompt> 로 history prepend 검증 단순 200 응답 검증만 하면 진짜로 history 가 들어갔는지 알 수 없음 — captor 가 필수
9 테스트 — chatMemory.add(...) 가 user/assistant 두 번 verify Mockito spy 또는 Mock 으로 verify(chatMemory, times(2)).add(...)

배점 비율은 상 60% / 중 25% / 하 15%. 8·9 번이 진짜 손맛 의 포인트예요. DB 에 메시지가 저장됐는가 가 아니라 Prompt 의 instructions 안에 history 가 prepend 되었는가 가 검증의 핵심입니다.

3. 권장 구현

3-1. 새 서비스 — CharacterVoiceMemoryService

@Service
@RequiredArgsConstructor
public class CharacterVoiceMemoryService {

    private final VoiceTranscriptionService voiceTranscriptionService;
    private final VoiceSynthesisService voiceSynthesisService;
    private final ChatModel chatModel;          // ★ 인터페이스 주입 (Day 2 프로바이더 추상화 원칙)
    private final ChatMemory chatMemory;        // ★ Spring AI 자동 등록 빈 (JdbcChatMemoryRepository 기반)
    private final SoulmateRepository soulmateRepository;

    private static final int LAST_N = 10;       // 직전 10 개 메시지 회상

    public byte[] converseWithMemory(Long soulmateId, MultipartFile audio) {
        // 1) STT — 사용자 음성 → 텍스트
        String userText = voiceTranscriptionService.transcribe(audio);

        // 2) 캐릭터 조회 + 페르소나 SystemMessage
        Soulmate soulmate = soulmateRepository.findById(soulmateId)
                .orElseThrow(() -> new VoiceException(ErrorCode.SOULMATE_NOT_FOUND));
        SystemMessage systemMessage = new SystemMessage(buildPersonaPrompt(soulmate));

        // 3) ★ ChatMemory 회상 — 직전 N 개 메시지
        String conversationId = "soulmate-" + soulmateId;   // 학습용 단일 사용자 가정
        List<Message> history = chatMemory.get(conversationId, LAST_N);

        // 4) Prompt 조립 — System + ...history + 현재 User
        UserMessage currentUser = new UserMessage(userText);
        List<Message> messages = new ArrayList<>();
        messages.add(systemMessage);
        messages.addAll(history);                // ★ history prepend
        messages.add(currentUser);

        // 5) ChatModel 호출
        ChatResponse response = chatModel.call(new Prompt(messages));
        String assistantText = response.getResult().getOutput().getText();

        // 6) ★ ChatMemory 누적 — user + assistant 두 번
        chatMemory.add(conversationId, currentUser);
        chatMemory.add(conversationId, new AssistantMessage(assistantText));

        // 7) TTS — 텍스트 → 음성 binary
        return voiceSynthesisService.synthesize(assistantText);
    }

    private String buildPersonaPrompt(Soulmate soulmate) {
        return """
            너는 %s 야. 성격은 %s 이고, 말투는 %s.
            사용자와 자연스럽게 대화해.
            """.formatted(soulmate.getName(), soulmate.getPersonality(), soulmate.getSpeechStyle());
    }
}

💡 학생이 직접 짜야 할 자리 — 컨트롤러 작성 (/api/voice/characters/{soulmateId}/converse-memory 같은 경로, binary 응답이라 byte[] raw 허용 —. 교안의 ApiResponse 표준 패턴의 binary 예외 절), Soulmate 엔티티의 personality / speechStyle 필드가 없다면 고정 페르소나 문자열 로 대체, LAST_N 의 적정선 (10~20).

3-2. conversationId 결정의 두 갈래 — PR description 에 한 줄

## conversationId 결정

(A) `"soulmate-" + soulmateId` — 단일 사용자 학습용으로 채택.
(B) `userId + ":" + soulmateId` — 운영급에선 정석 (멀티유저 격리).

본 과제는 학습용 단일 사용자 가정이라 (A) 를 선택. 운영 시점에서
SecurityContext 의 `principal.id` 를 prefix 로 박는 방식으로 마이그레이션
가능 — 시그니처에 conversationId 외부 주입 자리가 박혀 있으면 더 유연.

3-3. 검증 포인트 — 두 자리의 핵심

채점 시 Prompt 자루의 모양DB 누적 횟수 두 자리를 다음 두 약속으로 본다.

  • history 가 Prompt 에 prepend 되는가 — STT 결과 + chatMemory.get(...) 가 돌려준 직전 메시지 2 개를 stub 으로 박아두고, chatModel.call(...) 에 들어간 Prompt 의 instructions 가 4 자리 (System + history 2 + currentUser 1) 인지.
    • 두 번째 메시지의 텍스트에 전 대화 사용자 발화 가, 네 번째 메시지에 현재 사용자 발화 가 들어 있는지.
    • 단순 200 응답 검증만 하면 DB 에는 박혔는데 모델 입력에는 안 들어간 침묵의 사고 가 잡히지 않아요. Prompt 자루 안 까지 들여다보는 단계가 필수.
  • chatMemory.add(...) 가 user + assistant 두 번 호출되는가` — 누적 횟수 단언 + 잡힌 메시지의 타입이 UserMessage → AssistantMessage 순서 인지. assistant 만 박는 사고 가 가장 흔한데, 그러면 다음 턴 prepend 시 사용자 발화가 비어 있어 모델이 어색한 흐름으로 답해요.

4. 흔한 실수

  • chatMemory.get(...) 만 호출하고 Prompt 에 안 박음 — 가장 흔한 사고. messages.addAll(history) 한 줄을 빠뜨리면 DB 에 메시지는 박히는데 모델 입력엔 안 들어가는 침묵의 사고. 어제 대화를 모델은 모른 채로 응답함.
  • chatMemory.add(...) 를 assistant 만 박음 — user 를 빠뜨리면 다음 턴 prepend 시 사용자 발화가 없어서 모델이 어색한 흐름 으로 답해요. user / assistant 둘 다 누적이 정석.
  • InMemoryChatMemoryRepository 회귀 — 본 강의 Day 5 이후 영속 저장 원칙 위반. DB 영속성 이 살아 있어야 어제 대화를 오늘 기억하는 풍경이 진짜로 박힙니다. 테스트 코드는 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() 두 메서드 이름이 명확해야 호출부가 헷갈리지 않음
4 ErrorCode VC008 VOICE_DAILY_QUOTA_EXCEEDED (status 429) VC 시리즈 일관성 — V (Vision) 시리즈와 섞이지 않게
5 한도 외부 주입 — @Value("${aifriends.voice.stt.daily-limit:30}") 하드코딩 시 환경별 조정 불가 — 외부 주입이 정석
6 새 서비스 분리 (위임 호출) 또는 AOP — 기존 두 서비스 0 줄 수정 VoiceTranscriptionService 본체에 박으면 중간 합류 학생 코드 흐트러짐
7 테스트 — STT 한도 초과 차단 (한도 = 1 로 낮춰서 검증) @Value 주입 시 ReflectionTestUtils.setField(...) 또는 @TestPropertySource 활용
8 테스트 — TTS 한도 초과 차단 STT/TTS 카운터가 진짜로 분리되어 있는가 의 검증
9 테스트 — Clock.fixed() 자정 경계 리셋 23:59:59 한도 채움 → 00:00:01 카운터 0 — 두 시점 검증이 핵심
10 AtomicInteger (동시성) int raw 사용 시 동시성 사고 — 한도 30 이지만 실제 35 통과 같은 풍경

배점 비율은 상 65% / 중 20% / 하 15%. 1·2·9 번이 진짜 손맛의 핵심이에요.

3. 권장 구현

3-1. VoiceDailyQuotaGuard — Day 8 패턴 답습

@Component
@Slf4j
public class VoiceDailyQuotaGuard {

    private final Clock clock;
    private final int sttDailyLimit;
    private final int ttsDailyLimit;

    private final Map<LocalDate, AtomicInteger> sttCounters = new ConcurrentHashMap<>();
    private final Map<LocalDate, AtomicInteger> ttsCounters = new ConcurrentHashMap<>();

    public VoiceDailyQuotaGuard(
            Clock clock,
            @Value("${aifriends.voice.stt.daily-limit:30}") int sttDailyLimit,
            @Value("${aifriends.voice.tts.daily-limit:50}") int ttsDailyLimit) {
        this.clock = clock;
        this.sttDailyLimit = sttDailyLimit;
        this.ttsDailyLimit = ttsDailyLimit;
    }

    public void checkAndIncrementStt() {
        LocalDate today = LocalDate.now(clock);
        AtomicInteger counter = sttCounters.computeIfAbsent(today, k -> new AtomicInteger(0));

        int next = counter.incrementAndGet();
        if (next > sttDailyLimit) {
            counter.decrementAndGet();   // 카운트 롤백
            log.warn("[VoiceQuota] STT 일일 한도 초과 — date={}, limit={}", today, sttDailyLimit);
            throw new VoiceException(ErrorCode.VOICE_DAILY_QUOTA_EXCEEDED);
        }
        log.debug("[VoiceQuota] STT 호출 — date={}, count={}/{}", today, next, sttDailyLimit);
    }

    public void checkAndIncrementTts() {
        LocalDate today = LocalDate.now(clock);
        AtomicInteger counter = ttsCounters.computeIfAbsent(today, k -> new AtomicInteger(0));

        int next = counter.incrementAndGet();
        if (next > ttsDailyLimit) {
            counter.decrementAndGet();
            log.warn("[VoiceQuota] TTS 일일 한도 초과 — date={}, limit={}", today, ttsDailyLimit);
            throw new VoiceException(ErrorCode.VOICE_DAILY_QUOTA_EXCEEDED);
        }
        log.debug("[VoiceQuota] TTS 호출 — date={}, count={}/{}", today, next, ttsDailyLimit);
    }
}

💡 카운트 롤백 — 한도 초과 시 counter.decrementAndGet() 으로 되돌려야 한도 = 30 인데 실제 카운터가 31 까지 올라가는 사고를 막아요. Day 8 패턴 답습.

3-2. ErrorCode 추가

// ErrorCode.java
VOICE_DAILY_QUOTA_EXCEEDED("VC008", "음성 일일 한도를 초과했습니다.", HttpStatus.TOO_MANY_REQUESTS),

💡 STT/TTS 메시지 분리도 OK 입니다 — VC008 STT 일일 한도 초과 / VC009 TTS 일일 한도 초과. 운영 모니터링 대시보드에서 어느 자리가 막혔는지 가 한눈에 보임. 학습용은 하나로 통합도 충분.

3-3. 새 서비스 — 위임 + 가드 첫 줄

@Service
@RequiredArgsConstructor
public class VoiceTranscriptionGuardedService {

    private final VoiceDailyQuotaGuard quotaGuard;
    private final VoiceTranscriptionService delegate;

    public String transcribe(MultipartFile audio) {
        quotaGuard.checkAndIncrementStt();   // ★ 첫 줄
        return delegate.transcribe(audio);
    }
}

@Service
@RequiredArgsConstructor
public class VoiceSynthesisGuardedService {

    private final VoiceDailyQuotaGuard quotaGuard;
    private final VoiceSynthesisService delegate;

    public byte[] synthesize(String text) {
        quotaGuard.checkAndIncrementTts();   // ★ 첫 줄
        return delegate.synthesize(text);
    }
}

3-4. 검증 포인트 — 자정 경계 + 두 카운터 분리

채점 시 Clock 주입 덕분에 검증할 수 있는 두 시점을 본다.

  • 자정 경계에서 카운터 리셋Clock.fixed(2026-04-30T23:59:59Z) 로 한도를 채워 한 번 차단된 뒤 (예외 + VOICE_DAILY_QUOTA_EXCEEDED), Clock 빈을 2026-05-01T00:00:01Z 로 교체 (Spring TestContext / ReflectionTestUtils.setField) → 새 LocalDate카운터가 0 부터 다시 흐르는지.
    • LocalDate.now() 직접 호출 로 짠 코드는 이 검증이 불가능해요.
    • Clock 주입의 진짜 가치가 경계 두 시점에서 가드의 동작이 정확히 갈리는지 를 보는 데서 살아나요.
  • STT/TTS 카운터가 별개 — STT 한도를 채워 차단된 직후에도 TTS 카운터는 0 이라 통과해야. 한 Map 으로 묶어 짠 코드 는 이 부분에서 깨져요. 두 자루로 분리한 결정 이 진짜로 들어갔는지의 직접 증명.

4. 흔한 실수

  • LocalDate.now() 직접 호출 — Day 8 의 패턴 무시. 자정 경계 검증 자체가 불가능해져요. Clock 빈 주입 → LocalDate.now(clock) 으로.
  • STT/TTS 카운터를 한 Map 으로 묶음Map<LocalDate, AtomicInteger> dailyCounters 하나만 쓰면 상호 간섭. STT 가 30 회 채우면 TTS 도 동시에 막힘. 두 필드로 분리가 정석.
  • 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 에서 자세히).

과제 3 풀이 — 로컬 무료 어댑터 (`whisper.cpp` / `edge-tts`) (난이도 ⭐⭐⭐⭐ 🦙)

1. 시나리오 회상

PM 이 "외부 API 차단된 망에서도 시연되어야 한다" 를 들고 왔어요. Day 8 의 Ollama 시연 과제 의 음성 버전. 외부 API 0 원 / 완전 오프라인 으로 같은 5 단 파이프라인을 돌리는 풍경. 프로바이더 추상화의 마지막 회수 — 음성 모달리티 의 첫 손맛이에요. 🦙

핵심은 — Java 코드 0 줄 변경 + .env 한 줄 + ProcessBuilder 안전 List 분리 의 세 축. 인터페이스 주입의 추상화 가치가 진짜로 살아있어야 본 과제의 본질이에요. 기존 CharacterVoiceService / 컨트롤러 한 줄이라도 수정 시 본질이 깨져요.

2. 채점 포인트 표

# 항목 가중치 핵심
1 Java 코드 0 줄 변경 (기존 두 서비스 + 컨트롤러) 인터페이스 주입의 가치 증명 — 한 줄이라도 수정 시 본 과제 본질 깨짐 → 0 점
2 WhisperCppTranscriptionModel implements TranscriptionModel 또는 EdgeTtsTextToSpeechModel implements TextToSpeechModel Spring AI 인터페이스를 직접 구현 — 새 어댑터 작성의 본질
3 @ConditionalOnProperty 로 빈 등록 분기 (prefix="spring.ai.voice", name="provider", havingValue="local") Day 2 손맛 답습 — matchIfMissing=true 로 디폴트 (Cloud) 빈 자동 등록
4 ProcessBuilder명령어 인자 List 분리 문자열 합치기 결은 커맨드 인젝션 — new ProcessBuilder("edge-tts", "--text", text) 처럼 List
5 Process.waitFor(timeout) + 임시 파일 정리 timeout 없으면 영구 hang 위험. Files.delete(...)try-finally 로 정리
6 모델 / 바이너리 경로 외부화 — @Value("${aifriends.voice.whisper.model-path}") 학생 머신마다 경로가 다름 — 하드코딩 시 재현성 0
7 .env 한 줄로 풀림 — SPRING_AI_VOICE_PROVIDER=local 도커 재빌드 / Gradle 재컴파일 / 코드 수정 — 모두 불필요
8 테스트 — MockedConstruction<ProcessBuilder> 또는 고정 입력 → 고정 출력 검증 실제 CLI 호출 없이도 어댑터 흐름 만 검증
9 시연 비교 노트 (보너스 ⭐⭐⭐⭐⭐) — Cloud vs Local 의 지연 / 음질 / 비용 표 트레이드오프 매트릭스가 박혀 있으면 만점

배점 비율은 상 70% / 중 20% / 하 10%. 1·2·4 번이 생사의 자리 예요. 기존 코드 0 줄 수정 의 룰 + ProcessBuilder 안전 호출 두 축이 무너지면 본 과제 본질이 깨져요.

3. 권장 구현 — TTS 측 (EdgeTtsTextToSpeechModel)

학습 시간을 고려해 TTS 한 쪽만 박는 풀이로 시연. STT (WhisperCppTranscriptionModel) 도 같은 방식으로 펼쳐져요.

3-1. 어댑터 클래스

package kr.spartaclub.aifriends.voice.local;

import org.springframework.ai.audio.tts.TextToSpeechModel;
import org.springframework.ai.audio.tts.TextToSpeechPrompt;
import org.springframework.ai.audio.tts.TextToSpeechResponse;
// ... 기타 import

public class EdgeTtsTextToSpeechModel implements TextToSpeechModel {

    private final String edgeTtsPath;
    private final String voice;

    public EdgeTtsTextToSpeechModel(String edgeTtsPath, String voice) {
        this.edgeTtsPath = edgeTtsPath;
        this.voice = voice;
    }

    @Override
    public TextToSpeechResponse call(TextToSpeechPrompt prompt) {
        String text = prompt.getInstructions();
        Path outFile = null;

        try {
            outFile = Files.createTempFile("tts-", ".mp3");

            // ★ 명령어 인자 List 로 분리 — 커맨드 인젝션 방어
            ProcessBuilder pb = new ProcessBuilder(
                    edgeTtsPath,
                    "--voice", voice,
                    "--text", text,
                    "--write-media", outFile.toAbsolutePath().toString()
            );
            pb.redirectErrorStream(true);

            Process process = pb.start();
            boolean finished = process.waitFor(30, TimeUnit.SECONDS);

            if (!finished) {
                process.destroyForcibly();
                throw new VoiceException(ErrorCode.VOICE_LOCAL_PROVIDER_FAILED);
            }

            if (process.exitValue() != 0) {
                throw new VoiceException(ErrorCode.VOICE_LOCAL_PROVIDER_FAILED);
            }

            byte[] audioBytes = Files.readAllBytes(outFile);
            return new TextToSpeechResponse(/* audioBytes 를 응답 형태로 래핑 */);

        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
            throw new VoiceException(ErrorCode.VOICE_LOCAL_PROVIDER_FAILED, e);
        } finally {
            // ★ 임시 파일 정리
            if (outFile != null) {
                try { Files.deleteIfExists(outFile); }
                catch (IOException ignore) { /* 로깅만 */ }
            }
        }
    }
}

💡 TextToSpeechResponse 의 정확한 시그니처는 Spring AI 1.1.x 의 org.springframework.ai.audio.tts 패키지를 참조 — 본 답안은 흐름 만 박았어요. 실제 구현 시 IDE 자동완성으로 정확한 타입을 잡으면 OK.

3-2. @ConditionalOnProperty 로 빈 등록 분기

@Configuration
public class LocalVoiceConfig {

    @Bean
    @ConditionalOnProperty(prefix = "spring.ai.voice", name = "provider", havingValue = "local")
    public TextToSpeechModel localTextToSpeechModel(
            @Value("${aifriends.voice.edge-tts.path}") String edgeTtsPath,
            @Value("${aifriends.voice.edge-tts.voice:ko-KR-SunHiNeural}") String voice) {
        return new EdgeTtsTextToSpeechModel(edgeTtsPath, voice);
    }

    @Bean
    @ConditionalOnProperty(prefix = "spring.ai.voice", name = "provider", havingValue = "local")
    public TranscriptionModel localTranscriptionModel(
            @Value("${aifriends.voice.whisper.path}") String whisperPath,
            @Value("${aifriends.voice.whisper.model-path}") String modelPath) {
        return new WhisperCppTranscriptionModel(whisperPath, modelPath);
    }
}

💡 디폴트 (Gemini/OpenAI) 빈은 Spring AI 의 자동 설정이 그대로 처리해요. @ConditionalOnProperty(matchIfMissing=true)프로바이더 미지정 시 자동 등록. 본 어댑터는 명시적 provider=local 일 때만 등록되고, 그러면 자동 등록 빈과 우리 빈이 같은 인터페이스를 두 개 갖게 되는 사고를 피하기 위해 Cloud 빈을 disable 해야 — 보통 @ConditionalOnProperty(havingValue="cloud", matchIfMissing=true) 처럼 반대 조건 을 박아 둘 중 하나만 등록되게 합니다.

3-3. .env 한 줄

# Day 9 과제 3 — 로컬 음성 모드 스위치
SPRING_AI_VOICE_PROVIDER=local
AIFRIENDS_VOICE_EDGE_TTS_PATH=/usr/local/bin/edge-tts
AIFRIENDS_VOICE_EDGE_TTS_VOICE=ko-KR-SunHiNeural
AIFRIENDS_VOICE_WHISPER_PATH=/usr/local/bin/whisper-cli
AIFRIENDS_VOICE_WHISPER_MODEL_PATH=/path/to/ggml-base.bin

3-4. 시연 흐름 — ./run.sh 한 번

# 0) 호스트에서 한 번 (학습용은 Mac 디폴트)
brew install whisper-cpp
pip install edge-tts
wget https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin

# 1) Cloud 모드 (디폴트) 응답 캡처
./run.sh down
cp .env.cloud .env             # SPRING_AI_VOICE_PROVIDER=gemini
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
     -F "audio=@./test-audio/sample.mp3" --output reply-cloud.mp3

# 2) 로컬 모드로 갈아끼움 — Java 코드 0 줄 변경
./run.sh down
cp .env.local .env              # SPRING_AI_VOICE_PROVIDER=local
./run.sh up
curl -X POST "http://localhost:8080/api/voice/characters/1/converse" \
     -F "audio=@./test-audio/sample.mp3" --output reply-local.mp3

4. 흔한 실수

  • CharacterVoiceService / 컨트롤러 한 줄이라도 수정 — 가장 큰 함정. 어댑터 분기 로직 을 서비스에 박으면 추상화의 본질이 깨짐. @ConditionalOnProperty + 빈 두 개로 풀려야.
  • ProcessBuilder("edge-tts --voice " + voice + ...") 문자열 합치기커맨드 인젝션 의 정통 사고. 사용자가 text"; rm -rf /" 박으면 서버 디스크 날아감. new ProcessBuilder("edge-tts", "--voice", voice, ...) 처럼 List 로 분리 가 정석.
  • Process.waitFor() 에 timeout 없음 — 외부 CLI 가 영구 hang 하면 우리 스레드도 함께 영구 대기. waitFor(30, TimeUnit.SECONDS)상한 박기 + destroyForcibly() 로 정리.
  • 임시 파일 정리 빠뜨림Files.createTempFile(...) 박고 Files.deleteIfExists(...) 안 부르면 디스크가 점진적으로 차오름. try-finally반드시 정리.
  • 모델 파일 경로 하드코딩"/Users/sumin/whisper/ggml-base.bin" 처럼 박기. 학생 머신마다 경로가 다름 — @Value("${aifriends.voice.whisper.model-path}") 로 외부화.
  • WHISPER_BASE_URL — 외부 데몬으로 착각 — whisper.cpp 는 데몬이 아니라 CLI 도구. http://localhost:11434 처럼 박으면 동작 안 함. 바이너리 경로 + 모델 파일 경로 두 가지.
  • ggml-medium-q5_0.bin (1.5GB) 부터 시도 — 학습용은 ggml-base.bin (~140MB) 이 충분. 큰 모델은 첫 풀 시간 + 첫 호출 로딩 시간 모두 길어요. 학습 호흡 끊김.

5. 실무 개선 포인트 (심화)

  • CLI 호출 → Native Java 라이브러리 마이그레이션ProcessBuilder 결은 프로세스 fork 비용 + 임시 파일 I/O 가 있어요. 운영급은 whisper-jni (whisper.cpp 의 JNI 바인딩) 또는 Vosk (Java native STT) 로 프로세스 호출 없이 직접 호출. 응답 시간 5~10 배 단축. 단 의존성 무게가 늘어남.
  • Cloud → Local Fallback 전략 — 운영 시점에 Cloud 빈을 1 순위, Local 빈을 2 순위 fallback 으로 박기. @Primary + @Order + 커스텀 FallbackTextToSpeechModel 래퍼. Cloud 한도 초과 / 장애 시 자동으로 Local 로 흘러가는 풍경. Day 19 Harness 의 Resilience4j Fallback 복선.
  • 모델 warm-up 자동화 — 앱 기동 시 백그라운드로 whisper.cpp / edge-tts 한 번 호출 흘려보내서 첫 사용자의 timeout 경험 제거. @EventListener(ApplicationReadyEvent) 로 박기.

생각해볼 주제 1 — 음성 + 캐릭터 페르소나의 **감정 톤** — TTS 보이스 매핑

[문제 상황 요약]

Step 7 에서 우리는 캐릭터의 페르소나를 SystemMessage 의 자연어 로 설계했어요. 그런데. 음성으로 답하는 단계에서 가장 큰 체감 페르소나목소리 그 자체. 같은 답변 텍스트라도 남자 저음 vs 여자 고음 vs 어린아이 톤 이 결정적으로 다른 캐릭터를 만들어요. 분홍 토끼 캐릭터낮은 남자 보이스 로 답이 흘러나오면 순간 캐릭터가 박살. 이 보이스 매핑을 어디에 어떻게 둘지 의 결정이 핵심 트레이드오프입니다.

[튜터의 가이드 및 해설]

이 문제는 캐릭터 정체성의 영속성운영 부담 vs 자유도 의 트레이드오프를 묻습니다. 결정 트리는 세 갈래.

Option A — Soulmate.voiceModel DB 컬럼

가장 단순한 접근. 캐릭터 생성 시 입력 폼 에서 사용자가 직접 고르거나 기본값 으로 결정. Day 7 의 Soulmate.portraitUrl 과 같은 가족이에요. 영속성이 강하고 — 한 번 정해진 voice 가 DB 에 영구 보관 되어 캐릭터의 정체성 일부가 돼요.

  • 장점: 명시적 + 영속적 + 운영 부담 0 (조회 한 번이면 끝).
  • 단점: 캐릭터 추가 시 매번 voice 결정 이 필요. 수십 개 voice 중 어떤 걸 고를지 의 부담이 사용자 또는 운영자에게.

Option B — VoiceProfileMapper 별도 빈

캐릭터 성격 → voice 매핑 정책 을 별도 빈에 분리. "발랄"BongJinNeural (어린 톤), "차분"SunHiNeural (여성), "진중"InJoonNeural (남성). Soulmate.personality 컬럼 하나만 보고 Mapper 가 voice 를 자동 결정.

  • 장점: 캐릭터 추가 시 자동 매핑 — 운영 부담 0. 매핑 정책이 한 곳에 모임 (수정 시 한 군데만 수정).
  • 단점: 같은 성격 = 같은 voice 가 되어 캐릭터별 다양성이 줄어듦. 매핑 정책 변경 시 기존 캐릭터의 voice 가 한꺼번에 바뀌는 사고 (사용자 입장에선 어제까지 차분한 여성이던 캐릭터가 오늘 갑자기 남성으로 들리는 풍경 — 캐릭터 정체성 붕괴).

Option C — LLM 자체에게 추천 받기

캐릭터 생성 시 "이 캐릭터에 어울리는 보이스는?" 까지 LLM 에게 물어보는 풍경. "이름: 분홍이, 성격: 발랄, 외모: 어린 토끼""BongJinNeural" 같은 흐름.

  • 장점: 가장 자연스러운 매칭. 새 voice 가 추가되어도 LLM 이 알아서 매칭.
  • 단점: 비용 ↑ (캐릭터 생성마다 LLM 호출 한 번 추가) + 일관성 어려움 (같은 입력이라도 응답이 흔들림 — temperature 0 으로도 100% 일관 보장 안 됨) + 환각 (존재하지 않는 voice 이름을 추천하는 사고).

현업에서는 보통 — Option A + B 의 하이브리드

  • Option A 가 1 순위. DB 컬럼에 voice 가 명시적으로 기록되는 방식이 영속성 + 운영 안정성 두 축을 동시에 잡아요.
  • Option B 는 fallback. Soulmate.voiceModelnull 이거나 deprecated 된 voice 일 때만 Mapper 가 성격 기반 자동 매핑 으로 fallback.
  • Option C 는 사용 안 함. 비용 + 일관성 + 환각 셋 다 약함.

voice 변경 가능성의 통제

voice 를 너무 자주 바꾸면 같은 캐릭터로 안 들리는 게 가장 큰 사고예요. 통제는 세 가지.

  1. DB 컬럼 변경 시 사용자 확인 다이얼로그"이 캐릭터의 voice 를 바꾸면 사용자에게 다른 캐릭터처럼 들릴 수 있어요. 정말 바꾸시겠어요?" 의 한 줄.
  2. 변경 이력 로그voice_change_history 테이블에 언제 어떤 voice 에서 어떤 voice 로 바뀌었는지 기록. 디버깅 + 사고 복구.
  3. 운영 정책으로 보존"캐릭터 출시 후 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 추상화왜 한 줄로 풀리는지 가 손에 진하게 들어와요. 본 답안이 정답은 아니지만, 왜 이렇게 결정했는가 의 근거가 본인의 답안과 다르다면 — 그 차이가 본인의 손맛 이에요. 🌸

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.