CPN 한국어 자습서 · Claude Certified Developer — Foundations Prep
2-5 · 스트리밍 응답
Streaming responses and handling partial output without corrupting state
지금까지의 요청은 응답이 통째로 도착한 뒤에야 그다음 일을 시작했습니다. 응답이 짧을 때는 괜찮지만, 응답이 길어지거나 사용자가 빈 화면을 보며 기다리는 상황이라면 사정이 달라집니다. 스트리밍은 모델이 생성하는 대로 응답을 조각으로 나눠 보내는 방식입니다. 체감 속도가 빨라지는 대신, 코드에는 새 역할이 생깁니다. 이어지는 이벤트를 받아 최종 내용을 직접 조립해야 하고, 그 흐름이 도중에 끊기는 경우에도 대비할 필요가 있습니다.
이 장에서 배우는 것What you'll learn
약 25분스트리밍 요청에서 응답이 일련의 이벤트로 바뀌는 것
여섯 가지 이벤트 — 각각이 알리는 것과 핸들러가 할 일
부분 블록에 반응하지 않기 — tool_use 입력을 파싱해도 되는 시점
스트림이 도중에 끊겼을 때 부분 턴을 다루는 방법
반쯤 쓰인 도구 호출이 대화 기록에 남은 사후 분석 사례
깨진 스트림 핸들러를 직접 고치는 점검 과제
스트리밍이 없는 요청에서는 API가 완성된 메시지 하나를 건네줍니다. 모든 콘텐츠 블록이 완전한 형태로 담겨 있습니다. 스트리밍 요청에서는 대신, 메시지가 만들어지는 과정을 설명하는 이벤트가 순서대로 옵니다. 코드는 이 이벤트들을 받으면서 블록을 다시 조립합니다.
최종적으로 얻는 메시지는 스트리밍 없이 호출했을 때와 완전히 같습니다. 다른 점은 두 가지입니다. 조각을 조립하는 일이 코드의 몫이 되고, 이벤트가 메시지 완성 전에 멈췄을 때 어떻게 할지도 코드가 정해야 합니다.
여기서 일어나지 않는 일을 짚어 두면 이해에 도움이 됩니다. 모델이 살아 있는 객체 하나를 열어 두고 계속 갱신해 주는 것이 아닙니다. 각 이벤트는 변경 사항 하나를 설명하는 작은 메시지입니다. 블록이 시작됐다, 텍스트나 입력이 조금 추가됐다, 블록이 끝났다, 메시지 전체가 끝났다 — 이런 식입니다. 핸들러는 이벤트를 하나씩 받아, 그동안 쌓아 온 부분 상태에 적용합니다.
완성품 하나를 배송받는 대신, 부품이 든 상자를 차례로 받아 직접 조립하는 방식입니다. 마지막 상자가 도착하기 전까지는 아직 완성품이 아닙니다.
스트림에서 오는 이벤트는 여섯 가지입니다. 각 이벤트가 무엇을 알리는지, 그리고 핸들러가 무엇을 하면 되는지를 표로 정리했습니다.
EVENT SEQUENCE — 여섯 가지 이벤트와 핸들러의 일
| 이벤트 | 무엇을 알리는가 | 핸들러가 할 일 |
|---|---|---|
| message_start | 새 메시지가 시작된다. 내용이 빈 메시지 뼈대와 초기 사용량 정보를 담고 온다. | 블록을 모아 둘 빈 콘텐츠 배열을 준비한다. |
| content_block_start | 새 콘텐츠 블록이 열린다. 블록 타입(text·tool_use·thinking)과 인덱스를 알려 준다. | 그 인덱스에 해당 타입의 블록 자리를 만든다. tool_use 블록은 이름과 id를 갖고 열리지만, 입력은 아직 없다. |
| content_block_delta | 블록 하나의 증분 조각 — 텍스트 조각, 도구 호출 입력 JSON의 조각, 또는 사고 조각. | 그 인덱스의 블록에 조각을 이어 붙인다. 도구 호출 입력은 여러 델타에 나뉜 부분 JSON 문자열로 오기 때문에, 블록이 닫히기 전에는 파싱할 수 없다. |
| content_block_stop | 이 인덱스의 블록이 완성됐다. | 블록을 마무리한다. tool_use 블록이라면, 쌓인 JSON 입력을 파싱해도 되는 첫 시점이 여기다. |
| message_delta | 메시지 최상위 변경 — stop_reason과 최종 사용량. | stop_reason을 기록한다. 모델이 끝까지 생성했는지, 다른 이유로 멈췄는지 여기서 알 수 있다. |
| message_stop | 스트림이 완료됐다. | 조립된 콘텐츠 배열이 이제 완성된 메시지다. 여기서부터는 스트리밍 없는 응답과 똑같이 다루면 된다. |
상태가 어긋나는 것을 막아 주는 규칙은 하나입니다. 완성되지 않은 블록에 반응하지 않는 것입니다. 특히 지켜봐야 할 것이 tool_use 블록입니다.
tool_use 블록의 입력은 여러 content_block_delta 이벤트에 나뉜 부분 JSON 문자열로 도착하는데, 이 문자열은 content_block_stop이 블록을 닫기 전까지 유효한 JSON이 아닙니다. 블록이 닫히기 전에 입력을 파싱문자열을 프로그램이 다룰 수 있는 구조로 해석하는 일.하거나 도구를 실행하려 하면, 형식이 깨진 JSON에서 에러가 나거나 인자 절반이 빠진 채 실행되는 경우가 생깁니다. 그래서 규칙은 단순합니다. 델타는 모으기만 하고, 그 블록의 content_block_stop이 온 뒤에만 움직입니다.
같은 원칙이 스트리밍으로 받은 어시스턴트 턴대화에서 한 차례의 메시지. 사용자 턴과 어시스턴트 턴이 번갈아 쌓인다.을 대화 히스토리(지금까지 주고받은 턴의 목록)에 추가할 때도 적용됩니다. message_stop이 도착해 모든 블록이 완전히 조립된 뒤에만 추가합니다. 끊긴 스트림으로 만든 턴은 불완전한 턴이고, 반쯤 만들어진 tool_use 블록이 히스토리에 들어가면 tool_use 짝 맞춤 규칙에 걸려 다음 요청이 거부됩니다.
델타는 모으기만 합니다. 도구 실행은 블록이 닫힌 뒤에, 히스토리 저장은 메시지가 끝난 뒤에 합니다.
스트림은 도중에 실패하기도 합니다. 네트워크 연결이 끊기거나, 타임아웃이 나거나, 클라이언트가 접속을 끊으면 message_stop이 도착하기 전에 이벤트가 끊길 수 있습니다. 이때 실제로 문제가 되는 것은, 그때까지 모은 것을 완성본처럼 다루는 경우입니다.
부분 텍스트 블록이 사용자 화면에 보이는 것은 표시가 잠깐 어색해지는 정도로 끝납니다. 하지만 부분 tool_use 블록이 히스토리에 기록되면, 다음 턴까지 어긋나게 만드는 구조 문제가 됩니다.
stop_reason이 tool_use라면 조립된 도구 호출을 실행할 준비가 된 것이고, 다른 값이라면 도구 경로가 아닌 다른 경로에 있는 것입니다.스트리밍 자체를 언제 쓰고 언제 피할지도 원문은 세 갈래로 정리합니다.
WHEN TO STREAM — 세 가지 판단 기준
| 잘 맞는 곳 | 긴 응답, 그리고 생성되는 대로 출력을 보여 줘 빈 화면 대기를 없앨 수 있는 사용자 화면. |
|---|---|
| 비용·복잡도 | 블록 조립을 직접 해야 하고, 부분 블록에 반응하면 안 되고, 스트림 중간의 중단도 명시적으로 처리해야 합니다. |
| 다른 방식이 나은 곳 | 짧은 응답이나, 출력을 기다리는 사람이 없는 백엔드 작업. 스트리밍 없는 호출이 더 단순하고, 부분 상태 위험 자체가 사라집니다. |
스트리밍 응답은 화면에서는 멀쩡해 보이면서도 다음 요청을 어긋나게 만들 수 있습니다. 텍스트는 렌더링됐고, 사용자는 답을 봤고, 핸들러는 턴을 히스토리에 추가했습니다. 핸들러가 잡아내지 못한 것은 스트림이 블록 도중에 끊겼다는 사실입니다. 저장된 tool_use 호출에는 입력 절반이 빠져 있었고, 다음 요청은 검증에서 실패합니다. 에러는 원인이 된 스트림이 아니라 다음 턴을 가리킵니다.
원문이 소개하는 사후 분석 사례를 따라가 보겠습니다. 한 에이전트가 운영자들이 응답 생성을 실시간으로 지켜볼 수 있도록 스트리밍을 사용했습니다. 핸들러는 content_block_delta 이벤트를 누적하다가, 읽기 루프가 끝나면 어시스턴트 턴을 히스토리에 추가했습니다. 빠른 로컬 연결에서 테스트할 때는 스트림이 항상 끝까지 이어졌기 때문에, 루프는 항상 message_stop에서 끝났고 저장되는 턴도 항상 완전했습니다.
프로덕션에서는 달랐습니다. 네트워크가 잠깐 끊기면서, tool_use 블록이 열리고 JSON 입력 일부를 받은 뒤 content_block_stop이 오기 전에 스트림 하나가 끝났습니다. 읽기 루프는 평소와 같은 방식으로 끝났고, 핸들러는 평소처럼 턴을 추가했습니다. 입력 문자열이 도중에 잘린 tool_use 블록을 담은 어시스턴트 턴이었습니다. 운영자는 부분 답변을 보고 다시 시도했고, 그 재시도 요청에는 손상된 턴이 히스토리에 포함되어 있었습니다. API는 형식이 깨진 tool_use 블록을 가리키는 검증 에러와 함께 요청을 거부했습니다.
팀은 스키마와 재시도 로직을 살피는 데 오후를 통째로 썼습니다. 에러가 재시도 요청에서 나타났기 때문입니다. 하지만 실제 원인은 더 앞에 있었습니다. 핸들러가 '읽기 루프가 끝났다'를 '메시지가 완성됐다'와 같은 뜻으로 다루고 있었는데, 이 둘은 같지 않습니다.
원문의 점검 과제는 직접 고쳐 쓰는 서술형입니다. 아래 핸들러는 응답을 스트리밍으로 받아 어시스턴트 턴을 대화 히스토리에 추가하는데, 스트림이 끊겼을 때만 드러나는 결함이 하나 있습니다. 어디가 잘못됐는지 먼저 찾아보고, 고친 버전을 직접 써 보세요.
BROKEN HANDLER — 결함이 하나 있는 스트림 핸들러
blocks = {} stop_seen = False with client.messages.stream(model=model, max_tokens=4096, messages=messages, tools=tools) as stream: for event in stream: if event.type == "content_block_start": blocks[event.index] = init_block(event) elif event.type == "content_block_delta": apply_delta(blocks[event.index], event.delta) elif event.type == "message_stop": stop_seen = True messages.append({"role": "assistant", "content": assemble(blocks)})
MODEL ANSWER — 원문이 제시한 모범 답안
blocks = {} stop_seen = False with client.messages.stream(model=model, max_tokens=4096, messages=messages, tools=tools) as stream: for event in stream: if event.type == "content_block_start": blocks[event.index] = init_block(event) elif event.type == "content_block_delta": apply_delta(blocks[event.index], event.delta) elif event.type == "message_stop": stop_seen = True # 스트림이 정상 종료됐을 때만 히스토리에 추가 if stop_seen: messages.append({"role": "assistant", "content": assemble(blocks)}) else: raise StreamInterruptedError( "Stream ended before message_stop; discarding partial turn. Retry from the last complete turn." )
결함은 append가 message_stop 도착 여부와 상관없이 실행된다는 것입니다. 스트림이 끊기면, 반쯤 만들어진 tool_use 블록까지 포함될 수 있는 부분 턴이 히스토리에 저장됩니다. append를 stop_seen 조건으로 걸면 완성된 메시지만 히스토리에 들어갑니다. 중단됐을 때 예외를 던지면, 형식이 깨진 블록으로 컨텍스트를 오염시키는 대신 마지막 완전한 턴부터 요청을 다시 시도하게 됩니다.
고친 코드가 append를 stop_seen 조건으로 걸고, 중단됐을 때 예외를 던지면 통과입니다.
이어서 객관식으로 점검합니다. 정답을 먼저 떠올려 본 뒤 골라 보세요. 맞히면 설명이 나옵니다.
Q1위 핸들러의 결함은 무엇이었을까요?
append가 with 블록 밖에서 무조건 실행되고 있어서, message_stop 없이 끝난 스트림의 부분 턴도 히스토리에 저장됩니다. 델타를 인덱스별로 누적하는 부분은 올바른 구현이고, max_tokens는 이 결함과 관련이 없습니다. 모범 답안은 append를 stop_seen 조건으로 걸고, 중단 시 예외를 던져 마지막 완전한 턴부터 다시 시도하게 합니다.
Q2tool_use 블록의 입력 JSON을 파싱해도 되는 첫 시점은 언제일까요?
입력은 여러 델타에 나뉜 부분 JSON 문자열로 오기 때문에, 블록이 닫히기 전에는 유효한 JSON이 아닙니다. content_block_start는 이름과 id만 갖고 열리고 입력은 아직 없습니다. 그래서 델타는 모으기만 하고, content_block_stop 이후에 파싱합니다.
Q3재시도 요청에서 tool_use 검증 에러가 났습니다. 스키마를 고치기 전에 먼저 확인해 볼 것은 무엇일까요?
사후 분석 사례에서 본 그대로입니다. 에러는 재시도 요청에서 나타나지만, 원인은 끊긴 스트림에서 조립된 직전 턴에 있는 경우가 있습니다. 원문도 스키마를 손대기 전에 직전 턴이 스트림에서 만들어졌는지부터 확인하라고 안내합니다. 한도 조정이나 필드 추가는 잘린 JSON 입력이라는 원인과 관련이 없습니다.
모듈 2의 다음 레슨은 컨텍스트 엔지니어링(Context Engineering)을 다룹니다. → 2-6 · 컨텍스트 엔지니어링
전 코스는 계속 무료입니다. 등록하면 이 코스의 남은 38개 레슨을 끝까지 읽을 수 있습니다.
이미 등록하셨다면 그때 쓰신 이메일을 넣어 주세요.