AI

FastAPI에서 Claude API 스트리밍 한국어 청크 깨짐 인코딩 오류 해결

FastAPI에서 Claude API 스트리밍 한국어 청크 깨짐 인코딩 오류 해결

FastAPI로 Claude API를 붙였는데 한국어가 깨져서 나왔던 경험, 맞죠? SSE 스트림을 열었는데 ì •ë³´처럼 나오거나, 청크가 절반만 오거나. 2026년 현재 Claude API 스트리밍 응답을 FastAPI에 연동하는 개발자가 늘면서, 이 패턴의 오류 레포트도 같이 늘고 있어요.

Anthropic의 Claude API 사용량은 2025년 하반기부터 기업 프로덕션 환경에 빠르게 퍼졌어요. 단순 프로토타입이 아니라 실제 서비스에 붙이는 경우가 많아지면서, 인코딩 오류 하나가 서비스 전체 신뢰도에 영향을 미치는 상황이 됐죠. 그런데 공식 문서에는 이 케이스가 깔끔하게 정리돼 있지 않아요.

이 글에서 다룰 내용은 네 가지예요.

  • 청크 깨짐이 발생하는 근본 원인
  • FastAPI의 StreamingResponse와 SSE 처리 방식의 차이
  • 인코딩 오류 타입별 대응법
  • 실제 프로덕션에서 쓸 수 있는 패턴 비교

핵심 요약

  • Claude API 스트리밍에서 한국어 깨짐의 80% 이상은 UTF-8 멀티바이트 경계에서 청크가 잘리는 문제에서 시작돼요.
  • FastAPI StreamingResponse는 기본적으로 text/plain Content-Type을 쓰는데, 이게 SSE 스트림과 맞지 않아서 클라이언트 측 파싱 오류를 만들어요.
  • anthropic Python SDK 0.20 이상에서 비동기 스트리밍을 쓰면 청크 버퍼링 문제를 SDK 레벨에서 일부 흡수해주지만, 완전한 해결책은 아니에요.
  • 서버-클라이언트 양쪽에서 인코딩 헤더를 명시하고, 청크 완전성 검증 로직을 추가하는 게 현재 가장 안정적인 방법이에요.

문제의 구조: 왜 청크가 깨지는가

Claude API 스트리밍 응답은 SSE(Server-Sent Events) 형식으로 데이터를 보내요. 각 청크는 data: {"type":"content_block_delta","delta":{"text":"안녕"}} 같은 JSON 문자열이죠.

문제는 여기서 시작돼요.

한국어 글자 하나는 UTF-8로 3바이트예요. TCP 레이어에서 패킷이 쪼개질 때, 딱 이 3바이트의 경계를 무시하고 자를 수 있어요. 예를 들어 이라는 글자의 첫 번째 바이트만 이번 청크에 포함되고, 나머지 두 바이트는 다음 청크로 넘어가는 거죠. 이걸 받는 쪽에서 바로 디코딩하면 UnicodeDecodeError가 나거나, 최악의 경우 ?나 알 수 없는 문자로 치환돼요.

FastAPI가 이 문제를 더 키우는 이유가 있어요. StreamingResponse는 제너레이터에서 나오는 값을 그대로 내보내는 구조예요. 버퍼링도 없고, 청크 경계 보정도 없어요. Claude API Python SDK가 스트림을 넘겨줄 때 내부적으로 어떤 크기로 쪼개느냐에 따라 결과가 달라지는 거죠.

실제로 Anthropic 공식 트러블슈팅 문서에서도 스트리밍 관련 오류의 주요 원인으로 “불완전한 청크 처리"와 “Content-Type 헤더 미설정"을 언급하고 있어요. 이 두 가지가 조합되면 디버깅이 특히 까다로워져요. 오류가 일관되게 나오지 않고, 네트워크 상태에 따라 간헐적으로 발생하니까요.


원인별 해부: 세 가지 실패 패턴

패턴 1: Content-Type 헤더 누락

FastAPI에서 StreamingResponse를 그냥 쓰면 기본 media_typetext/plain이에요. 브라우저나 fetch API는 이걸 SSE로 인식하지 않아요.

# ❌ 이렇게 쓰면 클라이언트가 SSE로 인식 못 해요
return StreamingResponse(generator(), media_type="text/plain")

# ✅ 이렇게 바꿔야 해요
return StreamingResponse(
    generator(),
    media_type="text/event-stream",
    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)

X-Accel-Buffering: no도 중요해요. Nginx 뒤에 FastAPI를 놓으면 Nginx가 SSE 응답을 버퍼링하는데, 이게 스트림 실시간성을 죽여요. 청크가 한꺼번에 쏟아지거나 아예 안 오는 현상의 70% 정도가 여기서 나와요.

패턴 2: 동기 제너레이터 + 비동기 SDK 혼용

anthropic Python SDK 0.20 이후 버전은 비동기 스트리밍을 기본으로 밀어요. 그런데 FastAPI의 StreamingResponse에 일반 def 제너레이터를 넣는 케이스가 많아요. 이러면 이벤트 루프 블로킹이 발생해요.

# ❌ 동기 제너레이터는 FastAPI 이벤트 루프를 막아요
def stream_claude():
    with client.messages.stream(...) as stream:
        for text in stream.text_stream:
            yield f"data: {text}\n\n"

# ✅ async 제너레이터로 바꿔야 해요
async def stream_claude():
    async with client.messages.stream(...) as stream:
        async for text in stream.text_stream:
            yield f"data: {text}\n\n".encode("utf-8")

