FastAPI, Claude, 그리고 Server-Sent Events를 활용한 실시간 AI 번역 지원 기능 구축하기
요약
FastAPI와 Server-Sent Events(SSE)를 사용하여 Claude LLM의 응답을 실시간 스트리밍하는 AI 번역 지원 기능을 구축하는 방법을 설명합니다. 사용자가 선택한 문맥을 바탕으로 낮은 지연 시간과 비동기 처리를 구현하여 사용자 경험을 개선한 사례를 다룹니다.
핵심 포인트
- FastAPI와 SSE를 활용한 실시간 LLM 응답 스트리밍 구현
- 사용자 경험을 위한 2초 이내의 낮은 지연 시간 확보
- 문맥 인식을 위해 주변 텍스트를 포함한 정교한 프롬프트 설계
- 비동기 아키텍처를 통한 메인 번역 파이프라인과의 독립성 유지
어떻게 까다로운 구절에 대해 LLM 제안을 스트리밍하여, 저희의 도서 번역 플랫폼에 온디맨드(on-demand) 번역 도움말 기능을 추가했는지 소개합니다.
LectuLibre에서 저희의 AI 기반 도서 번역 서비스는 사용자가 EPUB 또는 PDF 파일을 업로드하면 Claude 및 DeepSeek와 같은 대규모 언어 모델(Large Language Models, LLM)을 통해 번역을 생성할 수 있도록 지원합니다. 하지만 저희는 곧 한 가지 문제점을 발견했습니다. 자동 번역은 빠르긴 하지만, 문화적으로 특수한 문구, 관용구 또는 기술적 전문 용어에 대해 때때로 어색하거나 모호한 결과를 생성한다는 점이었습니다. 사용자들은 플랫폼을 떠나지 않고도 이러한 까다로운 구절에 대해 즉각적이고 문맥적인 도움을 받을 수 있는 방법을 원했습니다. 그래서 저희는 翻译与转录求助 (Translation Assistance, 번역 및 전사 도움말) 기능을 구축하기로 했습니다. 이는 사용자가 임의의 문장이나 단락을 선택하면 LLM으로부터 대안 번역, 설명 및 스타일 제안을 실시간으로 받을 수 있는 대화형 사이드 패널입니다.
이 글에서는 엔지니어링 과제, 저희가 선택한 아키텍처(Architecture), 그리고 프로덕션(Production) 제약 조건 하에서 기능이 원활하게 작동하도록 만든 구체적인 코드와 트레이드오프(Trade-offs)에 대해 설명하겠습니다.
문제점: 실시간, 문맥 인식 번역 도움말
핵심 요구사항은 간단했습니다. 사용자가 번역된 책에서 텍스트 일부를 하이라이트하고 “도움말 받기(Get Assistance)”를 클릭하면, 시스템은 주변 문맥, 저자의 스타일, 그리고 대상 언어를 모두 고려하여 여러 가지 번역 옵션, 차이점에 대한 간략한 설명, 그리고 스타일 노트를 즉시 스트리밍하여 반환해야 합니다.
내부적으로 이는 다음을 의미했습니다:
- 낮은 지연 시간 (Low latency): 사용자는 2초 이내의 응답을 기대합니다.
- 스트리밍 (Streaming): LLM 출력은 길어질 수 있으므로, 토큰(Tokens)이 생성되는 대로 스트리밍해야 했습니다.
- 문맥 인식 (Context awareness): 모델의 응답에 근거를 제공하기 위해 책의 충분한 주변 텍스트를 포함해야 합니다.
- 비차단 (No blocking): 메인 번역 파이프라인에 영향을 주어서는 안 되며, 도움말 기능은 독립적인 비동기(Async) 서비스로 존재해야 합니다.
- 비용 효율성 (Cost efficiency): 사용자가 도움을 요청할 때마다 책 전체를 다시 처리하는 것을 피해야 합니다.
우리의 접근 방식: Async FastAPI + SSE + Rate Limiting
우리는 VPS 상에서 Python/FastAPI 백엔드를 실행하며, 기존의 번역 파이프라인은 전체 도서 번역을 위해 Claude API에 배치 호출 (batch calls)을 수행합니다. 도움말 기능(assistance feature)을 위해, 우리는 도서 ID, 시작/종료 오프셋(offset)을 입력받아 Server-Sent Events (SSE)를 통해 도움말을 스트리밍하여 반환하는 별도의 엔드포인트(endpoint)를 구축했습니다. 전체 흐름은 다음과 같습니다:
- 프론트엔드에서 선택된 텍스트 오프셋을 포함한 POST 요청을 보냅니다.
- 백엔드는 PostgreSQL 데이터베이스(수집 단계에서 이미 청크(chunk)로 분할됨)에서 주변 문단들을 가져옵니다.
- 선택된 텍ек스트, 해당 문맥(context), 그리고 다양한 번역 변체(translation variants)를 위한 지침을 포함하여 정교하게 설계된 프롬프트(prompt)를 구성합니다.
- Claude의 스트리밍 API (
messages.create(stream=True))를 호출하고, 각 청크를 SSE 이벤트로 전달합니다.
데이터베이스 및 수집(Ingestion) 설정
도서가 업로드되면, EPUB/PDF를 파싱(parse)하여 텍스트를 추출하고, 위치 및 메타데이터(metadata)와 함께 청크(문단) 단위로 저장합니다. PostgreSQL에는 book_id, chunk_index, text, lang 컬럼을 가진 book_chunks 테이블이 있습니다. 이를 통해 간단한 쿼리만으로 선택된 오프셋과 주변 몇 개의 인접 청크를 포함하는 데이터를 빠르게 가져올 수 있습니다:
async def get_context_chunks(book_id: str, chunk_index: int, context_size: int = 2):
query = """
SELECT chunk_index, text
...
이 방식은 도서 전체를 메모리에 올리지 않고도 주변 텍스트를 가져올 수 있게 해줍니다.
스트리밍 엔드포인트
우리는 SSE 형식의 메시지를 생성(yield)하는 제너레이터(generator)와 함께 FastAPI의 StreamingResponse를 사용했습니다. 핵심은 Claude SDK의 stream=True가 비동기 이터레이터(async iterator)를 반환한다는 점이며, 이를 통해 각 청크를 await하고 즉시 생성(yield)할 수 있습니다:
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
import anthropic
...
이를 통해 프론트엔드에 실시간으로 단어 단위의 스트리밍 경험을 제공합니다. event_generator는 Anthropic SDK의 핵심 기능인 stream.text_stream 덕분에 비동기 제너레이터(async generator)로 동작합니다.
여러 변형을 위한 프롬프트 엔지니어링 (Prompt Engineering)
유용한 출력을 얻기 위해, 우리는 각각 짧은 설명이 포함된 세 가지의 서로 다른 번역 옵션을 요청하는 프롬프트를 제작했습니다. 또한 모델에게 문맥(context)을 보존하고 스타일 가이드(style guide)를 제공하도록 지시했습니다. 간략화된 버전은 다음과 같습니다:
def build_assistance_prompt(context: str, selected: str) -> str:
return f"""당신은 전문 문학 번역가입니다. 다음 책의 문맥을 고려하여:
...
그 다음 프론트엔드에서 JSON을 파싱하여 대안들을 깔끔하게 표시합니다. 스트리밍되는 텍스트는 도착하는 대로 누적되어 표시되므로, 즉각적인 응답을 받는 듯한 인상을 줍니다.
트레이드오프 (Trade-Offs) 및 과제
지연 시간 (Latency) vs. 완전성 (Completeness)
스트리밍은 체감 성능을 향상시키지만, 150개 토큰의 문맥(context)에 대한 첫 번째 토큰 생성 시간(time to first token)은 여전히 약 1~2초 정도 소요됩니다. 우리는 검색 속도를 높이기 위해 문맥 임베딩(context embeddings)을 미리 가져오는(pre-fetching) 방안을 고려했으나, LLM 호출에 따른 오버헤드가 지배적이었습니다. 따라서 다음과 같은 방식으로 최적화했습니다:
- 이벤트 루프(event loop)가 차단되는 것을 방지하기 위해
asyncSDK를 사용했습니다. - 동일한 쿼리(같은 책, 같은 청크, 같은 선택된 텍스트)에 대해 5분간의 TTL(Time-To-Live)을 가진 간단한 Redis 캐시를 구현했습니다. 이를 통해 반복적인 요청을 약 30% 줄였습니다.
- 응답이 짧기 때문에 상대적으로 작은
max_tokens(1024)를 선택했습니다.
비용 관리 (Cost Management)
각 지원(assistance) 호출은 토큰을 소모합니다. Claude 3.5 Sonnet의 경우, 입력 토큰은 $3/MTok, 출력 토큰은 $15/MTok입니다. 우리는 사용자별 사용량을 추적하고 일일 지원 요청 횟수를 50회로 제한했습니다. 사용자가 이를 초과하면 정중한 메시지가 표시됩니다. 이는 Redis를 사용한 간단한 속도 제한기(rate limiter)를 통해 구현되었습니다:
import aioredis
import time
...
모델 선택 (Model Choice)
초기에는 비용이 더 저렴한 DeepSeek를 사용했으나, 문학적 뉘앙스에 대한 번역 품질이 눈에 띄게 낮았고 스트리밍 API가 덜 안정적이었습니다. Claude의 출력이 더 풍부했기 때문에, 비용에도 불구하고 Claude로 결정했습니다. 비문학 작품의 경우에는 DeepSeek로 전환할 수 있는 토글(toggle)을 제공할 수도 있습니다.
컨텍스트 윈도우 (Context Window) 및 토큰 제한
프롬프트 비용을 낮게 유지하면서 모델의 200K 컨텍스트 윈도우 (Context Window) 범위를 충분히 벗어나지 않도록 컨텍스트를 3개 문단(약 500 토큰)으로 제한했습니다. 이는 모델이 어조와 스타일을 이해하기에 보통 충분한 양입니다.
결과 및 학습된 교훈 (Results and Lessons Learned)
배포 후, “翻译与转录求助” 기능은 LectuLibre에서 가장 많이 사용되는 부분 중 하나가 되었습니다. 사용자들은 즉각적인 반응과 여러 번역 옵션을 나란히 볼 수 있는 능력을 매우 좋아했습니다. 클릭부터 첫 번째 토큰이 생성될 때까지의 평균 응답 시간은 1.4초이며, 전체 응답(3가지 옵션)은 약 5~7초가 소요되는데, 사용자들은 이를 수용 가능한 수준으로 판단했습니다.
주요 교훈:
- 스트리밍 (Streaming)은 필수입니다: 실시간 LLM 애플리케이션에서는 타협할 수 없는 요소이며, 항상 SSE 또는 WebSockets를 사용해야 합니다.
- 캐싱 (Caching)은 효과가 있습니다: 간단한 쿼리 수준의 캐싱은 부하와 비용을 크게 줄일 수 있습니다.
- 비동기 (Async)는 정말 중요합니다:
asyncio와 비동기 SDK를 사용하면 부하 상황에서도 스레드 고갈 (Thread Exhaustion)을 방지할 수 있습니다. - 프롬프트 구조가 품질과 프론트엔드 파싱 (Parsing)을 제어합니다: JSON 출력을 요청하면 UI 렌더링은 단순해지지만 모델의 창의성을 제한할 수 있습니다. 저희는 JSON 형식을 지시하되 그 안에 자유로운 형식의 설명을 허용함으로써 절충안을 찾았습니다.
다음 단계는? (What’s Next?)
저희는 어시스턴트 모델을 미세 조정 (Fine-tuning)하기 위해 사용자 피드백을 추가하는 것과, 이 기능을 번역가들을 위한 독립적인 API로 공개하는 것을 고려하고 있습니다. 다른 팀들은 대규모 환경에서 실시간 LLM 스트리밍을 어떻게 처리하는지 — 특히 요청 배치 (Batching) 및 토큰 비용 절감과 관련하여 — 정말 궁금합니다. 댓글로 여러분의 생각을 들려주세요!
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기