byteforce

CPN 한국어 자습서 · 러닝패스 2 / 4 — Building with the Claude API

API로 Claude에 접근하기

응답 스트리밍

Response streaming

응답 전체를 기다렸다 한 번에 받지 않고, Claude가 생성하는 대로 조각조각 받아 화면에 즉시 표시하는 방법입니다. stream=True로 오는 이벤트의 구조를 살펴보고, stream.text_stream으로 텍스트만 간편하게 받아 봅니다.

전체 내레이션영상 나레이션 한국어 번역 (전체)

Stephen Grider · Anthropic 기술 스태프

이 섹션 앞부분에서 본 챗 인터페이스 예시로 돌아가 봅시다. 웹 앱이나 모바일 앱 안에 챗 창이 떠 있고, 사용자가 질문을 입력하면 그게 우리 서버로 전송됩니다. 서버는 그 내용을 user message로 만들어 Claude에 보내고, Claude는 assistant message를 돌려줍니다. 우리는 거기서 텍스트를 꺼내 다시 앱으로 내려보내고, 그 내용이 화면에 표시됩니다.

여기까지는 단순하고 쉬워 보이지만, 아직 다루지 않은 문제가 하나 있습니다. user message를 Claude에 보내고 assistant message를 돌려받기까지 걸리는 시간이 생각보다 훨씬 길어질 수 있다는 점입니다. 입력·출력 메시지 크기에 따라 10초, 길게는 30초까지 걸리기도 합니다.

사용자가 응답을 기다리는 이 시간 동안 화면에 스피너만 돌릴 수도 있습니다. 하지만 그건 좋은 사용자 경험이 아닙니다. 대부분의 사용자는 "양자 컴퓨팅이 뭐야?" 같은 첫 메시지를 보내면 거의 즉시 화면에 응답이 나타나기 시작하길 기대합니다.

더 나은 경험을 위해 스트리밍(streaming)이라는 기법을 씁니다. 서버는 여전히 user message를 Claude에 보냅니다. 그런데 Claude는 거의 즉시 초기 응답(initial response)을 돌려줍니다. 이 초기 응답에는 사실 텍스트 내용이 없습니다. 단지 Claude가 요청을 받았고, 이제 텍스트 생성을 시작하려 한다는 신호일 뿐입니다.

그다음부터 우리는 이벤트(event)의 스트림을 받기 시작합니다. 각 이벤트는 생성 중인 응답의 일부 조각을 담고 있습니다. 받는 이벤트 개수는 생성하는 텍스트 양에 따라 달라집니다. 첫 이벤트는 "Quantum", 다음은 "computing", 그다음은 "is…" 식이죠. 다만 이벤트 하나가 꼭 단어 하나만 담는 건 아닙니다. 여러 단어, 때로는 문장 전체가 들어올 수도 있습니다. Claude가 각 조각을 만드는 데 걸리는 시간에 달려 있습니다.

서버는 이 이벤트들을 받아, 각 이벤트에서 텍스트를 꺼내 즉시 앱으로 내려보낼 수 있습니다. 그러면 그 작은 조각이 화면에 표시됩니다. 받는 이벤트마다 이 과정을 반복하면, 사용자는 챗 인터페이스에서 텍스트가 조각조각 나타나는 걸 보게 됩니다.

노트북으로 돌아가 코드를 봅시다. 헬퍼 함수 세 개는 그대로 두되, 이번 영상에서는 chat 함수를 쓰지 않습니다. 스트리밍을 쓰기 시작하면 지금 구현된 chat 함수와는 잘 맞지 않기 때문입니다. 대신 messages 리스트를 직접 만들고 client.messages.create를 직접 호출합니다.

빈 messages 리스트를 만들고, user message로 "Write a 1 sentence description of a fake database"를 추가합니다. 그리고 client.messages.create를 호출하면서 model, max_tokens, messages를 넘기고, 마지막에 stream=True를 추가합니다. 이러면 최종 답이 아니라 여러 이벤트의 스트림이 돌아옵니다. 이건 일반 이터레이터라서 for event in stream으로 순회하며 print(event)로 찍어 볼 수 있습니다.

