스트리밍 응답, 왜 붙이면 챗봇이 달라 보일까
Claude API 스트리밍 응답 구현 방법을 실제 코드와 함께 풀어봤습니다. 왜 쓰는지, 어떻게 붙이는지, 흔히 막히는 포인트까지 솔직하게 정리했습니다.
직접 만든 챗봇을 친구한테 보여줬는데 "야, 이거 ChatGPT랑 다르게 왜 다 쓰고 나서 한 번에 나와?" 라는 말 들어본 적 있어요? 저는 있습니다. 처음 API 붙일 때 응답을 그냥 기다렸다가 한꺼번에 출력하면 딱 그 느낌이 나거든요. 타이핑되는 것처럼 한 글자씩 흘러나오는 게 아니라, 3~4초 침묵하다가 텍스트가 와르르 쏟아지는. 기능은 똑같아도 "뭔가 다르다"는 인상을 주는 부분이 바로 이 스트리밍 구현 여부입니다.
이 글은 Claude API로 스트리밍 응답을 붙여보려는 분들 위한 거예요. 코드 몇 줄 보여주고 끝나는 게 아니라, 왜 이걸 써야 하는지부터 실제로 어디서 막히는지까지 같이 짚어볼게요.
스트리밍이 뭔지 모르면 왜 쓰는지도 모른다
스트리밍 응답이란 쉽게 말하면 이런 거예요. 모델이 응답 전체를 다 만들 때까지 기다렸다가 한 번에 보내는 게 아니라, 생성되는 토큰을 즉시즉시 클라이언트한테 흘려보내는 방식입니다. HTTP에서 Transfer-Encoding: chunked나 SSE(Server-Sent Events) 방식으로 구현돼요.
사용자 입장에서 체감 차이가 꽤 크거든요. 예를 들어 300토큰짜리 응답을 생성하는 데 3초가 걸린다고 할 때, 논스트리밍은 3초 기다렸다가 전부 나오고, 스트리밍은 0.1초 간격으로 한 단어씩 흘러나옵니다. 결과를 받는 총 시간은 같아도 느껴지는 속도가 완전히 달라요. 이게 UX에서 "지각된 응답성"이라고 부르는 겁니다. 실제로 빠른 게 아니라 빠른 것처럼 느껴지게 만드는 거죠.
Python에서 실제로 어떻게 붙이냐면
Claude API 기본 연결 방법은 Claude API, 처음 연결하는 날 생기는 일들에서 다뤘으니 거기서 기본 세팅을 먼저 하고 오면 더 빠르게 따라올 수 있어요.
기본 호출 코드에서 스트리밍으로 바꾸는 건 생각보다 간단합니다. 핵심은 stream=True 옵션 하나예요.
import anthropic
client = anthropic.Anthropic()
with client.messages.stream(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "양자역학 쉽게 설명해줘"}]
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
messages.create() 대신 messages.stream()을 쓰고, 반환된 스트림 객체에서 text_stream을 이터레이트하면 됩니다. flush=True는 꼭 넣어야 해요. 안 넣으면 버퍼링 때문에 터미널에서 한 번에 나오는 것처럼 보이거든요. 이거 빠뜨리고 "어? 스트리밍 안 되는데?" 하는 경우가 꽤 있습니다.
전체 응답 텍스트가 필요하면 루프 끝나고 stream.get_final_message()로 받으면 되고요.
웹 서비스에 붙이려면 SSE를 알아야 한다
터미널에서 출력하는 건 위에서 됐는데, 실제 웹 챗봇에 붙이려면 한 단계가 더 있어요. 브라우저까지 스트리밍을 전달해야 하니까요. 이 때 쓰는 게 SSE, 즉 Server-Sent Events입니다. 서버가 클라이언트한테 단방향으로 이벤트를 밀어주는 HTTP 기반 방식이에요.
FastAPI로 구현하면 이런 형태가 됩니다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import anthropic
app = FastAPI()
client = anthropic.Anthropic()
async def generate_stream(prompt: str):
with client.messages.stream(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "prompt"}]
) as stream:
for text in stream.text_stream:
yield f"data: {text}\n\n"
@app.get("/chat")
async def chat(q: str):
return StreamingResponse(
generate_stream(q),
media_type="text/event-stream"
)
media_type="text/event-stream"이 SSE를 활성화하는 부분입니다. data: {내용}\n\n 형식은 SSE 프로토콜 규격이에요. 이 포맷 안 지키면 프론트엔드에서 파싱이 안 됩니다. \n\n 두 개가 이벤트 하나의 끝을 나타내는 거거든요.
프론트에서는 EventSource API나 fetch의 ReadableStream으로 받으면 되고, 요즘은 fetch 방식을 더 많이 써요. EventSource는 GET 요청만 가능하다는 제약이 있어서.
이 지점에서 다들 한 번씩 막힌다
스트리밍 붙이다가 자주 걸리는 포인트 몇 가지를 솔직하게 짚을게요.
첫 번째는 에러 처리입니다. 논스트리밍은 응답 객체 하나에서 에러를 잡으면 됐는데, 스트리밍은 청크 중간에 에러가 날 수 있어요. 특히 네트워크가 끊기거나 rate limit에 걸리면 스트림 중간에 뚝 끊깁니다. try-except를 stream 컨텍스트 안에서 제대로 감싸야 하는데, 밖에만 두면 안 잡히는 경우가 있어요.
두 번째는 Nginx 리버스 프록시 설정. 로컬에선 잘 되는데 배포하면 스트리밍이 안 된다고 하는 경우의 90%는 여기서 걸립니다. Nginx 기본 설정이 응답을 버퍼링하거든요. proxy_buffering off;와 X-Accel-Buffering: no 헤더 설정을 안 하면 결국 다 모아서 한 번에 보냅니다. 스트리밍을 껐다 켰다 하는 거나 다름없어요.
세 번째는 토큰 과금 문제. 스트리밍으로 바꿨다고 해서 토큰 소비가 줄거나 늘지는 않아요. 그대로입니다. 다만 스트리밍 도중 사용자가 응답을 끊어도 이미 생성된 토큰은 과금됩니다. 이 부분은 Claude API 쓰다가 요금 폭탄 맞기 전에 알아야 할 것들을 한 번 보면 전체 과금 구조를 이해하는 데 도움이 돼요.
스트리밍 중에 다른 작업도 할 수 있어요
스트림에서 텍스트만 뽑는 게 전부가 아닙니다. 응답이 흘러오는 동안 특정 패턴을 실시간으로 감지하거나, 마크다운 코드 블록이 시작되는 순간 UI를 바꾸거나, 특정 키워드 나오면 다른 API 호출을 트리거하는 것도 가능해요.
Anthropic SDK에서 스트림 이벤트를 더 세밀하게 다루고 싶으면 text_stream 대신 stream 이터레이터를 직접 써요.
with client.messages.stream(...) as stream:
for event in stream:
if event.type == "content_block_delta":
delta = event.delta
if delta.type == "text_delta":
print(delta.text, end="", flush=True)
elif event.type == "message_stop":
print("\n스트림 종료")
이벤트 타입으로 message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop이 순서대로 들어옵니다. 이 구조를 알면 "응답 중에 취소 버튼 눌렀을 때 처리" 같은 기능도 만들 수 있어요.
그래서 언제 쓰고 언제 안 써도 되냐면
솔직히 말하면, 스트리밍이 항상 필요한 건 아니에요. 사용자가 직접 화면을 보는 인터랙티브 챗봇이라면 무조건 붙이는 게 맞아요. 응답 느낌이 완전히 달라지니까. 반면 백그라운드 배치 처리, 다른 API 결과로 넘기는 파이프라인, 응답 전체를 받아서 후처리하는 경우라면 스트리밍이 오히려 코드를 복잡하게 만들 수 있어요.
그리고 입력 토큰을 체계적으로 관리하고 있다면 스트리밍과 프롬프트 캐싱을 함께 쓰는 조합이 꽤 효율적입니다. 캐싱으로 입력 비용 줄이고, 스트리밍으로 UX 챙기는 거죠.
📌 한 줄 정리: 스트리밍 응답은
messages.stream()으로 시작하고, 웹 서비스엔 SSE 포맷으로 전달하고, 배포할 때 Nginx 버퍼링 설정 확인하는 것까지가 세트입니다.
- 터미널 출력:
stream.text_stream+flush=True- 웹 전달:
StreamingResponse+text/event-stream+data: ...\n\n포맷- 배포 주의: Nginx
proxy_buffering off필수- 에러·토큰 과금은 스트리밍 전환 후에도 그대로 적용