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분응답 생성에는 10–30초가 걸릴 수 있고, 그동안 스피너만 보여 주면 경험이 나쁘다
스트리밍은 응답을 한 번에 받지 않고 생성되는 대로 조각(이벤트)으로 받는다
create()에 stream=True를 주면 이벤트 이터레이터가 돌아온다 — Raw*Event
실제 텍스트를 담는 건 RawContentBlockDeltaEvent 하나다
client.messages.stream() + stream.text_stream으로 텍스트만 간편히 받는다
스트림이 끝나면 stream.get_final_message()로 전체 메시지를 조립한다
RawContentBlockDeltaEvent의 delta.text에 새로 생성된 텍스트가 담긴다.챗 인터페이스를 다시 떠올려 봅시다. 사용자가 질문을 보내면 서버가 user message로 Claude에 전달하고, assistant message를 받아 텍스트를 꺼내 화면에 표시합니다. 문제는 보내고 받기까지 걸리는 시간입니다.
스트리밍은 이 문제를 해결합니다. 서버가 보낸 요청에 Claude가 먼저 초기 응답을 돌려주는데, 여기엔 텍스트가 없습니다 — "요청을 받았고 곧 생성을 시작한다"는 신호일 뿐입니다. 이어서 생성된 텍스트 조각이 이벤트로 흘러오고, 서버는 받는 즉시 앱으로 내려보내 화면에 이어 붙입니다.
스트리밍 없음 한 번에
스트리밍 조각으로
왼쪽은 응답이 끝날 때까지(여기선 2.6초) 스피너만 돌다 한 번에 표시됩니다. 오른쪽은 초기 응답 직후부터 텍스트가 조각으로 채워져, 첫 글자가 거의 즉시 보입니다. 총 시간이 비슷해도 체감은 전혀 다릅니다.
스트리밍을 켜는 가장 기본적인 방법은 create()에 stream=True를 주는 것입니다. 그러면 최종 답 대신 이벤트 이터레이터가 돌아옵니다. 일반 이터레이터라서 for 문으로 순회할 수 있습니다.
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 하나뿐입니다. 나머지는 메시지·블록의 시작과 끝을 알리는 신호입니다. 아래에서 스트림을 실행해 직접 확인하세요.
조립되는 텍스트 · stream.text_stream
✓ message_stop · 전체 메시지 조립 가능 (get_final_message)
실제 텍스트를 담는 건 RawContentBlockDeltaEvent(주황색)뿐입니다. 나머지는 메시지·블록의 시작과 끝을 알리는 신호이고, delta들의 text를 이어 붙이면 완성된 응답이 됩니다.
for 루프에서 이벤트 종류를 일일이 가려내 텍스트만 꺼내는 건 번거롭습니다. SDK의 client.messages.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.with 블록으로 감싸고 as stream으로 받습니다.stream()은 이미 스트리밍이라 stream=True는 필요 없습니다.stream.text_stream을 순회하면 text에 텍스트 조각만 담깁니다.print(text, end="")로 줄바꿈 없이 이어 붙입니다.각 조각(text)은 여러 단어를 담을 수 있습니다. 이벤트 하나에 단어 하나만 오는 게 아닙니다.
사용자에겐 조각을 실시간으로 보여 주되, 스트림이 끝나면 전체 메시지를 한 덩어리로 저장하고 싶을 때가 많습니다 (예: 대화 기록 DB). 루프 본문은 pass로 비워도 되고, 동시에 앱으로 내려보내도 됩니다.
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(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))get_final_message()가 이벤트들을 모아 완성된 객체를 돌려준다. 저장·후처리에 쓴다.create(..., stream=True) → 이벤트 이터레이터. 실제 텍스트는 RawContentBlockDeltaEvent에 담긴다.client.messages.stream() + stream.text_stream으로 텍스트만 간편히 받는다.stream.get_final_message()로 전체 메시지를 조립한다.Q1스트리밍을 켜면 Claude가 가장 먼저 보내는 초기 응답에는?
초기 응답은 본문이 없는 신호입니다. 실제 텍스트는 뒤따르는 delta 이벤트로 옵니다.
Q2스트림 이벤트 중 실제 생성된 텍스트를 담는 것은?
delta 이벤트의 delta.text에 새로 생성된 텍스트 조각이 담깁니다.
Q3조각을 모두 보여 준 뒤, 전체 응답을 한 객체로 저장하려면?
get_final_message()가 이벤트들을 조립해 완성된 Message를 돌려줍니다. 직접 이어 붙일 수도 있지만 SDK가 대신 해 줍니다.
조각(이벤트)으로 흘러오는 응답을 순회해, delta.text만 이어 붙여 완성된 메시지로 되돌립니다.
// 스트림 이벤트 재조립기 — 스트리밍은 응답을 한 번에 받지 않고,
// 생성되는 대로 조각(이벤트)으로 받는다. 실제 텍스트를 담는 건
// 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);
이제 응답을 조각으로 받아 즉시 보여 줄 수 있습니다. 다음은 Claude의 응답을 사람이 읽는 문장이 아니라 프로그램이 바로 쓸 수 있는 구조화된 형태로 받아내는 방법입니다. → 구조화된 데이터
전 코스는 계속 무료입니다. 등록하면 이 코스의 남은 76개 레슨을 끝까지 읽을 수 있습니다.
이미 등록하셨다면 그때 쓰신 이메일을 넣어 주세요.