실행하면 이벤트 스트림이 화면에 빠르게 찍힙니다. 맨 처음은 RawMessageStartEvent, 그다음 RawContentBlockStartEvent, 그리고 RawContentBlockDeltaEvent가 여러 개 이어집니다. 끝으로 RawContentBlockStopEvent, RawMessageDeltaEvent, RawMessageStopEvent가 나옵니다. 모두 하나의 요청 안에서 Claude가 보내는 이벤트입니다.

각 이벤트는 전체 응답에서 나름의 의미가 있지만, 우리가 보통 가장 신경 쓰는 건 RawContentBlockDeltaEvent입니다. 실제로 Claude가 생성한 텍스트가 조각조각 담겨 오는 이벤트가 바로 이것입니다. 보통은 같은 순서가 반복됩니다 — message start, content block start, 그다음 content block delta들. 그래서 우리는 보통 이 이벤트들을 모아 텍스트만 뽑아 앱으로 내려보냅니다.

for 루프 안에서 이벤트 종류를 일일이 확인해 delta일 때만 텍스트를 꺼낼 수도 있지만, 코드가 번거로워집니다. 다행히 Anthropic SDK는 더 간편한 스트리밍 방식을 제공합니다. 텍스트만 쉽게 꺼낼 수 있는 방식이고, 텍스트야말로 우리가 대개 진짜 필요로 하는 부분입니다.

다음 셀에서 보겠습니다. 다시 messages 리스트를 만들고 같은 user message를 넣습니다. 이번엔 with 블록으로 client.messages.stream을 감쌉니다. 안에 model, max_tokens, messages를 넣되 stream=True는 필요 없습니다. as stream으로 받고, for text in stream.text_stream으로 순회합니다. 이제 text는 그 이벤트들의 텍스트 부분만 담습니다.

print(text)로 찍되 end=""를 줘서 줄바꿈 없이 이어 붙입니다. 실행하면 응답이 조각조각 스트리밍되어 들어오는 게 보입니다. 각 조각은 여러 단어를 담기도 합니다 — 이벤트 하나에 단어 하나만 보장되는 게 아닙니다.

마지막 기능 하나 더. 스트리밍으로 사용자에게 조각을 보여주는 것과 별개로, 스트림이 끝난 뒤 전체 메시지를 데이터베이스에 저장하고 싶은 경우가 많습니다. 그러면 print를 pass로 바꾸고, 스트림이 끝난 뒤 stream.get_final_message()를 호출합니다. 그러면 받은 이벤트들을 하나의 최종 메시지로 조립해 줍니다. 이걸 DB에 저장하거나 다른 용도로 쓰면 됩니다.

이 장에서 배우는 것What you'll learn

약 8분
1

응답 생성에는 10–30초가 걸릴 수 있고, 그동안 스피너만 보여 주면 경험이 나쁘다

2

스트리밍은 응답을 한 번에 받지 않고 생성되는 대로 조각(이벤트)으로 받는다

3

create()stream=True를 주면 이벤트 이터레이터가 돌아온다 — Raw*Event

4

실제 텍스트를 담는 건 RawContentBlockDeltaEvent 하나다

5

client.messages.stream() + stream.text_stream으로 텍스트만 간편히 받는다

6

스트림이 끝나면 stream.get_final_message()로 전체 메시지를 조립한다

먼저 짚고 갈 용어
streaming (스트리밍)
응답 전체를 기다리지 않고, 생성되는 대로 조각으로 받아 화면에 즉시 표시하는 기법.
event (이벤트)
스트림으로 오는 한 조각. 메시지 시작·블록 시작·텍스트 델타·종료 등 종류가 있다.
delta (델타)
직전까지와의 "차이분". RawContentBlockDeltaEventdelta.text에 새로 생성된 텍스트가 담긴다.
text_stream
SDK가 이벤트에서 텍스트만 뽑아 순회하게 해 주는 간편 인터페이스.