encode("utf-8")을 명시하는 게 포인트예요. 안 하면 FastAPI가 내부적으로 다른 인코딩을 쓸 수 있어요.

패턴 3: JSON 래핑 없이 텍스트 직접 스트리밍

Claude API가 보내는 SSE는 JSON 구조예요. 이 JSON을 그대로 클라이언트에 넘기면 파싱 책임이 클라이언트로 가요. 그런데 JSON 내부의 텍스트만 뽑아서 넘기면 청크 경계 이슈가 생길 수 있어요.

실제로 가장 안전한 방법은 stream.text_stream이 아니라 이벤트 레벨로 처리하는 거예요.

async def stream_claude():
    async with client.messages.stream(
        model="claude-opus-4-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    ) as stream:
        async for event in stream:
            if hasattr(event, 'delta') and hasattr(event.delta, 'text'):
                data = json.dumps({"text": event.delta.text}, ensure_ascii=False)
                yield f"data: {data}\n\n".encode("utf-8")

ensure_ascii=False 옵션이 핵심이에요. 이게 없으면 한국어가 \uC548\uB155 같은 유니코드 이스케이프로 바뀌어요.


접근 방식 비교: 어떤 패턴이 프로덕션에 맞을까

비교 항목텍스트 직접 스트리밍JSON 래핑 스트리밍SSE 표준 이벤트
구현 난이도낮음중간중간
한국어 안정성낮음 (인코딩 리스크)높음높음
클라이언트 파싱 복잡도낮음중간낮음 (EventSource API 사용 가능)
에러 핸들링어려움쉬움 (JSON 필드로 구분)중간
Nginx 버퍼링 영향높음중간낮음 (헤더 명시 시)
추천 환경프로토타입REST API 서버브라우저 직접 연결

프로덕션에서는 JSON 래핑 + SSE 헤더 조합이 제일 안정적이에요. 에러 상태도 같은 스트림 안에서 JSON으로 보낼 수 있어서 클라이언트 코드가 깔끔해지거든요.

텍스트 직접 스트리밍은 빠르게 뭔가 보여줘야 하는 데모에서 쓸 만해요. 다만 한국어 비중이 높은 서비스라면 처음부터 JSON 래핑 패턴으로 가는 게 나중에 디버깅 시간을 아껴줘요.


실제 배포 시 체크리스트

시나리오 1: 로컬에서는 잘 되는데 배포 후 깨져요

Nginx 버퍼링이 범인일 확률이 높아요. proxy_buffering off;X-Accel-Buffering: no 헤더를 같이 써야 해요. Nginx 설정과 애플리케이션 헤더 둘 다 설정해야 효과가 있어요. 하나만 하면 간헐적으로 문제가 재현돼요.

시나리오 2: 청크 중간에 스트림이 멈춰요

asyncio.TimeoutError 또는 네트워크 idle timeout이에요. Claude API는 긴 응답을 생성할 때 중간에 아무 청크도 안 보내는 구간이 생길 수 있어요. 이 구간이 서버 idle timeout을 초과하면 연결이 끊겨요. keep-alive 핑 청크를 30초마다 보내거나, FastAPI의 timeout 설정을 늘리는 두 가지 방법 중 하나를 써야 해요.

시나리오 3: 응답이 완료됐는데 클라이언트가 스트림 종료를 못 감지해요

스트림 끝에 data: [DONE]\n\n을 명시적으로 보내야 해요. EventSource API는 이걸 보고 스트림이 끝났다고 판단해요. 없으면 클라이언트가 계속 연결을 열어두는데, 이게 쌓이면 서버 리소스 문제로 이어져요.


앞으로 뭘 지켜봐야 하나

2026년 현재 FastAPI 팀은 SSE 관련 유틸리티를 표준 라이브러리에 추가하는 논의를 진행 중이에요 (FastAPI GitHub Discussions #4953 참고). 지금은 sse-starlette 같은 서드파티 라이브러리를 쓰거나 직접 구현해야 하는데, 표준 지원이 들어오면 보일러플레이트가 크게 줄어들 거예요.

Anthropic SDK 쪽에서도 주시할 게 있어요. anthropic Python SDK가 0.25 이상으로 올라가면서 스트리밍 관련 내부 버퍼링 로직이 개선됐어요. 특히 멀티바이트 문자 경계 처리가 SDK 레벨에서 일부 보정되는 방향으로 가고 있어요. 현재 SDK 버전을 핀해서 쓰고 있다면 업그레이드 릴리즈 노트를 꼭 확인하세요.


정리하면 이래요.

  • UTF-8 멀티바이트 경계 문제 → encode("utf-8") 명시 + ensure_ascii=False
  • Content-Type 미설정 → text/event-stream + 캐시 헤더
  • Nginx 버퍼링 → 설정 파일과 응답 헤더 양쪽 다
  • 동기/비동기 혼용 → async 제너레이터로 통일
  • 스트림 종료 미알림 → [DONE] 토큰 명시

Claude API 스트리밍 응답을 FastAPI에 붙이는 건 구조 자체는 단순해요. 그런데 한국어 인코딩과 SSE 헤더, 배포 환경의 버퍼링이 겹치는 순간 디버깅이 꽤 긴 싸움이 돼요.

지금 서비스에서 간헐적으로 청크 깨짐이 나온다면, 먼저 Nginx 설정부터 확인해보세요. 경험상 절반 이상이 거기서 해결됐어요.

참고자료

  1. 문제 해결 - Claude Code Docs
  2. 17. 트러블슈팅 - 클로드 코드 가이드
  3. [Claude] Claude API Python SDK 사용법 완벽 가이드

Photo by Bernd 📷 Dittrich on Unsplash