본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 2. · 3 Views
Dual-Track 스트리밍 생성 완벽 가이드
실시간 음성 대화 시스템의 핵심인 Dual-Track 스트리밍 아키텍처를 다룹니다. 97ms의 초저지연을 달성하는 방법과 스트리밍 TTS 구현 기법을 초급 개발자도 이해할 수 있도록 쉽게 설명합니다.
목차
1. 실시간 대화의 필수 조건
김개발 씨는 AI 음성 비서 프로젝트에 투입된 지 일주일째입니다. 사용자가 말을 걸면 AI가 대답하는 간단한 시스템인데, 문제가 하나 있었습니다.
사용자가 질문을 마치고 무려 3초나 기다려야 AI의 목소리가 들렸던 것입니다.
실시간 대화 시스템에서 가장 중요한 것은 **지연 시간(Latency)**입니다. 사람 간의 자연스러운 대화에서 응답까지 걸리는 시간은 평균 200ms 정도입니다.
이보다 지연이 길어지면 사용자는 시스템이 고장났거나 자신의 말을 이해하지 못했다고 느낍니다. 따라서 실시간 대화 시스템은 First Token Latency를 극단적으로 줄여야 합니다.
다음 코드를 살펴봅시다.
# 전통적인 방식: 전체 응답을 기다림
def traditional_response(user_input):
# 전체 텍스트 생성 완료까지 대기 (2-3초 소요)
full_text = llm.generate(user_input)
# 전체 음성 변환 완료까지 대기 (1-2초 추가)
full_audio = tts.synthesize(full_text)
return full_audio # 총 3-5초 지연
# 스트리밍 방식: 첫 조각을 즉시 전달
async def streaming_response(user_input):
# 첫 번째 텍스트 청크가 생성되는 즉시 (100ms 내외)
async for text_chunk in llm.stream(user_input):
# 바로 음성으로 변환하여 전달
audio_chunk = await tts.stream_synthesize(text_chunk)
yield audio_chunk # 첫 응답까지 ~100ms
김개발 씨는 입사 후 첫 번째 대규모 프로젝트를 맡게 되었습니다. AI 음성 비서를 만드는 일이었는데, 처음에는 그저 신기하고 재미있었습니다.
하지만 막상 프로토타입을 만들어 테스트해보니 심각한 문제가 있었습니다. "안녕하세요, 오늘 날씨 어때요?" 사용자가 이렇게 물으면 시스템은 뚝 하고 멈춥니다.
1초, 2초, 3초... 그제야 "오늘 서울 날씨는 맑고 기온은 섭씨 22도입니다"라는 대답이 흘러나왔습니다.
김개발 씨가 직접 테스트해봐도 어색함을 느꼈습니다. 팀의 시니어 개발자 박시니어 씨가 다가와 물었습니다.
"김개발 씨, 사람들이 대화할 때 상대방의 대답을 얼마나 기다릴 수 있을 것 같아요?" 김개발 씨는 곰곰이 생각했습니다. "글쎄요, 1초 정도요?" 박시니어 씨가 고개를 저었습니다.
"실제로는 200밀리초 정도예요. 0.2초죠.
그보다 길어지면 사람들은 상대방이 자기 말을 못 알아들었거나, 뭔가 문제가 생겼다고 느끼기 시작해요." 이것이 바로 실시간 대화 시스템의 첫 번째 관문입니다. First Token Latency, 즉 첫 번째 응답이 나오기까지의 시간을 극단적으로 줄여야 합니다.
마치 전화 통화를 생각해보세요. 친구에게 "밥 먹었어?"라고 물었는데 3초 동안 아무 소리도 안 들린다면 어떨까요?
"여보세요? 거기 있어?"라고 다시 물을 겁니다.
음성 AI도 마찬가지입니다. 전통적인 방식에서는 텍스트 전체를 먼저 생성하고, 그 다음에 전체 텍스트를 음성으로 변환했습니다.
LLM이 응답을 완전히 만드는 데 2초, TTS가 음성을 만드는 데 1초. 합쳐서 3초 이상이 걸렸습니다.
하지만 스트리밍 방식은 다릅니다. LLM이 "오늘"이라는 첫 단어를 생성하는 순간, 바로 그 단어만 TTS로 보냅니다.
TTS는 "오늘"의 음성을 만들어 즉시 사용자에게 전달합니다. 그 사이에 LLM은 "서울"을 생성하고, 이 과정이 파이프라인처럼 연결됩니다.
위 코드에서 traditional_response 함수는 모든 것이 끝날 때까지 기다립니다. 반면 streaming_response 함수는 async for와 yield를 사용해서 조각조각 데이터를 흘려보냅니다.
이것이 스트리밍의 핵심입니다. 실제 서비스에서 이 차이는 엄청납니다.
3초를 기다리는 것과 0.1초만에 응답이 시작되는 것. 사용자 경험은 완전히 달라집니다.
카카오, 네이버, 구글의 음성 비서들이 모두 이런 스트리밍 방식을 사용하는 이유입니다.
실전 팁
💡 - 첫 응답 시간 200ms를 목표로 삼으세요
- 전체 처리 시간보다 첫 조각의 도착 시간이 체감 품질을 결정합니다
2. Dual Track 하이브리드 구조
박시니어 씨의 조언을 듣고 김개발 씨는 스트리밍 방식을 도입하기로 했습니다. 그런데 새로운 고민이 생겼습니다.
어떤 요청은 빠른 응답이 중요하고, 어떤 요청은 정확한 응답이 중요했습니다. 하나의 방식으로 모든 상황을 다룰 수 있을까요?
Dual-Track 아키텍처는 두 개의 처리 경로를 동시에 운영하는 구조입니다. 첫 번째 트랙은 Fast Track으로 속도에 최적화되어 있고, 두 번째 트랙은 Quality Track으로 품질에 최적화되어 있습니다.
요청의 특성에 따라 적절한 트랙을 선택하거나, 두 트랙을 조합하여 최상의 결과를 얻을 수 있습니다.
다음 코드를 살펴봅시다.
class DualTrackProcessor:
def __init__(self):
self.fast_track = FastTrack() # 속도 우선
self.quality_track = QualityTrack() # 품질 우선
async def process(self, request):
# 요청 분석: 속도가 중요한지, 품질이 중요한지
priority = self.analyze_priority(request)
if priority == "speed":
# Fast Track: 경량 모델로 즉시 응답
return await self.fast_track.process(request)
elif priority == "quality":
# Quality Track: 고성능 모델로 정교한 응답
return await self.quality_track.process(request)
else:
# Hybrid: Fast Track으로 시작, Quality Track으로 보강
fast_result = await self.fast_track.process(request)
yield fast_result # 즉시 전달
quality_result = await self.quality_track.refine(fast_result)
yield quality_result # 개선된 버전 전달
김개발 씨는 스트리밍 시스템을 구현하면서 흥미로운 패턴을 발견했습니다. 사용자의 요청에는 크게 두 종류가 있었습니다.
첫 번째는 "지금 몇 시야?", "오늘 날씨 알려줘" 같은 단순 질문입니다. 이런 질문은 정확도보다 빠른 응답이 훨씬 중요합니다.
시간을 묻는데 3초 후에 대답하면 그 사이에 사용자가 직접 시계를 볼 테니까요. 두 번째는 "이 코드의 버그를 찾아줘", "오늘 있었던 일을 일기로 써줘" 같은 복잡한 요청입니다.
이런 경우에는 조금 기다리더라도 정확하고 품질 좋은 결과를 원합니다. 박시니어 씨가 화이트보드에 그림을 그리기 시작했습니다.
"자동차 도로를 생각해봐요. 고속도로가 있고 일반도로가 있죠.
고속도로는 빠르지만 목적지가 제한적이고, 일반도로는 느리지만 어디든 갈 수 있어요. 우리 시스템도 이렇게 두 개의 도로를 만들 거예요." 이것이 바로 Dual-Track 아키텍처의 핵심 아이디어입니다.
하나의 시스템 안에 서로 다른 특성을 가진 두 개의 처리 경로를 만드는 것입니다. Fast Track은 경량화된 모델을 사용합니다.
파라미터 수가 적은 작은 모델, 또는 미리 계산해둔 캐시를 활용합니다. 응답의 다양성은 떨어질 수 있지만, 첫 응답을 50ms 이내에 내보낼 수 있습니다.
Quality Track은 대형 모델을 사용합니다. GPT-4나 Claude 같은 고성능 모델을 풀로 활용합니다.
시간은 걸리지만, 복잡한 추론이나 창의적인 작업에서 뛰어난 결과를 냅니다. 위 코드에서 analyze_priority 메서드가 요청을 분석합니다.
단순 질문인지, 복잡한 요청인지 판단하는 것이죠. 그리고 적절한 트랙으로 요청을 보냅니다.
가장 영리한 부분은 Hybrid 모드입니다. Fast Track으로 먼저 빠른 응답을 보내고, 그 사이에 Quality Track이 더 나은 답변을 준비합니다.
사용자는 즉각적인 피드백을 받으면서도, 곧 개선된 버전을 받게 됩니다. 마치 레스토랑에서 일단 물과 빵을 먼저 내오고, 본 요리를 준비하는 것과 같습니다.
손님은 기다리는 동안 허기를 달랠 수 있고, 레스토랑은 충분한 시간을 가지고 훌륭한 요리를 완성할 수 있습니다. 실제로 많은 상용 음성 AI 시스템이 이 구조를 채택하고 있습니다.
단순히 하나의 모델에 의존하지 않고, 상황에 맞는 최적의 경로를 선택하는 지혜가 담겨 있습니다.
실전 팁
💡 - 요청을 분류하는 로직이 핵심입니다. 잘못 분류하면 역효과가 날 수 있습니다
- Hybrid 모드에서 두 응답 간의 일관성을 유지하는 것이 중요합니다
3. 스트리밍 vs 비스트리밍 모드
김개발 씨는 Dual-Track 구조를 이해했지만, 여전히 궁금한 점이 있었습니다. "스트리밍이 무조건 좋은 건가요?
그럼 왜 비스트리밍 모드가 아직도 존재하는 거죠?" 박시니어 씨가 웃으며 대답했습니다. "좋은 질문이에요.
각각 써야 할 때가 다르거든요."
스트리밍 모드는 데이터를 조각 단위로 실시간 전송하는 방식입니다. 반면 비스트리밍 모드는 전체 데이터가 완성된 후 한 번에 전송합니다.
스트리밍은 낮은 지연 시간이 필요할 때, 비스트리밍은 데이터 무결성이나 후처리가 중요할 때 사용합니다. 두 모드를 적절히 조합하면 최적의 사용자 경험을 제공할 수 있습니다.
다음 코드를 살펴봅시다.
# 스트리밍 모드: 실시간 대화에 적합
async def streaming_tts(text_stream):
buffer = ""
async for chunk in text_stream:
buffer += chunk
# 문장이 완성되면 즉시 음성 변환
if is_sentence_complete(buffer):
audio = await tts.synthesize(buffer)
yield audio
buffer = ""
# 비스트리밍 모드: 정확한 타이밍 제어에 적합
def non_streaming_tts(full_text):
# 전체 텍스트 분석 후 최적의 운율 결정
prosody = analyze_prosody(full_text)
# 일괄 처리로 일관된 품질 보장
audio = tts.synthesize_with_prosody(full_text, prosody)
return audio
# 하이브리드: 용도에 따라 선택
def choose_mode(use_case):
if use_case in ["voice_chat", "live_assistant"]:
return "streaming" # 즉각 반응 필요
elif use_case in ["audiobook", "announcement"]:
return "non_streaming" # 품질 우선
else:
return "adaptive" # 상황에 따라 전환
김개발 씨는 회사 복도에서 물을 마시다가 문득 깨달았습니다. 회사 전화기는 실시간으로 음성을 전달하지만, 안내 방송은 미리 녹음된 멘트를 재생합니다.
둘 다 음성이지만 방식이 다른 것입니다. 박시니어 씨에게 달려가 이 생각을 말했더니, 박시니어 씨가 환하게 웃었습니다.
"정확해요! 그게 바로 스트리밍과 비스트리밍의 차이예요." 스트리밍 모드는 수도꼭지에서 물이 나오는 것과 같습니다.
꼭지를 틀면 물이 조금씩, 하지만 즉시 나옵니다. 버킷이 가득 찰 때까지 기다릴 필요 없이 바로 손을 씻을 수 있습니다.
반면 비스트리밍 모드는 생수통을 배달받는 것과 같습니다. 배달이 올 때까지 기다려야 하지만, 한번 오면 충분한 양의 깨끗한 물을 확보할 수 있습니다.
그리고 물의 양과 품질을 미리 알 수 있죠. 코드를 보면 그 차이가 명확합니다.
streaming_tts 함수는 텍스트가 들어오는 대로 처리합니다. 문장 하나가 완성되면 즉시 음성으로 변환해서 보냅니다.
사용자는 AI가 "생각하면서" 말하는 것처럼 느낍니다. non_streaming_tts 함수는 다릅니다.
전체 텍스트를 먼저 받아서 분석합니다. 문장의 흐름, 강조해야 할 단어, 적절한 쉼의 위치까지 파악한 후에 음성을 생성합니다.
결과물의 품질은 더 높지만, 기다림이 필요합니다. 그렇다면 언제 어떤 모드를 써야 할까요?
실시간 음성 채팅이나 AI 비서처럼 즉각적인 반응이 필요한 경우에는 스트리밍이 필수입니다. 사용자가 "오늘 일정 알려줘"라고 말했는데 5초 동안 조용하면 불안해집니다.
하지만 오디오북이나 팟캐스트 TTS, 또는 공항 안내 방송 같은 경우에는 비스트리밍이 낫습니다. 여기서는 자연스러운 억양과 정확한 발음이 더 중요합니다.
청취자는 약간의 로딩 시간을 충분히 기다릴 수 있습니다. 위 코드의 choose_mode 함수처럼, 실제 시스템에서는 용도에 따라 자동으로 모드를 전환하는 로직이 필요합니다.
때로는 같은 앱 안에서도 상황에 따라 다른 모드를 사용하기도 합니다. 김개발 씨가 정리했습니다.
"결국 '언제 응답이 필요한가'의 문제군요. 지금 당장이면 스트리밍, 나중에 잘 만들어서 주면 되면 비스트리밍." 박시니어 씨가 고개를 끄덕였습니다.
"바로 그거예요."
실전 팁
💡 - 스트리밍 모드에서는 문장 단위로 끊어 처리하면 자연스러운 발화가 됩니다
- 비스트리밍은 전처리/후처리 파이프라인을 자유롭게 설계할 수 있습니다
4. 첫 오디오 패킷 생성 타이밍
이론은 충분히 배웠습니다. 이제 김개발 씨는 실제로 "첫 소리가 언제 나오는가"를 측정해보기로 했습니다.
스톱워치를 들고 테스트를 시작했는데, 생각보다 복잡한 문제가 숨어 있었습니다.
첫 오디오 패킷 생성 타이밍은 사용자 입력부터 첫 번째 음성 조각이 만들어지기까지의 시간을 의미합니다. 이 시간을 줄이려면 파이프라인의 각 단계를 병렬화하고, 버퍼링을 최소화해야 합니다.
특히 LLM의 첫 토큰 생성 시간과 TTS의 초기화 시간이 핵심 병목입니다.
다음 코드를 살펴봅시다.
import time
import asyncio
class LatencyTracker:
def __init__(self):
self.timestamps = {}
async def process_with_tracking(self, user_input):
# 1단계: 사용자 입력 수신
self.timestamps["input_received"] = time.time()
# 2단계: LLM 스트리밍 시작 (병목 1)
llm_stream = llm.stream(user_input)
first_token = await llm_stream.__anext__()
self.timestamps["first_token"] = time.time()
# 3단계: TTS 초기화 (병목 2 - 미리 예열)
tts_session = await tts.create_streaming_session()
# 4단계: 첫 오디오 청크 생성
first_audio = await tts_session.synthesize(first_token)
self.timestamps["first_audio"] = time.time()
# 지연 시간 계산
total_latency = self.timestamps["first_audio"] - self.timestamps["input_received"]
print(f"첫 오디오까지 지연: {total_latency * 1000:.1f}ms")
yield first_audio
김개발 씨는 코드 여기저기에 타임스탬프를 찍기 시작했습니다. 마치 마라톤 코스에 중간 체크포인트를 설치하는 것처럼요.
어디서 시간이 오래 걸리는지 정확히 알아야 개선할 수 있으니까요. 측정 결과는 충격적이었습니다.
사용자가 말을 마치고 첫 소리가 나오기까지 무려 850ms가 걸렸습니다. 목표인 200ms의 네 배가 넘는 시간이었습니다.
박시니어 씨가 측정 결과를 함께 분석했습니다. "어디서 시간을 잡아먹는지 하나씩 살펴봅시다." 입력 처리에 50ms, LLM 첫 토큰 생성에 400ms, TTS 세션 초기화에 200ms, 첫 오디오 합성에 200ms.
이렇게 시간이 쌓이고 있었습니다. 첫 번째 병목은 LLM의 첫 토큰 생성 시간이었습니다.
아무리 스트리밍이라고 해도, 모델이 "생각을 시작"하는 데는 시간이 필요합니다. 특히 대형 모델일수록 이 초기 지연이 깁니다.
두 번째 병목은 TTS 세션 초기화였습니다. TTS 엔진이 처음 가동될 때 모델을 메모리에 로드하고, 음성 합성에 필요한 리소스를 준비하는 시간이 필요합니다.
위 코드에서 LatencyTracker 클래스는 각 단계별 소요 시간을 정밀하게 측정합니다. 이런 측정 없이는 최적화가 불가능합니다.
무엇을 개선해야 할지 모르니까요. 박시니어 씨가 해결책을 제시했습니다.
"두 가지 전략이 있어요. 첫째는 파이프라인 병렬화, 둘째는 사전 예열이에요." 파이프라인 병렬화란, LLM이 첫 토큰을 생성하는 동안 TTS 세션을 미리 초기화하는 것입니다.
두 작업이 순차적으로 일어날 필요가 없습니다. 동시에 진행하면 총 시간을 줄일 수 있습니다.
사전 예열은 더 적극적인 방법입니다. 사용자가 말하기 시작하면, 아직 입력이 끝나지 않았어도 TTS 세션을 미리 만들어 두는 것입니다.
마치 자동차 시동을 미리 걸어두는 것처럼요. 김개발 씨가 이 전략들을 적용한 후 다시 측정했습니다.
850ms가 320ms로 줄었습니다. 아직 목표에는 못 미치지만, 엄청난 진전이었습니다.
"더 줄일 수 있을까요?" 김개발 씨가 물었습니다. 박시니어 씨가 의미심장하게 웃었습니다.
"다음 단계로 가보죠. 97ms까지 줄이는 방법이 있어요."
실전 팁
💡 - 모든 단계에 타임스탬프를 찍어 병목을 정확히 파악하세요
- TTS 세션은 가능한 한 미리 초기화하고 재사용하세요
5. 97ms 지연 달성 방법
김개발 씨의 눈이 휘둥그레졌습니다. "97ms요?
정말 그게 가능해요?" 박시니어 씨가 고개를 끄덕였습니다. "가능해요.
다만, 몇 가지 과감한 결정이 필요하죠." 초저지연의 세계로 들어가 봅시다.
97ms 지연은 사람이 인식하기 어려운 수준의 반응 속도입니다. 이를 달성하려면 경량 모델 사용, 추측 실행(Speculative Execution), 첫 음절 프리페칭(Prefetching) 등의 고급 기법이 필요합니다.
속도를 위해 일부 정확도를 희생하는 트레이드오프가 존재하지만, 체감 품질은 오히려 올라갑니다.
다음 코드를 살펴봅시다.
class UltraLowLatencyPipeline:
def __init__(self):
# 경량 모델과 고성능 모델 동시 로드
self.fast_llm = load_model("llama-7b-quantized") # 경량
self.quality_llm = load_model("llama-70b") # 고성능
self.tts = StreamingTTS(preload=True)
# 자주 쓰는 응답 패턴 캐시
self.response_cache = PrefixCache()
async def ultra_fast_response(self, user_input):
# 1. 추측 실행: 입력 분석과 동시에 예상 응답 준비
prediction_task = asyncio.create_task(
self.response_cache.predict_prefix(user_input)
)
# 2. 경량 모델로 즉시 첫 응답 생성 (50ms)
first_chunk = await self.fast_llm.generate_first(user_input)
# 3. 프리페치된 음성 또는 즉시 합성
cached_audio = await prediction_task
if cached_audio:
yield cached_audio # 캐시 히트: ~20ms
else:
audio = await self.tts.synthesize(first_chunk)
yield audio # 캐시 미스: ~70ms
# 4. 백그라운드에서 고품질 응답 생성 및 교체
async for quality_chunk in self.quality_llm.stream(user_input):
yield await self.tts.synthesize(quality_chunk)
박시니어 씨가 화이트보드에 숫자를 적었습니다. 100ms 안에 첫 소리를 내려면 모든 것을 다르게 생각해야 합니다.
"김개발 씨, 마트 계산대를 생각해봐요. 손님이 많으면 줄이 길어지죠.
그런데 어떤 마트는 셀프 계산대, 빠른 계산대, 일반 계산대로 나눠요. 물건이 적은 손님은 빠른 계산대로 가고, 물건이 많은 손님은 일반 계산대로 가죠." 첫 번째 비결은 경량 모델의 적극적 활용입니다.
7B 파라미터의 양자화된 모델은 70B 모델보다 응답 품질은 떨어지지만, 첫 토큰을 생성하는 속도는 5배 이상 빠릅니다. 첫인상만 빠르게 만들면 되니까요.
두 번째 비결은 **추측 실행(Speculative Execution)**입니다. 컴퓨터 CPU가 사용하는 기법을 차용한 것입니다.
사용자의 입력을 완전히 분석하기 전에, "아마 이런 종류의 대답을 원하겠지"라고 추측하고 미리 준비를 시작합니다. 위 코드에서 prediction_task가 바로 그 역할을 합니다.
사용자가 "오늘"이라고 말하기 시작하면, 시스템은 "오늘 날씨", "오늘 일정", "오늘 뉴스" 등의 응답 접두사를 미리 준비합니다. 세 번째 비결은 **프리페칭(Prefetching)**입니다.
자주 사용되는 응답의 첫 부분을 미리 음성으로 변환해서 캐시에 저장해둡니다. "안녕하세요", "네, 알겠습니다", "죄송합니다만" 같은 인사말이나 접속사들이죠.
이 세 가지가 조합되면 놀라운 일이 벌어집니다. 사용자가 "날씨 알려줘"라고 말합니다.
시스템은 이미 "날씨" 키워드를 감지하고 "오늘 [지역] 날씨는"이라는 응답 접두사를 준비합니다. 이 접두사의 음성은 이미 캐시에 있습니다.
사용자가 말을 마치자마자 20ms 만에 "오늘"이라는 소리가 나옵니다. 물론 트레이드오프가 있습니다.
추측이 틀릴 수도 있습니다. 사용자가 "날씨 말고 뉴스"라고 말하면 준비한 응답은 버려야 합니다.
하지만 통계적으로 추측이 맞는 경우가 훨씬 많습니다. 그리고 틀리더라도, 고품질 응답이 바로 뒤따라오니까 문제없습니다.
김개발 씨가 이 시스템을 구현하고 테스트했습니다. 측정 결과, 캐시 히트 시 23ms, 캐시 미스 시 89ms.
평균 97ms의 첫 응답 지연을 달성했습니다. "믿기 어려워요." 김개발 씨가 감탄했습니다.
인간의 청각 반응 속도가 약 150ms라고 합니다. 97ms면 사람이 인식하기도 전에 응답이 시작되는 것입니다.
실전 팁
💡 - 경량 모델의 첫 응답은 품질보다 자연스러움에 집중하세요
- 캐시 적중률을 높이기 위해 사용 패턴을 지속적으로 분석하세요
6. 실전 스트리밍 TTS 구현
이론과 전략은 충분합니다. 이제 김개발 씨는 실제로 동작하는 스트리밍 TTS 시스템을 처음부터 끝까지 구현해보기로 했습니다.
손에 잡히는 코드가 있어야 진짜 이해한 것이니까요.
실전 스트리밍 TTS 구현에서는 텍스트 청킹, 비동기 음성 합성, 오디오 버퍼 관리, 클라이언트 전송까지 전체 파이프라인을 다룹니다. WebSocket을 통한 실시간 스트리밍과 적절한 청크 크기 설정이 핵심입니다.
에러 처리와 재연결 로직도 프로덕션 환경에서는 필수입니다.
다음 코드를 살펴봅시다.
import asyncio
from fastapi import FastAPI, WebSocket
import edge_tts
app = FastAPI()
class StreamingTTSPipeline:
def __init__(self, voice="ko-KR-SunHiNeural"):
self.voice = voice
self.chunk_size = 1024 # 오디오 청크 크기 (바이트)
async def text_to_speech_stream(self, text_stream):
"""텍스트 스트림을 오디오 스트림으로 변환"""
buffer = ""
async for text_chunk in text_stream:
buffer += text_chunk
# 문장 단위로 처리 (자연스러운 끊김 방지)
sentences = self.split_sentences(buffer)
for sentence in sentences[:-1]: # 마지막 미완성 문장 제외
async for audio in self.synthesize(sentence):
yield audio
buffer = sentences[-1] if sentences else ""
# 남은 버퍼 처리
if buffer.strip():
async for audio in self.synthesize(buffer):
yield audio
async def synthesize(self, text):
"""Edge TTS로 음성 합성"""
communicate = edge_tts.Communicate(text, self.voice)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
yield chunk["data"]
@app.websocket("/tts/stream")
async def websocket_tts(websocket: WebSocket):
await websocket.accept()
pipeline = StreamingTTSPipeline()
try:
while True:
text = await websocket.receive_text()
async for audio_chunk in pipeline.synthesize(text):
await websocket.send_bytes(audio_chunk)
except Exception as e:
print(f"Connection closed: {e}")
드디어 실전입니다. 김개발 씨는 빈 파이썬 파일을 열고 첫 줄부터 코드를 작성하기 시작했습니다.
먼저 필요한 도구들을 정했습니다. 웹 서버는 FastAPI를 선택했습니다.
비동기 처리가 기본이고, WebSocket 지원도 훌륭하기 때문입니다. TTS 엔진은 Edge TTS를 골랐습니다.
Microsoft의 고품질 음성을 무료로 사용할 수 있고, 한국어 음성도 자연스럽습니다. 위 코드의 StreamingTTSPipeline 클래스가 핵심입니다.
이 클래스는 텍스트가 들어오면 음성을 내보내는 파이프라인 역할을 합니다. 가장 중요한 부분은 text_to_speech_stream 메서드입니다.
텍스트가 조각조각 들어올 때, 무턱대고 바로 음성으로 바꾸면 안 됩니다. "안녕하"에서 끊으면 어색하죠.
그래서 문장 단위로 버퍼링합니다. 버퍼에 텍스트를 모으다가, 마침표나 물음표가 나오면 그때 음성으로 변환합니다.
split_sentences 메서드가 이 역할을 합니다. 이렇게 하면 "안녕하세요."까지 모은 다음에 한 번에 자연스럽게 발화합니다.
synthesize 메서드는 Edge TTS를 호출하는 부분입니다. edge_tts.Communicate 객체를 만들고, stream() 메서드로 오디오 청크를 받아옵니다.
async for 문으로 청크가 생성되는 대로 yield합니다. WebSocket 엔드포인트 /tts/stream은 클라이언트와의 통신 창구입니다.
클라이언트가 텍스트를 보내면, 서버는 음성 데이터를 바이너리로 쏴줍니다. 실시간으로요.
박시니어 씨가 코드를 검토하며 몇 가지를 짚어주었습니다. "프로덕션에서는 에러 처리가 중요해요.
네트워크가 끊기거나, TTS 서버가 응답하지 않을 수 있거든요." 실제로 위 코드의 try-except 블록이 그 역할을 합니다. 연결이 끊기면 조용히 로그를 남기고 종료합니다.
프로덕션에서는 여기에 재연결 로직, 타임아웃 처리, 모니터링 연동 등이 추가됩니다. 김개발 씨가 서버를 실행하고 테스트했습니다.
웹 브라우저에서 WebSocket으로 연결하고, "안녕하세요, 오늘 날씨가 좋네요"라고 보냈습니다. 거의 즉시 스피커에서 자연스러운 한국어 음성이 흘러나왔습니다.
"됐다!" 김개발 씨가 환호했습니다. 몇 주간의 노력이 드디어 결실을 맺는 순간이었습니다.
박시니어 씨가 어깨를 두드리며 말했습니다. "잘했어요.
하지만 이건 시작이에요. 프로덕션으로 가려면 부하 테스트, 모니터링, 장애 대응까지 준비해야 해요.
천천히 하나씩 해봅시다." 김개발 씨는 고개를 끄덕였습니다. 앞으로 할 일이 많지만, 이제는 방향이 보입니다.
Dual-Track 스트리밍의 세계에 첫 발을 내딛은 것입니다.
실전 팁
💡 - 문장 단위 청킹은 한국어의 경우 마침표, 물음표, 느낌표를 기준으로 합니다
- WebSocket 연결 풀링으로 다수의 동시 요청을 효율적으로 처리하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
vLLM 통합 완벽 가이드
대규모 언어 모델 추론을 획기적으로 가속화하는 vLLM의 설치부터 실전 서비스 구축까지 다룹니다. PagedAttention과 연속 배칭 기술로 GPU 메모리를 효율적으로 활용하는 방법을 배웁니다.
Web UI Demo 구축 완벽 가이드
Gradio를 활용하여 머신러닝 모델과 AI 서비스를 위한 웹 인터페이스를 구축하는 방법을 다룹니다. 코드 몇 줄만으로 전문적인 데모 페이지를 만들고 배포하는 과정을 초급자도 쉽게 따라할 수 있도록 설명합니다.
Sandboxing & Execution Control 완벽 가이드
AI 에이전트가 코드를 실행할 때 반드시 필요한 보안 기술인 샌드박싱과 실행 제어에 대해 알아봅니다. 격리된 환경에서 안전하게 코드를 실행하고, 악성 동작을 탐지하는 방법을 단계별로 설명합니다.
Voice Design then Clone 워크플로우 완벽 가이드
AI 음성 합성에서 일관된 캐릭터 음성을 만드는 Voice Design then Clone 워크플로우를 설명합니다. 참조 음성 생성부터 재사용 가능한 캐릭터 구축까지 실무 활용법을 다룹니다.
Tool Use 완벽 가이드 - Shell, Browser, DB 실전 활용
AI 에이전트가 외부 도구를 활용하여 셸 명령어 실행, 브라우저 자동화, 데이터베이스 접근 등을 수행하는 방법을 배웁니다. 실무에서 바로 적용할 수 있는 패턴과 베스트 프랙티스를 담았습니다.