왜 스트리밍인가

Why streaming

챗 인터페이스를 다시 떠올려 봅시다. 사용자가 질문을 보내면 서버가 user message로 Claude에 전달하고, assistant message를 받아 텍스트를 꺼내 화면에 표시합니다. 문제는 보내고 받기까지 걸리는 시간입니다.

스트리밍은 이 문제를 해결합니다. 서버가 보낸 요청에 Claude가 먼저 초기 응답을 돌려주는데, 여기엔 텍스트가 없습니다 — "요청을 받았고 곧 생성을 시작한다"는 신호일 뿐입니다. 이어서 생성된 텍스트 조각이 이벤트로 흘러오고, 서버는 받는 즉시 앱으로 내려보내 화면에 이어 붙입니다.

스트리밍 vs 한 번에 받기 · 같은 질문, 다른 사용자 경험

스트리밍 없음 한 번에

양자 컴퓨팅이 뭐야?

스트리밍 조각으로

양자 컴퓨팅이 뭐야?

왼쪽은 응답이 끝날 때까지(여기선 2.6초) 스피너만 돌다 한 번에 표시됩니다. 오른쪽은 초기 응답 직후부터 텍스트가 조각으로 채워져, 첫 글자가 거의 즉시 보입니다. 총 시간이 비슷해도 체감은 전혀 다릅니다.

이벤트의 구조

The event stream

스트리밍을 켜는 가장 기본적인 방법은 create()stream=True를 주는 것입니다. 그러면 최종 답 대신 이벤트 이터레이터가 돌아옵니다. 일반 이터레이터라서 for 문으로 순회할 수 있습니다.

004_streaming.ipynb · stream=True
model = "claude-sonnet-4-6"   # 영상은 Claude 3.7 Sonnet · 현행 최신 Sonnet

messages = []
add_user_message(messages, "Write a 1 sentence description of a fake database")

stream = client.messages.create(
    model=model,
    max_tokens=1000,
    messages=messages,
    stream=True,
)
for event in stream:
    print(event)
출력 · 원본 이벤트 스트림
RawMessageStartEvent(message=Message(id='msg_01Mqz…', content=[], model='claude-sonnet-4-6',
  role='assistant', stop_reason=None, …, usage=Usage(input_tokens=18, output_tokens=2)), type='message_start')
RawContentBlockStartEvent(content_block=TextBlock(text='', type='text'), index=0, type='content_block_start')
RawContentBlockDeltaEvent(delta=TextDelta(text='"Re', type='text_delta'), index=0, …)
RawContentBlockDeltaEvent(delta=TextDelta(text='almDB: A cutting-edge, quantum', …), index=0, …)
RawContentBlockDeltaEvent(delta=TextDelta(text='-encrypted NoSQL database platform', …), …)
…  (content_block_delta 이벤트가 텍스트 조각만큼 계속)  …
RawContentBlockStopEvent(index=0, type='content_block_stop')
RawMessageDeltaEvent(delta=Delta(stop_reason='end_turn', …), usage=MessageDeltaUsage(output_tokens=48))
RawMessageStopEvent(type='message_stop')

돌아오는 이벤트는 보통 같은 순서입니다. 종류별 의미는 이렇습니다.

이벤트 타입의미
RawMessageStartEvent새 메시지가 시작됨 (본문 없음)
RawContentBlockStartEvent새 콘텐츠 블록 시작 — 텍스트·도구 사용 등
RawContentBlockDeltaEvent실제 생성된 텍스트 조각 — 직전 블록에 이어진다
RawContentBlockStopEvent현재 콘텐츠 블록이 완료됨
RawMessageDeltaEvent메시지 단위 변경 — stop_reason 등 마무리 정보
RawMessageStopEvent현재 메시지 정보의 끝
핵심

이 중 실제 생성 텍스트를 담는 건 RawContentBlockDeltaEvent 하나뿐입니다. 나머지는 메시지·블록의 시작과 끝을 알리는 신호입니다. 아래에서 스트림을 실행해 직접 확인하세요.

이벤트 스트림 인스펙터 · for event in stream

조립되는 텍스트 · stream.text_stream

✓ message_stop · 전체 메시지 조립 가능 (get_final_message)

실제 텍스트를 담는 건 RawContentBlockDeltaEvent(주황색)뿐입니다. 나머지는 메시지·블록의 시작과 끝을 알리는 신호이고, delta들의 text를 이어 붙이면 완성된 응답이 됩니다.

간편 스트리밍: text_stream

Simplified text streaming

for 루프에서 이벤트 종류를 일일이 가려내 텍스트만 꺼내는 건 번거롭습니다. SDK의 client.messages.stream()을 쓰면 텍스트만 바로 받을 수 있습니다 — 대개 우리가 진짜 필요로 하는 부분입니다.

004_streaming.ipynb · stream() + text_stream
messages = []
add_user_message(messages, "Write a 1 sentence description of a fake database")

with client.messages.stream(
    model=model,
    max_tokens=1000,
    messages=messages,
) as stream:
    for text in stream.text_stream:
        print(text, end="")
출력 · 스트리밍 텍스트
The "QuantumVault" is a fictional database that claims to store data in quantum
particles scattered across parallel dimensions, accessible only through special quantum
entanglement algorithms that mysteriously never require maintenance or experience downtime.
유의

각 조각(text)은 여러 단어를 담을 수 있습니다. 이벤트 하나에 단어 하나만 오는 게 아닙니다.

전체 메시지 조립: get_final_message()

Getting the complete message

사용자에겐 조각을 실시간으로 보여 주되, 스트림이 끝나면 전체 메시지를 한 덩어리로 저장하고 싶을 때가 많습니다 (예: 대화 기록 DB). 루프 본문은 pass로 비워도 되고, 동시에 앱으로 내려보내도 됩니다.

004_streaming.ipynb · get_final_message()
messages = []
add_user_message(messages, "Write a 1 sentence description of a fake database")

with client.messages.stream(
    model=model,
    max_tokens=1000,
    messages=messages,
) as stream:
    for text in stream.text_stream:
        # print(text, end="")  ← 화면 표시는 생략
        pass

final_message = stream.get_final_message()   # 이벤트들을 하나로 조립
print(final_message)
출력 · 조립된 Message 객체
Message(id='msg_01SV4…', content=[TextBlock(
  text='A comprehensive virtual repository called "ByteSync" seamlessly integrates
        non-existent data across illusory platforms while maintaining the appearance of
        robust functionality despite being entirely fabricated.', type='text')],
  model='claude-sonnet-4-6', role='assistant', stop_reason='end_turn',
  usage=Usage(input_tokens=18, output_tokens=40))
두 마리 토끼
  • 실시간 스트리밍 — 사용자는 텍스트가 채워지는 걸 즉시 본다.
  • 조립된 Messageget_final_message()가 이벤트들을 모아 완성된 객체를 돌려준다. 저장·후처리에 쓴다.

정리 & 점검

Recap & check
핵심 정리
  • 응답을 한 번에 기다리는 대신, 생성되는 대로 조각으로 받는 게 스트리밍이다.
  • create(..., stream=True) → 이벤트 이터레이터. 실제 텍스트는 RawContentBlockDeltaEvent에 담긴다.
  • client.messages.stream() + stream.text_stream으로 텍스트만 간편히 받는다.
  • 각 조각은 단어 하나가 아니라 여러 단어일 수 있다.
  • 스트림이 끝나면 stream.get_final_message()로 전체 메시지를 조립한다.

Q1스트리밍을 켜면 Claude가 가장 먼저 보내는 초기 응답에는?

Q2스트림 이벤트 중 실제 생성된 텍스트를 담는 것은?

Q3조각을 모두 보여 준 뒤, 전체 응답을 한 객체로 저장하려면?


LAB · 실습 콘솔STREAM EVENTS

스트림 이벤트 재조립기

조각(이벤트)으로 흘러오는 응답을 순회해, delta.text만 이어 붙여 완성된 메시지로 되돌립니다.

index.js
// 스트림 이벤트 재조립기 — 스트리밍은 응답을 한 번에 받지 않고,
// 생성되는 대로 조각(이벤트)으로 받는다. 실제 텍스트를 담는 건
// content_block_delta 이벤트 하나뿐이고, 그 delta.text 를 이어 붙이면
// 완성된 응답이 된다(SDK의 get_final_message 가 하는 일).

// 조각 목록으로부터 하나의 요청이 만들어 내는 이벤트 스트림을 구성한다.
// 실제 순서: message_start → content_block_start → content_block_delta … →
//            content_block_stop → message_delta(stop_reason) → message_stop
function buildStream(chunks) {
  const events = [];
  events.push({ type: "message_start" });                 // 본문 없음 — 시작 신호
  events.push({ type: "content_block_start", index: 0 }); // 새 텍스트 블록 시작
  chunks.forEach(function (text) {
    // 조각 하나가 꼭 단어 하나는 아니다 — 여러 단어가 한 이벤트에 오기도 한다
    events.push({ type: "content_block_delta", index: 0, delta: { text: text } });
  });
  events.push({ type: "content_block_stop", index: 0 });
  events.push({ type: "message_delta", stop_reason: "end_turn" });
  events.push({ type: "message_stop" });
  return events;
}

// 스트림을 순회하며 delta.text 만 이어 붙여 최종 메시지를 조립한다.
function reconstruct(events) {
  let text = "";
  let stopReason = null;
  const counts = {};
  events.forEach(function (ev) {
    counts[ev.type] = (counts[ev.type] || 0) + 1;
    if (ev.type === "content_block_delta") text += ev.delta.text;  // 텍스트를 담는 유일한 이벤트
    if (ev.type === "message_delta") stopReason = ev.stop_reason;  // 마무리 정보
  });
  return { text: text, stopReason: stopReason, counts: counts };
}

// ── 여기서부터 직접 고쳐 보세요 ──
// Claude가 조각조각 생성한 텍스트라고 상상하세요. 조각을 바꾸거나 더 넣으면
// 이벤트 수와 재조립 결과가 그대로 따라 바뀝니다.
const chunks = [
  "The ",
  "\"QuantumVault\" ",
  "is a fictional database ",
  "that stores data in ",
  "quantum particles.",
];

const stream = buildStream(chunks);

console.log("── 이벤트 스트림 (" + stream.length + "개) ──");
stream.forEach(function (ev, i) {
  const carries = ev.type === "content_block_delta"
    ? "  ← 텍스트: " + JSON.stringify(ev.delta.text)
    : "";
  console.log("  " + (i + 1) + ". " + ev.type + carries);
});

const result = reconstruct(stream);

console.log("");
console.log("── 이벤트 종류별 개수 ──");
Object.keys(result.counts).forEach(function (t) {
  console.log("  " + t + " · " + result.counts[t] + "개");
});
console.log("텍스트를 담은 이벤트는 content_block_delta " +
  (result.counts["content_block_delta"] || 0) + "개뿐입니다.");

console.log("");
console.log("── get_final_message() 로 조립한 결과 ──");
console.log("stop_reason: " + result.stopReason);
console.log("text: " + result.text);
MEMBER SESSION REQUIRED · REGISTRATION IS FREE

여기부터는 등록한 분에게 열립니다.

전 코스는 계속 무료입니다. 등록하면 이 코스의 남은 76개 레슨을 끝까지 읽을 수 있습니다.

등록하고 이어서 읽기

이미 등록하셨다면 그때 쓰신 이메일을 넣어 주세요.