<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>seanson2709.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Tue, 22 Sep 2026 12:14:25 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>seanson2709.log</title>
            <url>https://velog.velcdn.com/images/seanson2709/profile/d1c0bb72-3f18-4125-ac53-52602774e0ad/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. seanson2709.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/seanson2709" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[쉽게 따라하는 Amazon Bedrock으로 나만의 AI 어시스턴트 만들기 (2) — iPhone 앱 붙이기: SwiftUI + SSE]]></title>
            <link>https://velog.io/@seanson2709/%EC%89%BD%EA%B2%8C-%EB%94%B0%EB%9D%BC%ED%95%98%EB%8A%94-Amazon-Bedrock%EC%9C%BC%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-AI-%EC%96%B4%EC%8B%9C%EC%8A%A4%ED%84%B4%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-2-iPhone-%EC%95%B1-%EB%B6%99%EC%9D%B4%EA%B8%B0-SwiftUI-SSE</link>
            <guid>https://velog.io/@seanson2709/%EC%89%BD%EA%B2%8C-%EB%94%B0%EB%9D%BC%ED%95%98%EB%8A%94-Amazon-Bedrock%EC%9C%BC%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-AI-%EC%96%B4%EC%8B%9C%EC%8A%A4%ED%84%B4%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-2-iPhone-%EC%95%B1-%EB%B6%99%EC%9D%B4%EA%B8%B0-SwiftUI-SSE</guid>
            <pubDate>Tue, 22 Sep 2026 12:14:25 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-개요">📌 개요</h2>
<p>1편은 <code>curl</code>로 SSE 스트림이 흘러나오는 터미널 화면에서 끝났습니다.
그 화면을 보고 있으니 &quot;이걸 폰에서 보고 싶다&quot;는 생각이 먼저 들었고, 그래서 이번 편에서는 <strong>iPhone 앱</strong>을 붙입니다.</p>
<p>원래 2편은 운영하면서 겪은 문제들(재시도·타임아웃·비용)을 쓰려고 했는데, 순서를 바꿨습니다.
그런 문제들은 앱을 실제로 매일 쓰기 시작해야 만나는 것들이라, <strong>먼저 쓸 수 있는 물건을 만들고</strong> 그다음에 운영 이야기를 하는 게 맞겠다고 판단했습니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/ed99e7fa-98f9-4d3e-a735-6556d0c38058/image.png" alt="SeanChat 2편 — 전체 구성. 앱은 Bedrock을 직접 부르지 않고 1편에서 만든 FastAPI 서버에 붙는다"></p>
<p>구성은 위 그림이 전부입니다. <strong>앱은 Thin 클라이언트</strong>입니다.</p>
<ul>
<li>iPhone 앱은 Bedrock을 직접 부르지 않습니다. 1편 11장에서 만든 FastAPI 서버에 HTTP로 붙고, 답은 SSE로 한 글자씩 받습니다.</li>
<li>AWS 자격증명은 Mac(나중엔 EC2)에만 있습니다. 폰에는 아무것도 들어가지 않습니다.</li>
<li>대화 기억(메시지 배열)은 <strong>앱이</strong> 들고 있습니다. 1편 7장 &quot;상태는 서버가 아니라 내가 들고 있다&quot;가 그대로 이어집니다.</li>
</ul>
<p>이렇게 나눠두면 모델을 바꾸거나 도구를 붙이거나 프롬프트를 고칠 때 <strong>앱은 손대지 않습니다</strong>. 서버만 고치면 됩니다. 제 SeanChat 앱도 올해 초 이후 서버는 수십 번 바뀌었지만 앱의 네트워크 코드는 거의 그대로입니다.</p>
<p>이번 편에서 만드는 것:</p>
<table>
<thead>
<tr>
<th>파일</th>
<th>줄 수</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>step12_server.py</code> (Mac)</td>
<td>56</td>
<td>1편 <code>step11_server.py</code>에 멀티턴·시스템 프롬프트·에러 이벤트 추가</td>
</tr>
<tr>
<td><code>Message.swift</code></td>
<td>9</td>
<td>말풍선 하나</td>
</tr>
<tr>
<td><code>ChatAPI.swift</code></td>
<td>62</td>
<td>서버와 이야기하는 유일한 파일 — SSE 파싱</td>
</tr>
<tr>
<td><code>ChatViewModel.swift</code></td>
<td>44</td>
<td>상태 + 보내기/중지</td>
</tr>
<tr>
<td><code>ContentView.swift</code></td>
<td>101</td>
<td>채팅 화면</td>
</tr>
<tr>
<td><code>SettingsView.swift</code></td>
<td>43</td>
<td>서버 주소 입력 + 연결 테스트</td>
</tr>
<tr>
<td><code>Info.plist</code></td>
<td>—</td>
<td>로컬 네트워크 권한 문구</td>
</tr>
</tbody></table>
<p>Swift는 다 합쳐 약 260줄입니다. 저도 Swift는 이 앱을 만들면서 처음 제대로 썼기 때문에, &quot;iOS 개발자라면 당연히 아는 것&quot;은 가정하지 않고 하나씩 적었습니다.</p>
<blockquote>
<p>모든 스크린샷은 2026년 9월, macOS 26.5.1 + Xcode 26.5 + iPhone 17 시뮬레이터(iOS 26.5)에서 <strong>실제로 돌린 것</strong>입니다. 서버는 1편과 같은 개인 계정(us-east-2, Claude Haiku 4.5)입니다.</p>
</blockquote>
<hr>
<h2 id="📡-sse-30초-이해--앱-쪽에서-보면">📡 SSE 30초 이해 — 앱 쪽에서 보면</h2>
<p>1편 11장에서 서버가 이렇게 내보냈습니다.</p>
<pre><code>data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;당&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;신의 이름은 Sean&quot;}

data: {&quot;type&quot;: &quot;done&quot;}</code></pre><p>앱 쪽에서 SSE는 어렵지 않습니다. 정리하면 세 줄입니다.</p>
<ol>
<li><strong>그냥 HTTP 응답</strong>입니다. 다만 서버가 끊지 않고 계속 써 내려갈 뿐입니다. 웹소켓처럼 별도 프로토콜이 아닙니다.</li>
<li>한 이벤트 = <code>data:</code> 로 시작하는 <strong>한 줄</strong> + 빈 줄. 그래서 &quot;줄 단위로 읽어서 <code>data:</code>를 떼고 JSON 파싱&quot;이 전부입니다.</li>
<li>iOS에는 이걸 위한 API가 이미 있습니다. <code>URLSession.bytes(for:)</code> 가 응답을 바이트 스트림으로 주고, <code>.lines</code> 를 붙이면 <strong>줄이 도착하는 대로</strong> <code>for try await</code> 로 받을 수 있습니다.</li>
</ol>
<p>저는 처음에 &quot;SSE 라이브러리를 찾아야 하나&quot; 하고 검색부터 했는데, 결국 표준 API 두 개(<code>bytes(for:)</code>, <code>.lines</code>)로 끝났습니다. 라이브러리 없이 갑니다.</p>
<hr>
<h2 id="🛠️-0-사전-준비">🛠️ 0. 사전 준비</h2>
<h3 id="0-1-1편-환경-그대로">0-1. 1편 환경 그대로</h3>
<p>1편의 파이썬 환경(venv + <code>boto3</code> + <code>fastapi</code> + <code>uvicorn</code>)과 자격증명이 그대로 필요합니다. 1편 11장 <code>step11_server.py</code>가 돌아갔다면 준비된 것입니다.</p>
<pre><code class="language-bash">python -c &quot;import boto3, fastapi, uvicorn; print(&#39;ok&#39;)&quot;
aws sts get-caller-identity --query Account --output text</code></pre>
<blockquote>
<p>🔗 1편에서 쓴 <code>us.anthropic.claude-haiku-4-5-…</code> 가 여전히 호출 가능한지, 새 모델이 열렸는지는 <a href="https://hub.sean-chloe.com/bedrock/">hub.sean-chloe.com/bedrock</a>에서 확인할 수 있습니다 — Bedrock 모델·추론 프로파일 목록을 매일 자동 갱신하는 페이지입니다.</p>
</blockquote>
<h3 id="0-2-xcode">0-2. Xcode</h3>
<p>Mac App Store에서 <strong>Xcode</strong>를 설치합니다. 저는 26.5를 썼지만 <strong>Xcode 16 이상</strong>이면 이 글의 코드가 그대로 빌드됩니다. (이 글에서 쓰는 <code>@Observable</code>, <code>ContentUnavailableView</code> 가 iOS 17 이상 API라서, 그보다 오래된 Xcode는 안 됩니다.)</p>
<p>처음 실행하면 추가 컴포넌트(iOS 시뮬레이터 런타임)를 받는데 수 GB라 시간이 걸립니다. 저는 이 사이에 1장 서버 작업을 했습니다.</p>
<h3 id="0-3-apple-개발자-계정은-필요-없습니다">0-3. Apple 개발자 계정은 필요 없습니다</h3>
<p><strong>시뮬레이터</strong>에서 돌리는 데는 계정이 필요 없습니다. 이 글의 스크린샷은 전부 시뮬레이터입니다.
실제 iPhone에 올릴 때도 Xcode에 본인 Apple ID로 로그인하면 됩니다. 이 앱은 스토어에 올릴 게 아니라 <strong>내 폰에서 나만 쓰는 앱</strong>이라 그 이상은 필요 없습니다. 5장 끝에서 다시 이야기합니다.</p>
<hr>
<h2 id="🔧-1-백엔드-손보기--step12_serverpy">🔧 1. 백엔드 손보기 — <code>step12_server.py</code></h2>
<p>1편 11장의 <code>step11_server.py</code>는 <code>{&quot;prompt&quot;: &quot;...&quot;}</code> 한 문장만 받았습니다. 앱을 붙이려면 세 가지를 더해야 했습니다.</p>
<ol>
<li><strong>멀티턴</strong> — 앱이 대화 전체(<code>messages</code>)를 들고 옵니다. 서버는 그대로 Bedrock에 넘기기만 합니다.</li>
<li><strong>시스템 프롬프트</strong> — 1편 5장. 앱 이름과 말투를 여기서 정합니다.</li>
<li><strong>에러를 SSE 이벤트로</strong> — 모델 호출이 실패하면 HTTP 500이 아니라 <code>{&quot;type&quot;: &quot;error&quot;}</code> 이벤트를 흘려보냅니다. 앱이 빈 말풍선 대신 이유를 보여줄 수 있게 하려는 것입니다.</li>
</ol>
<pre><code class="language-python">&quot;&quot;&quot;2편 — 1편의 step11_server.py에 세 가지만 더한다.
   ① 멀티턴: 앱이 대화 전체(messages)를 들고 온다 (1편 7장 — 상태는 &#39;내&#39;가 들고 있다)
   ② 시스템 프롬프트 (1편 5장)
   ③ 에러를 SSE 이벤트로 흘려보내기 — 앱이 빈 말풍선 대신 이유를 보여줄 수 있게
&quot;&quot;&quot;
import json
import boto3
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()
runtime = boto3.client(&quot;bedrock-runtime&quot;, region_name=&quot;us-east-2&quot;)
MODEL = &quot;us.anthropic.claude-haiku-4-5-20251001-v1:0&quot;
SYSTEM = [{&quot;text&quot;: &quot;너는 SeanChat, 한국어로 답하는 개인 비서다. 간결하게 답한다.&quot;}]


class Msg(BaseModel):
    role: str          # &quot;user&quot; | &quot;assistant&quot;
    text: str


class ChatReq(BaseModel):
    messages: list[Msg]   # ← 앱이 대화 전체를 들고 온다


def sse(obj) -&gt; str:                       # SSE 프레이밍: &quot;data: {...}\n\n&quot;
    return f&quot;data: {json.dumps(obj, ensure_ascii=False)}\n\n&quot;


@app.get(&quot;/health&quot;)                        # 앱의 &#39;연결 테스트&#39; 버튼이 부른다
def health():
    return {&quot;ok&quot;: True, &quot;model&quot;: MODEL}


@app.post(&quot;/chat&quot;)
def chat(body: ChatReq):
    msgs = [{&quot;role&quot;: m.role, &quot;content&quot;: [{&quot;text&quot;: m.text}]}
            for m in body.messages if m.text.strip()]      # 빈 문자열은 Bedrock이 거부한다 (1편 에러 #4)

    def gen():
        try:
            resp = runtime.converse_stream(
                modelId=MODEL, system=SYSTEM, messages=msgs,
                inferenceConfig={&quot;maxTokens&quot;: 1000})
            for ev in resp[&quot;stream&quot;]:
                if &quot;contentBlockDelta&quot; in ev:
                    t = ev[&quot;contentBlockDelta&quot;][&quot;delta&quot;].get(&quot;text&quot;)
                    if t:
                        yield sse({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: t})
                elif &quot;metadata&quot; in ev:
                    yield sse({&quot;type&quot;: &quot;usage&quot;, &quot;usage&quot;: ev[&quot;metadata&quot;][&quot;usage&quot;]})
        except Exception as e:                              # 모델 에러도 이벤트로 — 앱이 이유를 보여준다
            yield sse({&quot;type&quot;: &quot;error&quot;, &quot;message&quot;: f&quot;{type(e).__name__}: {e}&quot;})
        yield sse({&quot;type&quot;: &quot;done&quot;})
    return StreamingResponse(gen(), media_type=&quot;text/event-stream&quot;)</code></pre>
<p><code>/health</code>는 앱의 &quot;연결 테스트&quot; 버튼이 부를 용도로 넣었습니다. 5장에서 이게 있어서 편했습니다.</p>
<p>띄울 때 <strong><code>--host 0.0.0.0</code></strong> 을 꼭 붙입니다. 기본값 <code>127.0.0.1</code>로 띄우면 같은 Mac의 시뮬레이터는 붙지만, 실제 iPhone은 붙지 못합니다. 저는 처음에 이걸 빼먹고 폰에서 &quot;Could not connect to the server&quot;를 한참 봤습니다.</p>
<pre><code class="language-bash">uvicorn step12_server:app --host 0.0.0.0 --port 8599</code></pre>
<p>앱을 만들기 전에 <code>curl</code>로 새 형식(<code>messages</code> 배열)이 잘 받아지는지 확인했습니다. 1편 7장의 &quot;이름 기억하기&quot;를 그대로 던졌습니다.</p>
<pre><code class="language-bash">curl -N -s http://127.0.0.1:8599/chat -H &#39;Content-Type: application/json&#39; -d &#39;{
  &quot;messages&quot;: [
    {&quot;role&quot;: &quot;user&quot;,      &quot;text&quot;: &quot;내 이름은 Sean이야&quot;},
    {&quot;role&quot;: &quot;assistant&quot;, &quot;text&quot;: &quot;반갑습니다, Sean님.&quot;},
    {&quot;role&quot;: &quot;user&quot;,      &quot;text&quot;: &quot;내 이름이 뭐라고?&quot;}
  ]}&#39;</code></pre>
<pre><code>data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;당&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;신의 이름은 Sean&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;입니다.&quot;}

data: {&quot;type&quot;: &quot;usage&quot;, &quot;usage&quot;: {&quot;inputTokens&quot;: 81, &quot;outputTokens&quot;: 17, &quot;totalTokens&quot;: 98}}

data: {&quot;type&quot;: &quot;done&quot;}</code></pre><p>앱이 파싱해야 하는 이벤트는 네 종류(<code>text</code> / <code>usage</code> / <code>error</code> / <code>done</code>)입니다. 이 다섯 줄이 이번 편에서 앱과 서버 사이의 <strong>계약</strong> 전부입니다.</p>
<hr>
<h2 id="🔧-2-xcode-프로젝트-만들기">🔧 2. Xcode 프로젝트 만들기</h2>
<p>Xcode를 열고 <strong>Create New Project</strong>를 누릅니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/3632889f-3f2a-431e-a709-eefd7db88db9/image.png" alt="Xcode 시작 화면 — Create New Project"></p>
<p>템플릿은 <strong>iOS → App</strong>입니다. 다른 건 고르지 않습니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/296d141d-2f2b-45db-8430-ea1f861c092f/image.png" alt="템플릿 선택 — iOS 탭의 App"></p>
<p>옵션 화면에서 제가 넣은 값입니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/3053aa34-4bd5-4ab3-90f1-fa76b487b7ed/image.png" alt="프로젝트 옵션 — SwiftUI / Swift / Storage None / Testing None"></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>값</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>Product Name</td>
<td><code>SeanChat</code></td>
<td>앱 이름. 원하는 이름으로</td>
</tr>
<tr>
<td>Team</td>
<td>본인 Apple ID (없으면 None)</td>
<td>시뮬레이터만 쓰면 None으로도 됩니다. 저는 개발자 계정이 연결돼 있어 제 이름이 뜹니다</td>
</tr>
<tr>
<td>Organization Identifier</td>
<td><code>com.sean-chloe</code></td>
<td>역방향 도메인. 번들 ID가 <code>com.sean-chloe.SeanChat</code>이 됩니다</td>
</tr>
<tr>
<td>Interface</td>
<td><strong>SwiftUI</strong></td>
<td>UIKit 아님</td>
</tr>
<tr>
<td>Language</td>
<td><strong>Swift</strong></td>
<td></td>
</tr>
<tr>
<td>Testing System</td>
<td>None</td>
<td>이번 편에서는 테스트 안 씁니다</td>
</tr>
<tr>
<td>Storage</td>
<td><strong>None</strong></td>
<td>SwiftData/Core Data 안 씁니다. 대화는 메모리에만</td>
</tr>
</tbody></table>
<p>저장 위치를 고르면 프로젝트가 열립니다. 왼쪽에 <code>SeanChatApp.swift</code>와 <code>ContentView.swift</code> 두 파일이 자동으로 만들어져 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/0f2ccba7-27f9-4a11-b8df-4c0e5697daa0/image.png" alt="프로젝트 생성 직후 — ContentView.swift와 오른쪽 미리보기(Canvas)"></p>
<p>이 상태에서 상단 재생(▶) 버튼을 누르면 시뮬레이터가 뜨고 &quot;Hello, world!&quot;가 나옵니다. 여기까지 확인하고 넘어갑니다. 저는 처음 빌드가 2분 정도 걸렸습니다(시뮬레이터 부팅 포함).</p>
<blockquote>
<p><strong>Xcode 16부터 바뀐 것 하나.</strong> 왼쪽 파일 목록의 <code>SeanChat</code> 폴더는 <strong>디스크 폴더와 자동 동기화</strong>됩니다(Xcode 15까지의 &quot;그룹&quot;과 다릅니다). 그래서 Finder나 터미널에서 <code>.swift</code> 파일을 폴더에 넣으면 그냥 프로젝트에 포함됩니다. 이 글의 파일들도 그렇게 넣었습니다. 대신 이것 때문에 <code>Info.plist</code>에서 한 번 걸리는데, 5장에서 이야기합니다.</p>
</blockquote>
<hr>
<h2 id="🔧-3-swift-파일-4개--모델-api-뷰모델-화면">🔧 3. Swift 파일 4개 — 모델, API, 뷰모델, 화면</h2>
<p><code>SeanChat</code> 폴더 안에 파일을 하나씩 만듭니다. Xcode에서 폴더 우클릭 → <strong>New File from Template… → Swift File</strong>, 또는 그냥 Finder에서 <code>.swift</code> 파일을 폴더에 넣어도 됩니다(2장 끝 참고).</p>
<h3 id="3-1-messageswift--말풍선-하나">3-1. <code>Message.swift</code> — 말풍선 하나</h3>
<pre><code class="language-swift">import Foundation

/// 말풍선 하나. role은 백엔드(Bedrock)와 같은 단어를 쓴다: user / assistant
struct Message: Identifiable, Equatable {
    enum Role: String, Codable { case user, assistant }
    let id = UUID()
    let role: Role
    var text: String
}</code></pre>
<p><code>role</code>을 <code>user</code> / <code>assistant</code> 로 둔 이유는 하나입니다. <strong>Bedrock이 쓰는 단어와 같게</strong> 하면 서버에서 변환할 게 없습니다. 1편 2장의 <code>messages</code> 배열에 들어가던 그 <code>role</code>입니다.</p>
<p><code>text</code>만 <code>var</code>인 것은 스트리밍 때문입니다. 모델의 말풍선은 빈 문자열로 만들어놓고, 조각이 올 때마다 뒤에 붙입니다.</p>
<h3 id="3-2-chatapiswift--서버와-이야기하는-유일한-파일">3-2. <code>ChatAPI.swift</code> — 서버와 이야기하는 유일한 파일</h3>
<p>이번 편의 핵심입니다. 62줄인데, 실제로 SSE를 읽는 부분은 열 줄이 안 됩니다.</p>
<pre><code class="language-swift">import Foundation

/// 백엔드(step12_server.py)와 이야기하는 유일한 파일.
///   GET  /health → 연결 테스트
///   POST /chat   → SSE 스트림 (&quot;data: {...}&quot; 한 줄이 이벤트 하나)
struct ChatAPI {
    let baseURL: URL

    struct WireMessage: Encodable { let role: String; let text: String }
    struct Event: Decodable { let type: String; let text: String?; let message: String? }
    struct Health: Decodable { let ok: Bool; let model: String }

    enum ChatError: LocalizedError {
        case http(Int), server(String)
        var errorDescription: String? {
            switch self {
            case .http(let code):  return &quot;서버가 HTTP \(code)를 돌려줬습니다&quot;
            case .server(let msg): return &quot;서버 오류: \(msg)&quot;
            }
        }
    }

    func health() async throws -&gt; Health {
        let (data, _) = try await URLSession.shared.data(from: baseURL.appending(path: &quot;health&quot;))
        return try JSONDecoder().decode(Health.self, from: data)
    }

    /// 대화 전체를 보내고, 모델이 뱉는 글자 조각(delta)을 순서대로 흘려준다
    func stream(_ messages: [Message]) -&gt; AsyncThrowingStream&lt;String, Error&gt; {
        AsyncThrowingStream { continuation in
            let task = Task {
                do {
                    var req = URLRequest(url: baseURL.appending(path: &quot;chat&quot;))
                    req.httpMethod = &quot;POST&quot;
                    req.setValue(&quot;application/json&quot;, forHTTPHeaderField: &quot;Content-Type&quot;)
                    req.timeoutInterval = 120
                    let wire = messages.map { WireMessage(role: $0.role.rawValue, text: $0.text) }
                    req.httpBody = try JSONEncoder().encode([&quot;messages&quot;: wire])

                    let (bytes, response) = try await URLSession.shared.bytes(for: req)
                    let status = (response as? HTTPURLResponse)?.statusCode ?? 0
                    guard status == 200 else { throw ChatError.http(status) }

                    for try await line in bytes.lines {              // 줄 단위로 도착하는 대로 읽는다
                        guard line.hasPrefix(&quot;data: &quot;) else { continue }
                        let ev = try JSONDecoder().decode(Event.self, from: Data(line.dropFirst(6).utf8))
                        switch ev.type {
                        case &quot;text&quot;:  if let t = ev.text { continuation.yield(t) }
                        case &quot;error&quot;: throw ChatError.server(ev.message ?? &quot;unknown&quot;)
                        case &quot;done&quot;:  continuation.finish(); return
                        default:      break                          // usage 등은 지금은 무시
                        }
                    }
                    continuation.finish()
                } catch {
                    continuation.finish(throwing: error)
                }
            }
            continuation.onTermination = { _ in task.cancel() }     // 화면에서 &#39;중지&#39;하면 요청도 끊는다
        }
    }
}</code></pre>
<p>읽는 순서대로 설명하면 이렇습니다.</p>
<p><strong>① 요청 만들기</strong> — <code>URLRequest</code>에 <code>POST</code>, JSON 헤더, 그리고 본문. 본문은 <code>{&quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;text&quot;: &quot;...&quot;}, ...]}</code>. 1장 <code>curl</code>로 보낸 것과 같은 모양입니다. <code>Message</code>를 그대로 인코딩하지 않고 <code>WireMessage</code>로 한 번 옮기는 것은 <code>id</code>(UUID)를 서버에 보내지 않기 위해서입니다. <code>timeoutInterval = 120</code>은 기본값 60초를 늘린 것인데, 이 값은 &quot;전체 시간&quot;이 아니라 <strong>데이터가 안 오는 상태가 이어지는 시간</strong>입니다. 스트리밍 중에는 조각이 계속 오니 문제없고, 첫 글자가 나오기까지 모델이 오래 생각하는 경우(1편 10장 Extended Thinking)를 대비해 여유를 뒀습니다.</p>
<p><strong>② <code>bytes(for:)</code></strong> — 보통 쓰는 <code>data(for:)</code>는 응답이 <strong>다 끝나야</strong> 돌아옵니다. 스트리밍에는 못 씁니다. <code>bytes(for:)</code>는 헤더가 오는 즉시 돌아오고, 본문은 <code>AsyncSequence</code>로 뒤에 흘러옵니다.</p>
<p><strong>③ <code>.lines</code></strong> — 바이트 시퀀스에 <code>.lines</code>를 붙이면 개행 단위로 잘라 <code>String</code>으로 줍니다. SSE는 줄 단위 프로토콜이니 이게 그대로 파서입니다. <code>&quot;data: &quot;</code>로 시작하지 않는 줄(빈 줄)은 버리고, 나머지는 앞 6글자를 떼고 JSON으로 디코딩합니다.</p>
<p><strong>④ 이벤트 분기</strong> — <code>text</code>면 조각을 내보내고(<code>yield</code>), <code>error</code>면 던지고, <code>done</code>이면 끝냅니다. <code>usage</code>는 지금은 무시합니다(토큰 표시는 다음 편).</p>
<p><strong>⑤ <code>AsyncThrowingStream</code></strong> — 이 함수는 호출한 쪽에 &quot;글자 조각이 흘러나오는 시퀀스&quot;를 돌려줍니다. 호출하는 쪽(뷰모델)은 <code>for try await chunk in api.stream(...)</code> 으로 받기만 하면 됩니다. 네트워크 코드가 이 파일 밖으로 새지 않습니다.</p>
<p><strong>⑥ <code>onTermination</code></strong> — 화면에서 ■(중지)를 눌러 시퀀스를 끊으면 이 클로저가 불리고, 안에서 돌던 <code>Task</code>를 취소합니다. 그러면 <code>bytes.lines</code> 루프가 <code>CancellationError</code>로 빠져나오고 소켓이 닫힙니다. <strong>이걸 안 하면 화면은 멈춰도 요청은 끝까지 살아서 토큰을 계속 태웁니다.</strong> 제 SeanChat 초기 버전이 정확히 그랬습니다. 중지를 눌렀는데 서버 로그엔 모델이 계속 답하고 있었습니다.</p>
<blockquote>
<p><strong>왜 <code>health()</code>를 따로 두나.</strong> 5장의 설정 화면에서 &quot;연결 테스트&quot; 버튼이 이걸 부릅니다. 실기기에서 Mac에 안 붙을 때, 문제가 주소인지·방화벽인지·서버인지를 채팅을 보내보지 않고도 알 수 있어서 넣었습니다.</p>
</blockquote>
<h3 id="3-3-chatviewmodelswift--상태와-보내기중지">3-3. <code>ChatViewModel.swift</code> — 상태와 보내기/중지</h3>
<p>화면이 보여줄 상태(<code>messages</code>, <code>input</code>, <code>isStreaming</code>, <code>errorText</code>)와 &quot;보내기/중지/지우기&quot; 세 동작이 들어 있습니다.</p>
<pre><code class="language-swift">import SwiftUI
import Observation

/// 화면 상태 + 보내기/중지.
/// 대화 이력(messages)은 앱이 들고 있다 — 서버는 아무것도 기억하지 않는다 (1편 7장)
@Observable @MainActor
final class ChatViewModel {
    var messages: [Message] = []
    var input = &quot;&quot;
    var isStreaming = false
    var errorText: String?
    private var task: Task&lt;Void, Never&gt;?

    static let defaultServer = &quot;http://127.0.0.1:8599&quot;
    var serverURL: String { UserDefaults.standard.string(forKey: &quot;serverURL&quot;) ?? Self.defaultServer }

    func send() {
        let text = input.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty, !isStreaming else { return }
        guard let url = URL(string: serverURL) else { errorText = &quot;서버 주소가 이상합니다: \(serverURL)&quot;; return }

        input = &quot;&quot;
        errorText = nil
        messages.append(Message(role: .user, text: text))
        messages.append(Message(role: .assistant, text: &quot;&quot;))   // 빈 말풍선을 먼저 만들어 두고
        let history = Array(messages.dropLast())                // 그 빈 말풍선은 빼고 서버에 보낸다
        isStreaming = true

        task = Task {
            defer { isStreaming = false }
            do {
                for try await delta in ChatAPI(baseURL: url).stream(history) {
                    messages[messages.count - 1].text += delta    // 조각이 올 때마다 마지막 말풍선에 붙인다
                }
            } catch {
                errorText = error.localizedDescription
                if messages.last?.text.isEmpty == true { messages.removeLast() }  // 한 글자도 못 받았으면 빈 말풍선 제거
            }
        }
    }

    func stop() { task?.cancel() }                 // 지금까지 받은 글자는 그대로 남는다
    func clear() { stop(); messages.removeAll(); errorText = nil }
}</code></pre>
<p><strong>빈 말풍선을 먼저 만드는 순서</strong>가 이 파일의 핵심입니다. 보내기를 누르면 ① 내 말풍선을 붙이고 ② 모델 말풍선을 <strong>빈 문자열로</strong> 하나 더 붙인 뒤 ③ 그 빈 것은 빼고 서버에 보냅니다. 그리고 조각이 올 때마다 <code>messages[messages.count - 1].text += delta</code> 로 마지막 말풍선에 이어 붙입니다. <code>messages</code>가 바뀌면 SwiftUI가 알아서 다시 그리니, &quot;타이핑되는 것처럼 보이는&quot; 효과는 이 한 줄이 전부입니다.</p>
<p><code>@Observable @MainActor</code> 두 개를 같이 붙였습니다.</p>
<ul>
<li><code>@Observable</code>(iOS 17+) — 프로퍼티가 바뀌면 그걸 읽는 뷰만 다시 그려줍니다. 옛 방식(<code>ObservableObject</code> + <code>@Published</code>)보다 코드가 짧습니다. iOS 17 API라서 프로젝트 설정의 <strong>Minimum Deployments</strong>를 <code>iOS 17.0</code>으로 두었습니다(기본값은 Xcode 버전에 따라 18 또는 26으로 잡혀 있는데, 낮추는 쪽은 자유입니다).</li>
<li><code>@MainActor</code> — 이 클래스의 코드는 전부 메인 스레드에서 돈다고 선언합니다. 네트워크 <code>Task</code> 안에서 <code>messages</code>를 고치는데, UI 상태는 메인 스레드에서만 건드려야 하니 이 한 줄로 컴파일러가 대신 지켜줍니다. 참고로 <strong>Xcode 26이 만든 새 프로젝트는 빌드 설정에 <code>Default Actor Isolation = MainActor</code>가 켜져 있어서</strong> 안 붙여도 같은 뜻이 됩니다. Xcode 16에는 이 설정이 없어서 빼면 동시성 경고·에러가 나니, 어느 쪽이든 되게 명시해 두었습니다.</li>
</ul>
<p><code>stop()</code>은 <code>task?.cancel()</code> 한 줄입니다. 취소 신호는 <code>ChatAPI</code>의 <code>onTermination</code>까지 전달되어 소켓이 닫힙니다(3-2 ⑥). 그때까지 받은 글자는 지우지 않고 그대로 둡니다.</p>
<p><code>catch</code> 블록에서 <strong>한 글자도 못 받았으면 빈 말풍선을 지우는</strong> 처리도 넣었습니다. 서버가 꺼져 있을 때 &quot;…&quot; 말풍선만 덩그러니 남는 게 보기 싫어서인데, 5장 에러 스크린샷에서 이 처리가 보입니다.</p>
<h3 id="3-4-contentviewswift--채팅-화면">3-4. <code>ContentView.swift</code> — 채팅 화면</h3>
<p>자동 생성된 <code>ContentView.swift</code>의 내용을 <strong>전부 지우고</strong> 아래로 바꿉니다. 101줄이지만 세 덩어리(말풍선 목록 / 입력창 / 말풍선 하나)입니다.</p>
<pre><code class="language-swift">import SwiftUI

struct ContentView: View {
    @State private var vm = ChatViewModel()
    @State private var showSettings = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                messageList
                if let err = vm.errorText {
                    Text(err).font(.footnote).foregroundStyle(.red)
                        .padding(.horizontal).padding(.bottom, 4)
                }
                inputBar
            }
            .navigationTitle(&quot;SeanChat&quot;)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button(&quot;지우기&quot;, systemImage: &quot;trash&quot;) { vm.clear() }
                        .disabled(vm.messages.isEmpty)
                }
                ToolbarItem(placement: .topBarTrailing) {
                    Button(&quot;설정&quot;, systemImage: &quot;gearshape&quot;) { showSettings = true }
                }
            }
            .sheet(isPresented: $showSettings) { SettingsView() }
        }
    }

    // MARK: 말풍선 목록
    private var messageList: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 12) {
                    if vm.messages.isEmpty {
                        ContentUnavailableView(&quot;무엇이든 물어보세요&quot;,
                                               systemImage: &quot;bubble.left.and.text.bubble.right&quot;,
                                               description: Text(&quot;Mac에서 step12_server.py가 켜져 있어야 합니다&quot;))
                            .padding(.top, 80)
                    }
                    ForEach(vm.messages) { BubbleView(message: $0) }
                    Color.clear.frame(height: 1).id(&quot;bottom&quot;)
                }
                .padding()
            }
            .scrollDismissesKeyboard(.interactively)
            .onChange(of: vm.messages.last?.text) {            // 글자가 늘어날 때마다 바닥으로
                proxy.scrollTo(&quot;bottom&quot;, anchor: .bottom)
            }
        }
    }

    // MARK: 입력창
    private var inputBar: some View {
        HStack(alignment: .bottom, spacing: 8) {
            TextField(&quot;메시지&quot;, text: $vm.input, axis: .vertical)
                .lineLimit(1...5)
                .textFieldStyle(.roundedBorder)
            if vm.isStreaming {
                Button { vm.stop() } label: { Image(systemName: &quot;stop.circle.fill&quot;).font(.title) }
            } else {
                Button { vm.send() } label: { Image(systemName: &quot;arrow.up.circle.fill&quot;).font(.title) }
                    .disabled(vm.input.trimmingCharacters(in: .whitespaces).isEmpty)
            }
        }
        .padding()
        .background(.bar)
    }
}

/// 말풍선 하나 — 내 말은 오른쪽 파란색, 모델 말은 왼쪽 회색
struct BubbleView: View {
    let message: Message
    private var isUser: Bool { message.role == .user }

    /// **굵게**, *기울임*, `코드` 같은 인라인 마크다운만 해석한다 (한 줄 개선)
    private var attributed: AttributedString {
        (try? AttributedString(markdown: message.text,
                               options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
        ?? AttributedString(message.text)
    }

    var body: some View {
        HStack {
            if isUser { Spacer(minLength: 48) }
            Text(message.text.isEmpty ? AttributedString(&quot;…&quot;) : attributed)
                .padding(.horizontal, 14).padding(.vertical, 10)
                .background(isUser ? Color.accentColor : Color(.secondarySystemBackground),
                            in: RoundedRectangle(cornerRadius: 18))
                .foregroundStyle(isUser ? .white : .primary)
                .textSelection(.enabled)
            if !isUser { Spacer(minLength: 48) }
        }
    }
}

#Preview {
    ContentView()
}</code></pre>
<p>위에서부터 봅니다.</p>
<p><strong><code>@State private var vm = ChatViewModel()</code></strong> — <code>@Observable</code> 클래스는 <code>@StateObject</code>가 아니라 <code>@State</code>로 들고 있습니다. 이 화면이 살아 있는 동안 뷰모델도 살아 있습니다.</p>
<p><strong><code>ScrollViewReader</code> + <code>id(&quot;bottom&quot;)</code></strong> — 목록 맨 끝에 높이 1짜리 투명 뷰를 두고, <code>messages.last?.text</code>가 바뀔 때마다(=글자가 한 조각 붙을 때마다) 거기로 스크롤합니다. 스트리밍 중에 답이 길어져도 화면이 따라 내려가는 건 이 세 줄 덕입니다. 배열 전체를 감시해도 되지만, 스트리밍 중에 바뀌는 건 항상 <strong>마지막 말풍선의 글자</strong>라 그것만 봅니다.</p>
<p><strong><code>ContentUnavailableView</code></strong> — 메시지가 없을 때 보이는 빈 화면입니다(iOS 17+). 안내 문구에 &quot;Mac에서 step12_server.py가 켜져 있어야 합니다&quot;를 넣었습니다. 몇 달 뒤의 제가 서버 안 켜고 앱만 켜서 헤맬 게 뻔해서입니다.</p>
<p><strong>입력창</strong> — <code>TextField(..., axis: .vertical)</code> + <code>lineLimit(1...5)</code>로 여러 줄 입력이 됩니다. 스트리밍 중에는 보내기(↑) 대신 중지(■) 버튼이 나옵니다. 버튼 하나를 상태에 따라 바꾸는 게 아니라 <code>if vm.isStreaming</code> 으로 다른 버튼을 그립니다.</p>
<p><strong><code>BubbleView</code></strong> — 내 말은 오른쪽 파란색, 모델 말은 왼쪽 회색입니다. <code>Spacer(minLength: 48)</code>를 반대쪽에 두어 말풍선이 화면 폭을 다 먹지 않게 했습니다. 텍스트가 비어 있으면(첫 조각이 오기 전) &quot;…&quot;을 보여줍니다. <code>attributed</code> 프로퍼티는 6장에서 설명합니다 — 처음 만들 땐 <code>Text(message.text)</code> 한 줄이었습니다.</p>
<h2 id="여기까지-하면-빌드가-됩니다-settingsview가-아직-없어서-에러가-나는데-임시로-sheet-줄을-주석-처리하거나-5장의-settingsviewswift를-먼저-만들어-두면-됩니다-저는-후자로-했습니다">여기까지 하면 <strong>빌드가 됩니다</strong>. <code>SettingsView</code>가 아직 없어서 에러가 나는데, 임시로 <code>.sheet(...)</code> 줄을 주석 처리하거나 5장의 <code>SettingsView.swift</code>를 먼저 만들어 두면 됩니다. 저는 후자로 했습니다.</h2>
<h2 id="🔧-4-첫-실행--시뮬레이터에서">🔧 4. 첫 실행 — 시뮬레이터에서</h2>
<p>Mac에서 서버를 켜 둔 상태로(1장), Xcode 상단에서 시뮬레이터(iPhone 17)를 고르고 ▶를 누릅니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/e2651269-ce6d-4e5f-9d8d-07ee6bdafdef/image.png" alt="첫 실행 — 빈 화면. 상단에 지우기·설정, 하단에 입력창"></p>
<p>기본 서버 주소가 <code>http://127.0.0.1:8599</code>입니다. <strong>시뮬레이터는 Mac과 네트워크를 공유</strong>하므로 이 주소로 바로 붙습니다. 별도 설정 없이 첫 질문을 보냈습니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/b336896a-42f4-4ed4-a491-f93e76223676/image.png" alt="스트리밍 — &quot;…&quot;(첫 조각 대기) → &quot;Amazon&quot; → 문장 중간 → 완성. 마지막 장에서 ■가 ↑로 돌아온다"></p>
<p>1편 11장의 <code>curl</code> 출력이 그대로 말풍선에 타이핑되는 게 보입니다. 보내기를 누르면 빈 말풍선에 &quot;…&quot;이 먼저 뜨고, 첫 조각 <code>&quot;Amazon&quot;</code>이 도착하는 순간 &quot;…&quot;이 사라지고, 이후 조각이 붙는 대로 말풍선이 자랍니다. 연속 캡처 30장 중 내용이 달라진 4장만 골랐고, 실제로는 2초 안쪽입니다.</p>
<p>두 번째 질문에서 확인하고 싶었던 건 <strong>기억</strong>이었습니다. 서버는 아무것도 저장하지 않습니다(1장 코드에 상태가 없습니다). 그런데 앱이 매 요청에 대화 전체를 실어 보내니, 모델 입장에서는 이어지는 대화입니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/af4d0c07-1d8d-4656-9c21-902acd8c8c12/image.png" alt="멀티턴 — 앞 대화의 조건(&quot;두 문장으로&quot;)을 다음 답에서 그대로 지킨다"></p>
<p>&quot;두 문장으로 설명해줘&quot;라고 한 첫 질문의 조건을 두 번째 답이 정확히 짚어냅니다(&quot;당신은 두 문장으로 설명하라고 했고 …&quot;). 1편 7장에서 파이썬 리스트로 했던 것을 이번엔 <code>[Message]</code> 배열로 한 것뿐이고, 원리는 같습니다. 그리고 1편에서 이야기한 <strong>비용 구조</strong>도 같습니다 — 대화가 길어질수록 매 요청의 입력 토큰이 늘어납니다. 이 앱은 아직 아무 대책이 없고, 다음 편의 주제입니다.</p>
<hr>
<h2 id="🔧-5-설정-화면과-실기기--서버-주소를-바꿀-수-있게">🔧 5. 설정 화면과 실기기 — 서버 주소를 바꿀 수 있게</h2>
<p>시뮬레이터는 <code>127.0.0.1</code>로 되지만, 실제 iPhone에서 <code>127.0.0.1</code>은 <strong>폰 자기 자신</strong>입니다. Mac의 LAN 주소(예: <code>192.168.0.10</code>)를 넣을 수 있는 화면이 필요합니다.</p>
<h3 id="5-1-settingsviewswift">5-1. <code>SettingsView.swift</code></h3>
<pre><code class="language-swift">import SwiftUI

/// 서버 주소 입력 + 연결 테스트. 주소는 @AppStorage로 저장돼 앱을 다시 켜도 남는다
struct SettingsView: View {
    @AppStorage(&quot;serverURL&quot;) private var serverURL = ChatViewModel.defaultServer
    @State private var status = &quot;&quot;
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Form {
                Section(&quot;백엔드 서버&quot;) {
                    TextField(&quot;http://192.168.0.10:8599&quot;, text: $serverURL)
                        .keyboardType(.URL)
                        .textInputAutocapitalization(.never)
                        .autocorrectionDisabled()
                    Button(&quot;연결 테스트&quot;) { Task { await test() } }
                    if !status.isEmpty { Text(status).font(.footnote) }
                }
                Section {
                    Text(&quot;시뮬레이터 → http://127.0.0.1:8599\n실제 iPhone → http://&lt;Mac의 IP&gt;:8599 (같은 Wi-Fi)&quot;)
                        .font(.footnote).foregroundStyle(.secondary)
                }
            }
            .navigationTitle(&quot;설정&quot;)
            .toolbar { Button(&quot;완료&quot;) { dismiss() } }
        }
    }

    private func test() async {
        guard let url = URL(string: serverURL) else { status = &quot;❌ 주소 형식이 이상합니다&quot;; return }
        do {
            let h = try await ChatAPI(baseURL: url).health()
            status = h.ok ? &quot;✅ 연결 성공 — \(h.model)&quot; : &quot;❌ 서버 응답 이상&quot;
        } catch {
            status = &quot;❌ \(error.localizedDescription)&quot;
        }
    }
}

#Preview {
    SettingsView()
}</code></pre>
<p><code>@AppStorage(&quot;serverURL&quot;)</code>가 이 파일의 전부입니다. <code>UserDefaults</code>에 자동으로 저장되고, 앱을 지우지 않는 한 남습니다. <code>ChatViewModel</code>은 같은 키를 <code>UserDefaults.standard.string(forKey: &quot;serverURL&quot;)</code>로 읽으니 두 파일이 같은 값을 봅니다.</p>
<p>&quot;연결 테스트&quot; 버튼은 3장의 <code>health()</code>를 부릅니다. 성공하면 서버가 쓰는 모델 ID를 보여주도록 했습니다. 어느 모델에 붙어 있는지 앱에서 바로 보이는 게 의외로 유용했습니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/2bcfc355-30e7-43c8-b8c0-4aae2ac08bcb/image.png" alt="설정 — 연결 테스트 성공. 서버가 돌리는 모델 ID가 보인다"></p>
<p>서버를 꺼 두고 질문을 보내면 이렇게 됩니다. 3-3에서 넣은 &quot;한 글자도 못 받았으면 빈 말풍선 제거&quot;가 동작해서, 내 말풍선만 남고 아래에 빨간 한 줄이 뜹니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/9d8db786-ce95-455e-8118-015b0097d4b2/image.png" alt="서버가 꺼져 있을 때 — 빈 말풍선 없이 에러 한 줄"></p>
<h3 id="5-2-mac-주소-찾기">5-2. Mac 주소 찾기</h3>
<p>실기기에서 붙을 Mac의 LAN 주소는 터미널에서 이렇게 봅니다(Wi-Fi가 <code>en0</code>일 때).</p>
<pre><code class="language-bash">ipconfig getifaddr en0</code></pre>
<pre><code>192.168.123.120</code></pre><p>폰과 Mac이 <strong>같은 Wi-Fi</strong>에 있어야 하고, 서버는 1장처럼 <code>--host 0.0.0.0</code>으로 떠 있어야 합니다. macOS 방화벽이 켜져 있으면 처음 접속 때 &quot;python이 들어오는 연결을 허용할까요&quot; 팝업이 뜨는데 허용합니다.</p>
<h3 id="5-3-infoplist--로컬-네트워크-권한">5-3. <code>Info.plist</code> — 로컬 네트워크 권한</h3>
<p>iOS 14부터, 앱이 같은 Wi-Fi의 다른 기기(여기서는 Mac)에 붙으려 하면 시스템이 <strong>로컬 네트워크 권한</strong>을 묻습니다. 그 팝업에 들어갈 설명 문구를 <code>Info.plist</code>에 넣어야 합니다.</p>
<p>Xcode에서는 프로젝트 → 타깃 <code>SeanChat</code> → <strong>Info</strong> 탭 → 표 위에서 우클릭 → Add Row → <code>Privacy - Local Network Usage Description</code>을 고르고 문구를 넣으면 됩니다. 결과 파일은 이렇습니다.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot; &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
    &lt;key&gt;NSLocalNetworkUsageDescription&lt;/key&gt;
    &lt;string&gt;Mac에서 실행 중인 SeanChat 백엔드(포트 8599)에 연결하기 위해 로컬 네트워크 접근이 필요합니다.&lt;/string&gt;
    &lt;key&gt;NSAppTransportSecurity&lt;/key&gt;
    &lt;dict&gt;
        &lt;key&gt;NSAllowsLocalNetworking&lt;/key&gt;
        &lt;true/&gt;
    &lt;/dict&gt;
&lt;/dict&gt;
&lt;/plist&gt;</code></pre>
<p><code>NSAppTransportSecurity › NSAllowsLocalNetworking</code>도 같이 넣었습니다. iOS는 기본적으로 <code>http://</code>(TLS 없음) 연결을 막는데(ATS), 이 키는 <strong>IP 주소·<code>.local</code>·호스트명만 있는 주소</strong>에 한해 그 제한을 풉니다. Apple 문서에 따르면 iOS 16까지는 IP 주소 접속이 기본 허용이었지만 <strong>iOS 17부터는 이 키가 있어야</strong> 합니다. 4장의 시뮬레이터 스크린샷은 실제로 이 키 없이 <code>127.0.0.1</code>로 찍은 것이라 루프백은 예외인 듯한데, 실기기에서 <code>192.168.x.x</code>로 붙을 때는 넣어야 안전합니다. (공인 도메인에 <code>http://</code>로 붙는 건 이 키로는 안 풀립니다. 그땐 HTTPS를 붙이는 게 답이고, 3편에서 EC2 + ALB로 그렇게 합니다.)</p>
<blockquote>
<p><strong>⚠️ 여기서 한 번 걸렸습니다 — <code>Multiple commands produce &#39;…/SeanChat.app/Info.plist&#39;</code></strong>
저는 위 XML을 손으로 써서 <code>SeanChat</code> 폴더에 넣고, 빌드 설정 <code>INFOPLIST_FILE</code>을 그 파일로 지정했습니다. 그러자 위 에러로 빌드가 깨졌습니다. 2장 끝에서 말한 <strong>폴더 자동 동기화</strong> 때문입니다 — 폴더 안의 <code>Info.plist</code>가 &quot;리소스&quot;로도 복사되고 &quot;Info.plist&quot;로도 처리되어 같은 산출물을 두 번 만들려 한 것입니다.
Xcode의 <strong>Info 탭에서 키를 추가하는 정석 경로</strong>로 가면 Xcode가 파일 등록을 스스로 처리해서 보통 이 문제를 만나지 않습니다. 손으로 만들었다면: 왼쪽에서 <code>Info.plist</code>를 선택 → 오른쪽 File Inspector → <strong>Target Membership</strong>에서 <code>SeanChat</code> 체크를 끕니다. 그러면 리소스 복사에서 빠지고 <code>INFOPLIST_FILE</code>로만 쓰입니다. 저는 그렇게 해서 빌드가 다시 됐습니다.</p>
</blockquote>
<h3 id="5-4-실기기에-올리기">5-4. 실기기에 올리기</h3>
<p>iPhone을 USB로 연결하고(처음엔 폰에서 &quot;이 컴퓨터를 신뢰&quot; 확인), Xcode 상단 기기 목록에서 내 iPhone을 고르고 ▶를 누릅니다.</p>
<ul>
<li><strong>Signing</strong>: 프로젝트 → 타깃 → Signing &amp; Capabilities에서 Team을 본인 Apple ID로 고릅니다. 스토어에 올릴 앱이 아니라 내 폰에서만 쓸 거라 이걸로 충분합니다.</li>
<li><strong>폰 쪽 설정</strong>: 첫 실행 때 &quot;신뢰하지 않는 개발자&quot; 경고가 뜨면 설정 → 일반 → VPN 및 기기 관리에서 본인 계정을 신뢰합니다.</li>
<li><strong>앱 안에서</strong>: 설정(⚙) → 서버 주소를 <code>http://192.168.123.120:8599</code>처럼 Mac 주소로 바꾸고 → 연결 테스트. iOS가 로컬 네트워크 권한 팝업을 띄우면 허용합니다.</li>
</ul>
<h2 id="여기까지-되면-소파에-앉아-폰으로-mac의-bedrock-서버에-질문을-던질-수-있습니다-제-seanchat도-정확히-이-단계에서-쓸-만하다는-느낌이-처음-들었습니다">여기까지 되면 소파에 앉아 폰으로 Mac의 Bedrock 서버에 질문을 던질 수 있습니다. 제 SeanChat도 정확히 이 단계에서 &quot;쓸 만하다&quot;는 느낌이 처음 들었습니다.</h2>
<h2 id="🔧-6-한-줄-개선--굵게가-그대로-보이는-문제">🔧 6. 한 줄 개선 — <code>**굵게**</code>가 그대로 보이는 문제</h2>
<p>몇 번 쓰다 보니 바로 눈에 걸리는 게 있었습니다. 모델이 목록을 만들 때 <code>**제목**</code> 식으로 마크다운을 섞어 쓰는데, 앱은 그 별표를 <strong>그대로</strong> 보여줍니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/7ae38ed6-614d-4c0e-8a63-d9b1a7e8d4c3/image.png" alt="왼쪽: Text(message.text) — 별표가 그대로 보인다 / 오른쪽: AttributedString(markdown:) 적용 후"></p>
<p>SwiftUI의 <code>Text</code>는 문자열 리터럴에 한해 마크다운을 해석해 주지만, 변수로 넘긴 <code>String</code>은 해석하지 않습니다. 변수를 쓰려면 <code>AttributedString(markdown:)</code>으로 한 번 감싸야 합니다. <code>BubbleView</code>에 이 프로퍼티를 넣고 <code>Text(attributed)</code>로 바꾼 게 3-4 코드의 <code>attributed</code>입니다.</p>
<pre><code class="language-swift">    /// **굵게**, *기울임*, `코드` 같은 인라인 마크다운만 해석한다 (한 줄 개선)
    private var attributed: AttributedString {
        (try? AttributedString(markdown: message.text,
                               options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
        ?? AttributedString(message.text)
    }</code></pre>
<p>옵션의 <code>inlineOnlyPreservingWhitespace</code>가 중요합니다. 기본 옵션(<code>.full</code>)으로 하면 마크다운 파서가 <strong>줄바꿈을 접어</strong> 버려서, 모델이 정성껏 나눠 준 목록이 한 덩어리로 붙습니다. 인라인 문법(굵게·기울임·코드)만 해석하고 줄바꿈은 그대로 두라는 옵션입니다.</p>
<p>스크린샷 오른쪽을 보면 첫 줄 <code># iOS 앱 개발 초보자의 흔한 실수 3가지</code>의 <strong>헤딩(<code>#</code>)은 여전히 그대로</strong>입니다. 인라인만 해석하니 당연합니다. 코드 블록·표·헤딩까지 제대로 그리려면 마크다운 렌더러가 필요하고, 그건 이 한 줄로는 안 됩니다. 제 SeanChat 앱은 결국 렌더러를 따로 만들었는데(코드 블록 복사 버튼, 표 가로 스크롤 등) 이번 편 범위는 넘어서서 여기서 멈춥니다. 대신 서버의 시스템 프롬프트에 &quot;마크다운 헤딩은 쓰지 말고 굵게로 대신해라&quot;를 넣는 것도 방법입니다 — 앱을 고치는 것보다 서버 프롬프트 한 줄이 빠릅니다. 이게 1장에서 말한 &quot;Thin 클라이언트&quot;의 장점입니다.</p>
<hr>
<h2 id="⚠️-이번-편에서-만난-에러-모음">⚠️ 이번 편에서 만난 에러 모음</h2>
<h3 id="could-not-connect-to-the-server-실기기"><code>Could not connect to the server</code> (실기기)</h3>
<p>세 가지 중 하나입니다. 위에서부터 확인합니다.</p>
<ol>
<li>서버를 <code>--host 0.0.0.0</code> 없이 띄웠다 → 1장. <code>lsof -iTCP:8599 -sTCP:LISTEN</code>에서 <code>*:8599</code>가 아니라 <code>127.0.0.1:8599</code>로 보이면 이겁니다.</li>
<li>폰과 Mac이 다른 네트워크다(폰이 LTE, Mac이 Wi-Fi 등).</li>
<li>macOS 방화벽이 python을 막고 있다 → 시스템 설정 → 네트워크 → 방화벽 → 옵션에서 python 허용.</li>
</ol>
<p><code>설정 → 연결 테스트</code> 버튼이 <code>/health</code>만 부르기 때문에, 채팅을 보내기 전에 이걸로 먼저 확인하면 원인을 빨리 좁힐 수 있습니다.</p>
<h3 id="the-internet-connection-appears-to-be-offline-실기기-wi-fi는-켜져-있는데"><code>The Internet connection appears to be offline</code> (실기기, Wi-Fi는 켜져 있는데)</h3>
<p>1번을 고쳐서 Mac 쪽에서는 <code>curl http://192.168.x.x:8599/health</code>가 200을 주는데도 폰에서만 이 메시지가 나왔습니다. 인터넷이 끊긴 게 아니라, iOS가 <strong>로컬 네트워크 접근을 막았을 때</strong> URLSession이 이렇게(-1009) 포장해서 내보냅니다. 설정 → 개인정보 보호 및 보안 → <strong>로컬 네트워크</strong>에서 앱 토글을 켜면 됩니다. 그런데 그 목록에 앱이 <strong>없다면</strong> iOS가 권한을 물어본 적조차 없다는 뜻인데, 제 경우 <code>Info.plist</code>에 <code>NSLocalNetworkUsageDescription</code>이 빠진 빌드에서 그랬습니다. 5-3의 키를 넣고 다시 설치하면 첫 접속 때 팝업이 뜹니다.</p>
<h3 id="multiple-commands-produce-infoplist"><code>Multiple commands produce &#39;…/Info.plist&#39;</code></h3>
<p>5-3 참고. 손으로 만든 <code>Info.plist</code>가 리소스로도 복사되고 있어서입니다. File Inspector → Target Membership 체크 해제.</p>
<h3 id="말풍선이-에서-멈추고-아무것도-안-나온다">말풍선이 &quot;…&quot;에서 멈추고 아무것도 안 나온다</h3>
<p>서버 로그를 봅니다. <code>POST /chat 200</code>이 찍혔는데 앱에 글자가 안 오면 대부분 <strong>Bedrock 쪽 에러</strong>입니다(모델 액세스 미승인, 자격증명 만료, 1편 에러 모음 참고). 1장 서버는 이걸 <code>{&quot;type&quot;: &quot;error&quot;}</code> 이벤트로 흘려보내고, 앱은 빨간 한 줄로 보여주게 되어 있습니다. 그게 안 보인다면 서버가 1편 <code>step11_server.py</code> 그대로(에러 처리 없음)인지 확인합니다.</p>
<h3 id="시뮬레이터에서-한글이-안-쳐진다">시뮬레이터에서 한글이 안 쳐진다</h3>
<p>Mac 키보드로 시뮬레이터에 한글을 치려면 시뮬레이터 메뉴 <strong>I/O → Keyboard → Connect Hardware Keyboard</strong>가 켜져 있어야 합니다. 꺼져 있으면 화면 키보드로만 입력됩니다. 반대로 켜져 있으면 화면 키보드가 안 뜨는데, 이건 정상입니다.</p>
<h3 id="중지■를-눌렀는데-서버-로그에선-모델이-계속-답한다">중지(■)를 눌렀는데 서버 로그에선 모델이 계속 답한다</h3>
<p><code>AsyncThrowingStream</code>의 <code>onTermination</code>에서 <code>task.cancel()</code>을 안 했을 때 생깁니다(3-2 ⑥). 앱 화면만 멈추고 소켓은 살아 있어서 토큰이 계속 소모됩니다. 서버 로그로만 보이는 문제라 눈치채기 어렵습니다.</p>
<h3 id="빌드는-되는데-observable을-못-찾는다">빌드는 되는데 <code>@Observable</code>을 못 찾는다</h3>
<p>Minimum Deployments가 iOS 16 이하로 잡혀 있을 때입니다. 프로젝트 → 타깃 → General → Minimum Deployments를 17.0 이상으로.</p>
<hr>
<h2 id="📝-정리">📝 정리</h2>
<p>이번 편에서 한 것:</p>
<table>
<thead>
<tr>
<th>장</th>
<th>한 것</th>
<th>파일</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>서버에 멀티턴·시스템 프롬프트·에러 이벤트 추가</td>
<td><code>step12_server.py</code></td>
</tr>
<tr>
<td>2</td>
<td>Xcode 프로젝트 생성 (SwiftUI, Storage None)</td>
<td>—</td>
</tr>
<tr>
<td>3</td>
<td>모델·API(SSE 파싱)·뷰모델·화면</td>
<td><code>Message</code> <code>ChatAPI</code> <code>ChatViewModel</code> <code>ContentView</code></td>
</tr>
<tr>
<td>4</td>
<td>시뮬레이터에서 스트리밍·멀티턴 확인</td>
<td>—</td>
</tr>
<tr>
<td>5</td>
<td>서버 주소 설정, 로컬 네트워크 권한, 실기기</td>
<td><code>SettingsView</code> <code>Info.plist</code></td>
</tr>
<tr>
<td>6</td>
<td>인라인 마크다운 렌더</td>
<td><code>ContentView</code> (<code>attributed</code>)</td>
</tr>
</tbody></table>
<p>기억에 남는 것 세 가지:</p>
<ol>
<li><strong>SSE는 앱 쪽에서 <code>bytes(for:)</code> + <code>.lines</code> 두 개로 끝난다.</strong> 라이브러리가 필요 없었습니다.</li>
<li><strong>대화 기억은 앱이 들고 있다.</strong> 서버는 상태가 없고, 그래서 서버를 아무 때나 죽이고 다시 띄워도 앱은 모릅니다. 1편 7장의 파이썬 리스트가 <code>[Message]</code>가 된 것뿐입니다.</li>
<li><strong>앱은 얇게, 판단은 서버에.</strong> 마크다운 헤딩 문제도 앱을 고치는 대신 서버 프롬프트 한 줄로 풀 수 있었습니다. 이 구조 덕에 앱 심사·재설치 없이 서버만 계속 고쳐 나갈 수 있습니다.</li>
</ol>
<p>지금 앱은 대화 하나가 전부입니다. 앱을 죽이면 대화가 사라지고, 대화가 길어질수록 매 요청의 입력 토큰이 늘어나고, 서버는 Mac이 잠들면 같이 잠듭니다. 이 세 가지가 다음 편들의 주제입니다.</p>
<table>
<thead>
<tr>
<th>편</th>
<th>주제</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Bedrock 기초 — 첫 호출부터 대화·도구 호출까지</td>
</tr>
<tr>
<td><strong>2 (이 글)</strong></td>
<td><strong>iPhone 앱 붙이기 — SwiftUI + SSE</strong></td>
</tr>
<tr>
<td>3</td>
<td>서버를 밖으로 — EC2 + ALB + HTTPS, 그리고 운영하면서 겪은 문제들(재시도·타임아웃·비용)</td>
</tr>
<tr>
<td>4</td>
<td>도구와 MCP, 위험한 도구엔 사람 승인 게이트</td>
</tr>
<tr>
<td>5</td>
<td>토큰 원장 · 컨텍스트 압축 · SQLite로 대화 저장</td>
</tr>
</tbody></table>
<p>이 글의 코드는 그대로 복사해서 빌드되도록 파일 단위로 실었습니다. 막히는 부분이 있으면 댓글로 남겨 주세요. 저도 Swift는 이 앱으로 배운 입장이라, 더 나은 방법이 있으면 배우고 싶습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쉽게 따라하는 Amazon Bedrock으로 나만의 AI 어시스턴트 만들기 (1) — 첫 호출부터 대화·도구 호출까지]]></title>
            <link>https://velog.io/@seanson2709/%EC%89%BD%EA%B2%8C-%EB%94%B0%EB%9D%BC%ED%95%98%EB%8A%94-Amazon-Bedrock%EC%9C%BC%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-AI-%EC%96%B4%EC%8B%9C%EC%8A%A4%ED%84%B4%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-1-%EC%B2%AB-%ED%98%B8%EC%B6%9C%EB%B6%80%ED%84%B0-%EB%8C%80%ED%99%94%EB%8F%84%EA%B5%AC-%ED%98%B8%EC%B6%9C%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@seanson2709/%EC%89%BD%EA%B2%8C-%EB%94%B0%EB%9D%BC%ED%95%98%EB%8A%94-Amazon-Bedrock%EC%9C%BC%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-AI-%EC%96%B4%EC%8B%9C%EC%8A%A4%ED%84%B4%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-1-%EC%B2%AB-%ED%98%B8%EC%B6%9C%EB%B6%80%ED%84%B0-%EB%8C%80%ED%99%94%EB%8F%84%EA%B5%AC-%ED%98%B8%EC%B6%9C%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Sun, 20 Sep 2026 15:26:39 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-개요">📌 개요</h2>
<p>이 글에서는 <strong>Amazon Bedrock</strong>을 이용해 개인용 AI 어시스턴트를 <strong>처음부터</strong> 만들어봅니다.
LangChain 같은 프레임워크 없이 <strong>boto3로 직접</strong> 호출하면서, Bedrock의 기초 기능을 하나씩 눌러봅니다.</p>
<p>저는 올해 초부터 <strong>SeanChat</strong>이라는 개인 어시스턴트를 만들어 쓰고 있습니다.
터미널(CLI)과 iPhone 앱이 같은 백엔드에 붙고, 백엔드는 Bedrock의 Claude 모델을 호출합니다.
파일 읽기·셸 명령·웹 검색 같은 도구도 붙어 있어서, 요즘은 일할 때 거의 항상 켜놓고 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/97b2193f-8479-4a8a-9d90-eb91257d68f3/image.png" alt="SeanChat CLI — 질문 → 🔧 bash 도구 호출 → 답변 → 토큰 사용량"></p>
<p>처음엔 저도 &quot;Bedrock 호출하는 게 뭐 어렵겠어&quot;라고 생각했는데,
<strong>모델 ID를 그대로 넣었더니 거부</strong>당하는 것부터 시작해서 생각보다 많이 헤맸습니다.
그때 하나씩 실험하면서 정리한 것을 그대로 옮긴 글입니다.</p>
<p>이 시리즈는 이렇게 이어질 예정입니다.</p>
<table>
<thead>
<tr>
<th>편</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1편 (이 글)</strong></td>
<td>Bedrock 기초 — 모델 선택, 첫 호출, 파라미터, 토큰, 스트리밍, 대화 기억, 도구 호출, 캐싱, SSE 백엔드</td>
</tr>
<tr>
<td>2편</td>
<td>iPhone 앱 붙이기 — Xcode 새 프로젝트부터 SwiftUI 채팅 화면, SSE 파싱, 서버 주소 설정, 실기기 설치까지</td>
</tr>
<tr>
<td>3편</td>
<td>서버를 밖으로 — EC2 + ALB + HTTPS, 그리고 운영하면서 물린 것들(타임아웃, 스트림 재시도, ALB 뒤에서 끊김, 모델별 파라미터 차이)</td>
</tr>
<tr>
<td>4편</td>
<td>도구를 MCP로 확장 + 위험한 도구(bash)에 사람 승인 게이트 두기</td>
</tr>
<tr>
<td>5편</td>
<td>토큰 원장·비용 상한, 컨텍스트 압축, 대화 저장(SQLite)</td>
</tr>
</tbody></table>
<p><strong>이 글의 모든 실행 결과는 제 개인 계정(us-east-2)에서 2026년 9월에 실제로 돌린 출력을 그대로 붙인 것입니다.</strong>
한 단계에 파이썬 파일 하나씩, 전부 합쳐도 200줄이 안 됩니다.</p>
<hr>
<h2 id="📦-bedrock-30초-이해--용어부터">📦 Bedrock 30초 이해 — 용어부터</h2>
<p>코드를 치기 전에 용어 몇 개만 잡고 가겠습니다. 이걸 모르면 에러 메시지를 읽을 수 없습니다.</p>
<table>
<thead>
<tr>
<th>용어</th>
<th>뜻</th>
<th>이 글에서</th>
</tr>
</thead>
<tbody><tr>
<td><strong>파운데이션 모델(FM)</strong></td>
<td>Claude, Nova, Llama 같은 대형 언어 모델</td>
<td>Claude Haiku 4.5를 주로 씀</td>
</tr>
<tr>
<td><strong>모델 ID</strong></td>
<td>모델을 가리키는 문자열 (<code>anthropic.claude-haiku-4-5-...</code>)</td>
<td>1장에서 처음 막힌 곳</td>
</tr>
<tr>
<td><strong>추론 프로파일(Inference Profile)</strong></td>
<td>여러 리전에 걸쳐 모델을 호출하는 라우팅 ID (<code>us.</code> / <code>global.</code> 접두어)</td>
<td>최신 모델은 이걸로만 호출 가능</td>
</tr>
<tr>
<td><strong>토큰(Token)</strong></td>
<td>모델이 글을 처리하는 단위. <strong>글자 수 ≠ 토큰 수</strong></td>
<td>4장에서 실측</td>
</tr>
<tr>
<td><strong>시스템 프롬프트</strong></td>
<td>모델에게 역할·규칙을 주는 첫 지시문</td>
<td>5장</td>
</tr>
<tr>
<td><strong>컨텍스트 윈도우</strong></td>
<td>한 번에 넣을 수 있는 토큰 총량</td>
<td>대화가 길어지면 여기 부딪힘</td>
</tr>
<tr>
<td><strong>스트리밍</strong></td>
<td>답변을 다 만들고 보내는 대신 조각으로 흘려보내기</td>
<td>6장</td>
</tr>
<tr>
<td><strong>도구 호출(Tool Use)</strong></td>
<td>모델이 &quot;이 함수를 불러줘&quot;라고 요청하는 기능</td>
<td>8장, 에이전트의 실체</td>
</tr>
</tbody></table>
<p>Bedrock의 API는 크게 두 층입니다. 이것도 처음에 헷갈렸던 부분입니다.</p>
<pre><code>bedrock          ← 컨트롤 플레인: 모델 목록 조회, 액세스 관리 (aws bedrock list-foundation-models)
bedrock-runtime  ← 데이터 플레인: 실제로 모델에게 질문 (converse, converse_stream)</code></pre><p>boto3에서 <code>boto3.client(&quot;bedrock&quot;)</code>과 <code>boto3.client(&quot;bedrock-runtime&quot;)</code>은 <strong>다른 클라이언트</strong>입니다.
<code>bedrock</code> 클라이언트로 <code>converse</code>를 부르면 그런 메서드 없다고 합니다.</p>
<hr>
<h2 id="🛠️-0-사전-준비">🛠️ 0. 사전 준비</h2>
<h3 id="0-1-리전-선택과-모델-액세스">0-1. 리전 선택과 모델 액세스</h3>
<p>Bedrock은 <strong>리전마다 쓸 수 있는 모델이 다릅니다.</strong> 저는 <code>us-east-2</code>(오하이오)를 씁니다.
서울(<code>ap-northeast-2</code>)도 되지만, 신모델이 미국 리전에 먼저 풀리는 경우가 많아서 개인 프로젝트는 미국 리전을 추천합니다.</p>
<p>콘솔에서 먼저 모델 액세스를 켭니다.</p>
<pre><code>Bedrock 콘솔 → 좌측 메뉴 맨 아래 Model access → Modify model access
→ 쓸 모델(Anthropic Claude ...) 체크 → Submit</code></pre><!-- 📸 스크린샷 자리 #2 (Sean 직접 캡처) : Bedrock 콘솔 → Model access, Anthropic Claude 행이 "Access granted"인 화면
     리전 us-east-2(오하이오)로 두고 캡처. 우상단 계정 ID·계정명은 가리기.
     캡처 후 images/02-model-access.png 로 저장 -->

<p>처음 쓰는 계정이면 몇 분 안에 <code>Access granted</code>로 바뀝니다.
이걸 안 켜고 호출하면 <code>AccessDeniedException</code>이 뜨는데, <strong>IAM 권한 문제와 메시지가 같아서</strong> 처음엔 구분이 안 됩니다.
구분법은 이 글 마지막 &quot;에러 모음&quot;에 정리했습니다.</p>
<h3 id="0-2-자격증명--두-가지-방법">0-2. 자격증명 — 두 가지 방법</h3>
<table>
<thead>
<tr>
<th>방법</th>
<th>언제 쓰나</th>
<th>설정</th>
</tr>
</thead>
<tbody><tr>
<td><strong>IAM Identity Center(SSO) 프로필</strong></td>
<td>내 개발 머신에서 콘솔·CLI 같이 쓸 때</td>
<td><code>aws sso login --profile &lt;프로필&gt;</code></td>
</tr>
<tr>
<td><strong>Bedrock API key</strong></td>
<td>서버·컨테이너에 두고 상시 구동할 때</td>
<td>환경변수 하나로 끝</td>
</tr>
</tbody></table>
<p>저는 처음엔 SSO 프로필로 하다가, 백엔드를 EC2에 올리면서 두 번째로 바꿨습니다.
SSO 토큰은 몇 시간마다 만료돼서 상시 구동 서버에는 맞지 않습니다.</p>
<p>Bedrock은 <strong>서비스 전용 자격증명(service-specific credential)</strong> 을 지원합니다.
<code>AWS_BEARER_TOKEN_BEDROCK</code> 환경변수 하나만 있으면 boto3가 알아서 씁니다.</p>
<pre><code class="language-bash"># 발급 (만료일이 있습니다 — 상시 구동 서버라면 만료일을 캘린더에 걸어두세요)
aws iam create-service-specific-credential \
  --user-name &lt;Bedrock 전용 IAM 사용자&gt; \
  --service-name bedrock.amazonaws.com

# 사용
echo &#39;export AWS_BEARER_TOKEN_BEDROCK=&lt;발급된 키&gt;&#39; &gt; ~/.bedrock.env
chmod 600 ~/.bedrock.env &amp;&amp; source ~/.bedrock.env</code></pre>
<p>그 IAM 사용자에게 붙일 정책은 이 정도로 충분합니다(최소 권한).</p>
<pre><code class="language-json">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [{
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Action&quot;: [
      &quot;bedrock:InvokeModel&quot;,
      &quot;bedrock:InvokeModelWithResponseStream&quot;,
      &quot;bedrock:Converse&quot;,
      &quot;bedrock:ConverseStream&quot;,
      &quot;bedrock:ListFoundationModels&quot;,
      &quot;bedrock:ListInferenceProfiles&quot;,
      &quot;bedrock:GetFoundationModelAvailability&quot;
    ],
    &quot;Resource&quot;: &quot;*&quot;
  }]
}</code></pre>
<p>자격증명이 <strong>제대로 잡혔는지 확인하는 가장 빠른 방법</strong>은 그냥 한 번 호출해보는 겁니다.
안 잡혀 있으면 이렇게 나옵니다(실제 출력).</p>
<pre><code>NoCredentialsError: Unable to locate credentials</code></pre><p>권한이 <strong>모자라면</strong> 이렇게 나옵니다. 메시지 안에 <strong>어떤 액션이 거부됐는지</strong> 그대로 적혀 있으니
당황하지 말고 <code>is not authorized to perform:</code> 뒤를 읽으면 됩니다.</p>
<pre><code>AccessDeniedException: User: arn:aws:iam::123456789012:user/bedrock-user is not authorized
to perform: bedrock:CountTokens on resource: arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-haiku-4-5-20251001-v1:0
because no identity-based policy allows the bedrock:CountTokens action</code></pre><blockquote>
<p>위 에러는 이 글을 쓰다가 실제로 만난 겁니다. 4장에서 써보려던 <code>count_tokens</code>가 위 정책에 없어서 났습니다.
해결은 메시지에 적힌 그대로 — 정책 <code>Action</code>에 <code>bedrock:CountTokens</code>를 추가하는 것입니다.
<strong>Bedrock 에러는 메시지가 친절한 편</strong>이라 끝까지 읽는 습관만 있으면 절반은 해결됩니다.</p>
</blockquote>
<h3 id="0-3-파이썬-환경">0-3. 파이썬 환경</h3>
<pre><code class="language-bash">mkdir bedrock-lab &amp;&amp; cd bedrock-lab
python3 -m venv .venv &amp;&amp; source .venv/bin/activate
pip install boto3 fastapi &quot;uvicorn[standard]&quot;</code></pre>
<p>boto3는 최신으로 두세요. Bedrock은 기능이 자주 추가돼서 오래된 boto3에는 파라미터 자체가 없는 경우가 있습니다.</p>
<hr>
<h2 id="🔧-1-어떤-모델을-부를-수-있나--모델-id-호출-거부">🔧 1. 어떤 모델을 부를 수 있나 — 모델 ID 호출 거부?</h2>
<p>제일 먼저 한 일은 &quot;이 리전에 무슨 모델이 있나&quot; 조회였습니다.</p>
<pre><code class="language-bash">aws bedrock list-foundation-models --region us-east-2 \
  --query &quot;modelSummaries[?contains(modelId,&#39;claude&#39;)].modelId&quot; --output table</code></pre>
<pre><code>-----------------------------------------------
|            ListFoundationModels             |
+---------------------------------------------+
|  anthropic.claude-sonnet-4-20250514-v1:0    |
|  anthropic.claude-haiku-4-5-20251001-v1:0   |
|  anthropic.claude-sonnet-4-5-20250929-v1:0  |
|  anthropic.claude-opus-4-5-20251101-v1:0    |
|  anthropic.claude-sonnet-4-6                |
|  anthropic.claude-opus-4-6-v1               |
|  ...                                        |
+---------------------------------------------+</code></pre><p>여기 나온 <code>anthropic.claude-haiku-4-5-20251001-v1:0</code>을 그대로 코드에 넣었습니다. 그리고 첫 에러를 만났습니다.</p>
<pre><code>ValidationException: Invocation of model ID anthropic.claude-haiku-4-5-20251001-v1:0
with on-demand throughput isn&#39;t supported. Retry your request with the ID or ARN of
an inference profile that contains this model.</code></pre><p>&quot;모델 목록에 있는데 왜 못 부르지?&quot;가 첫 반응이었습니다.
읽어보면 답이 적혀 있습니다 — <strong>inference profile의 ID를 쓰라</strong>는 겁니다.</p>
<p>최신 모델들은 특정 리전 한 곳에서만 처리하는 on-demand 호출을 막고,
<strong>크로스 리전 추론 프로파일</strong>로만 열어둔 경우가 많습니다. 목록을 따로 조회합니다.</p>
<pre><code class="language-bash">aws bedrock list-inference-profiles --region us-east-2 \
  --query &quot;inferenceProfileSummaries[?contains(inferenceProfileId,&#39;claude&#39;)].[inferenceProfileId,status]&quot; \
  --output table</code></pre>
<pre><code>----------------------------------------------------------------
|                     ListInferenceProfiles                    |
+----------------------------------------------------+---------+
|  us.anthropic.claude-haiku-4-5-20251001-v1:0       |  ACTIVE |
|  global.anthropic.claude-haiku-4-5-20251001-v1:0   |  ACTIVE |
|  us.anthropic.claude-sonnet-4-5-20250929-v1:0      |  ACTIVE |
|  global.anthropic.claude-sonnet-4-5-20250929-v1:0  |  ACTIVE |
|  us.anthropic.claude-sonnet-4-6                    |  ACTIVE |
|  ...                                               |         |
+----------------------------------------------------+---------+</code></pre><p>같은 모델에 접두어가 붙은 ID가 두 종류씩 있습니다.</p>
<table>
<thead>
<tr>
<th>접두어</th>
<th>의미</th>
<th>선택 기준</th>
</tr>
</thead>
<tbody><tr>
<td>(없음)</td>
<td>이 리전에서만 처리</td>
<td>최신 모델은 on-demand 미지원인 경우가 많음 → 위 에러</td>
</tr>
<tr>
<td><code>us.</code></td>
<td>미국 리전들 사이에서 라우팅</td>
<td><strong>기본 선택.</strong> 데이터가 미국 밖으로 안 나감</td>
</tr>
<tr>
<td><code>global.</code></td>
<td>전 세계 리전으로 라우팅</td>
<td>가용성은 최고, 하지만 <strong>데이터 처리 위치가 넓어짐</strong> — 규제 환경이면 확인 필요</td>
</tr>
</tbody></table>
<p>이 글에서는 전부 <strong><code>us.anthropic.claude-haiku-4-5-20251001-v1:0</code></strong> 을 씁니다.
Haiku는 Claude 중 가장 빠르고 저렴해서 실험용으로 딱 맞습니다.</p>
<blockquote>
<p>💡 <strong>모델 ID는 코드에 흩뿌리지 말고 한 곳에 별칭으로 모으세요.</strong>
저는 <code>{&quot;haiku&quot;: &quot;us.anthropic.claude-haiku-4-5-...&quot;, &quot;sonnet&quot;: &quot;us.anthropic.claude-sonnet-4-5-...&quot;}</code>
딕셔너리 하나에 두고 설정에서 별칭만 바꿉니다. 위 목록만 봐도 알 수 있듯이 모델은 몇 달마다 갱신됩니다.</p>
</blockquote>
<blockquote>
<p>🔗 위 두 명령(<code>list-foundation-models</code>·<code>list-inference-profiles</code>)의 결과를 <strong>매일 자동으로 갱신해 한 페이지에 정리</strong>해 두었습니다 → <a href="https://hub.sean-chloe.com/bedrock/">hub.sean-chloe.com/bedrock</a> — 서울 리전에서 지금 호출 가능한 모델, on-demand/CRIS 구분, 버지니아엔 있는데 서울엔 없는 모델 목록, 변경 이력 RSS. 글 작성 시점 이후 새로 열린 모델은 여기서 확인하세요.</p>
</blockquote>
<hr>
<h2 id="🔧-2-첫-호출--converse-api-그리고-응답-해부">🔧 2. 첫 호출 — Converse API, 그리고 응답 해부</h2>
<p>Bedrock에는 모델을 부르는 방법이 두 가지 있습니다.</p>
<table>
<thead>
<tr>
<th>API</th>
<th>특징</th>
<th>언제</th>
</tr>
</thead>
<tbody><tr>
<td><code>InvokeModel</code></td>
<td>모델 제공사마다 body 형식이 다름 (Anthropic은 <code>anthropic_version</code>, Nova는 또 다른 형식…)</td>
<td>특정 모델 전용 기능이 필요할 때</td>
</tr>
<tr>
<td><strong><code>Converse</code></strong></td>
<td><strong>모든 모델에 같은 요청/응답 형식</strong></td>
<td><strong>기본 선택</strong></td>
</tr>
</tbody></table>
<p><strong><code>Converse</code>를 쓰세요.</strong> 나중에 모델을 Haiku에서 Sonnet으로, 또 Nova로 바꿔도 코드를 안 고칩니다.</p>
<p><strong><code>step2_converse.py</code></strong> — 첫 호출입니다. 응답을 통째로 찍어서 구조를 봅니다.</p>
<pre><code class="language-python">&quot;&quot;&quot;응답 전체를 그대로 찍어보기 — 구조를 눈으로 익히는 게 먼저다&quot;&quot;&quot;
import boto3, json

runtime = boto3.client(&quot;bedrock-runtime&quot;, region_name=&quot;us-east-2&quot;)   # ← bedrock 아님, bedrock-runtime

resp = runtime.converse(
    modelId=&quot;us.anthropic.claude-haiku-4-5-20251001-v1:0&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: &quot;안녕! 한 문장으로 인사해줘.&quot;}]}],
    inferenceConfig={&quot;maxTokens&quot;: 100},
)
resp.pop(&quot;ResponseMetadata&quot;, None)              # HTTP 메타는 지우고 본문만
print(json.dumps(resp, ensure_ascii=False, indent=2))</code></pre>
<pre><code>$ python step2_converse.py
{
  &quot;output&quot;: {
    &quot;message&quot;: {
      &quot;role&quot;: &quot;assistant&quot;,
      &quot;content&quot;: [
        {
          &quot;text&quot;: &quot;안녕하세요, 반갑습니다! 😊&quot;
        }
      ]
    }
  },
  &quot;stopReason&quot;: &quot;end_turn&quot;,
  &quot;usage&quot;: {
    &quot;inputTokens&quot;: 26,
    &quot;outputTokens&quot;: 22,
    &quot;totalTokens&quot;: 48,
    &quot;cacheReadInputTokens&quot;: 0,
    &quot;cacheWriteInputTokens&quot;: 0
  },
  &quot;metrics&quot;: {
    &quot;latencyMs&quot;: 650
  }
}</code></pre><!-- 📸 스크린샷 자리 #3 : 위 실행 결과 터미널 (첫 성공의 순간) -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/19d8e1f9-aede-4e85-9a09-0d4c1e5d3ae8/image.png" alt="첫 호출 결과"></p>
<p>처음 성공했을 때 이 JSON을 한참 들여다봤는데, 결국 이 글 전체가 이 구조의 응용입니다.
<strong>요청</strong>과 <strong>응답</strong> 각각 한 번씩만 정확히 익히면 됩니다.</p>
<h3 id="요청-구조">요청 구조</h3>
<pre><code class="language-python">messages=[
  {&quot;role&quot;: &quot;user&quot;,                     # user 또는 assistant, 반드시 번갈아
   &quot;content&quot;: [                        # ← 배열! 문자열이 아님
     {&quot;text&quot;: &quot;안녕! ...&quot;}             # 텍스트 블록. 이미지·문서 블록도 여기 들어감
   ]}
]</code></pre>
<p><code>content</code>가 <strong>문자열이 아니라 블록 배열</strong>인 게 처음엔 번거롭게 느껴지지만,
나중에 이미지·문서·도구 결과를 같은 자리에 넣게 되면 이유를 알게 됩니다.</p>
<h3 id="응답-구조">응답 구조</h3>
<table>
<thead>
<tr>
<th>경로</th>
<th>내용</th>
<th>실무에서 쓰는 곳</th>
</tr>
</thead>
<tbody><tr>
<td><code>output.message</code></td>
<td><strong>요청과 똑같은 형식</strong>의 assistant 메시지</td>
<td>그대로 대화 기록에 append (7장)</td>
</tr>
<tr>
<td><code>output.message.content[]</code></td>
<td>응답 블록 배열 (<code>text</code>, <code>toolUse</code>, <code>reasoningContent</code>)</td>
<td>화면 출력</td>
</tr>
<tr>
<td><code>stopReason</code></td>
<td>왜 멈췄나 — <code>end_turn</code> / <code>max_tokens</code> / <code>tool_use</code> / <code>stop_sequence</code></td>
<td><strong>루프 제어의 핵심</strong> (3·8장)</td>
</tr>
<tr>
<td><code>usage.inputTokens</code> / <code>outputTokens</code></td>
<td>과금 단위</td>
<td>비용 집계 (4장)</td>
</tr>
<tr>
<td><code>usage.cacheRead/WriteInputTokens</code></td>
<td>캐시 적중량</td>
<td>9장</td>
</tr>
<tr>
<td><code>metrics.latencyMs</code></td>
<td>서버 측 처리 시간</td>
<td>모델 선택 근거</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ <strong><code>content[0][&quot;text&quot;]</code>로 꺼내면 언젠가 터집니다.</strong>
<code>content</code>는 배열이고 첫 블록이 텍스트라는 보장이 없습니다.
도구를 붙이면 첫 블록이 <code>toolUse</code>인 경우가 흔해서 <code>KeyError: &#39;text&#39;</code>가 납니다.
처음부터 이렇게 순회하는 습관을 들이세요.</p>
<pre><code class="language-python">text = &quot;&quot;.join(b.get(&quot;text&quot;, &quot;&quot;) for b in resp[&quot;output&quot;][&quot;message&quot;][&quot;content&quot;])</code></pre>
</blockquote>
<p>응답의 <code>output.message</code>가 요청의 <code>messages[i]</code>와 <strong>완전히 같은 형식</strong>이라는 점도 눌러두세요.
그래서 7장에서 &quot;응답을 그대로 append&quot;하는 게 가능합니다.</p>
<hr>
<h2 id="🔧-3-파라미터-하나씩-체감하기--inferenceconfig">🔧 3. 파라미터 하나씩 체감하기 — inferenceConfig</h2>
<p><code>inferenceConfig</code>에 들어가는 값이 넷 있습니다. 문서로 읽으면 뻔한데 직접 돌려보면 감이 옵니다.</p>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>뜻</th>
<th>기본값</th>
</tr>
</thead>
<tbody><tr>
<td><code>maxTokens</code></td>
<td>출력 토큰 상한. 넘으면 <strong>잘라버림</strong></td>
<td>모델별로 다름 → <strong>항상 명시</strong></td>
</tr>
<tr>
<td><code>temperature</code></td>
<td>0 = 결정적, 1 = 다양함</td>
<td>1.0</td>
</tr>
<tr>
<td><code>topP</code></td>
<td>확률 상위 P%에서만 뽑기 (temperature와 둘 중 하나만 조절)</td>
<td>모델별</td>
</tr>
<tr>
<td><code>stopSequences</code></td>
<td>이 문자열이 나오면 즉시 멈춤</td>
<td>없음</td>
</tr>
</tbody></table>
<p><strong><code>step3_params.py</code></strong> — 세 가지를 실제로 눌러봤습니다.</p>
<pre><code class="language-python">def ask(label, q, **cfg):
    resp = runtime.converse(modelId=MODEL, inferenceConfig=cfg,
                            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: q}]}])
    txt = &quot;&quot;.join(b.get(&quot;text&quot;, &quot;&quot;) for b in resp[&quot;output&quot;][&quot;message&quot;][&quot;content&quot;])
    print(f&quot;[{label}] stopReason={resp[&#39;stopReason&#39;]}  out={resp[&#39;usage&#39;][&#39;outputTokens&#39;]}토큰&quot;)
    print(f&quot;   → {txt!r}\n&quot;)

ask(&quot;maxTokens=20&quot;, &quot;클라우드 컴퓨팅을 자세히 설명해줘.&quot;, maxTokens=20)
ask(&quot;stopSequences&quot;, &quot;1, 2, 3, 4, 5까지 세어줘.&quot;, maxTokens=100, stopSequences=[&quot;3&quot;])

for t in (0.0, 1.0):                                   # 같은 질문을 3번씩
    outs = []
    for _ in range(3):
        resp = runtime.converse(modelId=MODEL, inferenceConfig={&quot;maxTokens&quot;: 40, &quot;temperature&quot;: t},
            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: &quot;귀여운 강아지 이름 하나만 지어줘. 이름만.&quot;}]}])
        outs.append(&quot;&quot;.join(b.get(&quot;text&quot;, &quot;&quot;) for b in resp[&quot;output&quot;][&quot;message&quot;][&quot;content&quot;]).strip())
    print(f&quot;temperature={t} → {outs}&quot;)</code></pre>
<pre><code>$ python step3_params.py
[maxTokens=20] stopReason=max_tokens  out=20토큰
   → &#39;# 클라우드 컴퓨팅 상세 설명&#39;

[stopSequences] stopReason=stop_sequence  out=8토큰
   → &#39;1, 2, &#39;

temperature=0.0 → [&#39;뭉치&#39;, &#39;뭉치&#39;, &#39;뭉치&#39;]
temperature=1.0 → [&#39;뽀삐&#39;, &#39;뽀삐&#39;, &#39;뭉치&#39;]</code></pre><!-- 📸 스크린샷 자리 #3b -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/ede8a230-8b46-4641-8e94-23562dc719cb/image.png" alt="파라미터 체감"></p>
<p>세 줄에서 배운 것을 정리하면 이렇습니다.</p>
<p><strong>① <code>maxTokens</code>는 &quot;잘라내기&quot;다.</strong> 자세히 설명해달라고 했는데 20토큰에서 제목만 쓰고 끊겼습니다.
중요한 건 <code>stopReason</code>이 <strong><code>max_tokens</code></strong> 로 온다는 점입니다.
답변이 뚝 끊긴 채로 왔는데 원인을 못 찾고 있다면, 십중팔구 이 값을 확인 안 한 겁니다.
<code>stopReason == &quot;max_tokens&quot;</code>이면 화면에 &quot;출력 상한에 걸려 잘렸습니다&quot; 표시를 붙여두는 것을 권합니다.</p>
<p><strong>② <code>stopSequences</code>는 즉시 멈춤.</strong> <code>&quot;3&quot;</code>을 만나자마자 멈춰서 <code>&#39;1, 2, &#39;</code>까지만 왔고,
<code>stopReason</code>은 <strong><code>stop_sequence</code></strong> 입니다. 정해진 형식으로만 답을 받을 때(예: <code>###END###</code> 마커) 씁니다.</p>
<p><strong>③ <code>temperature</code>는 &quot;같은 질문에 같은 답이 나오게 할 것인가&quot;.</strong>
0으로 놓으면 세 번 다 <code>뭉치</code>, 1.0으로 놓으면 갈립니다.
테스트 코드·분류·정형 추출은 <strong>0 근처</strong>, 아이디어·글쓰기는 <strong>높게</strong>. 저는 범용 어시스턴트라 기본값을 0.7로 두고 있습니다.</p>
<blockquote>
<p>💡 <code>stopReason</code>을 무시하면 안 되는 이유가 여기서 나옵니다.
같은 &quot;정상 응답(200 OK)&quot;이라도 <code>end_turn</code>(다 말함)과 <code>max_tokens</code>(잘림)는 완전히 다른 상황입니다.
<strong>HTTP 상태코드가 아니라 <code>stopReason</code>이 진짜 결과 코드</strong>입니다.</p>
</blockquote>
<hr>
<h2 id="🔧-4-토큰이란-무엇인가--요금의-단위">🔧 4. 토큰이란 무엇인가 — 요금의 단위</h2>
<p>Bedrock 요금은 <strong>토큰 단위</strong>로 계산됩니다. 그런데 토큰은 글자도 단어도 아닙니다.
이게 얼마나 다른지 직접 재봤습니다.</p>
<p>토큰을 세는 정식 방법은 <code>count_tokens</code> API인데(0장에서 권한 에러가 났던 그것),
권한을 추가하기 전이라 우회했습니다 — <strong><code>maxTokens=1</code>로 호출해서 <code>usage.inputTokens</code>만 읽는</strong> 방법입니다.
출력 토큰 1개 값만 내면 입력 토큰 수를 정확히 알 수 있습니다.</p>
<p><strong><code>step4_tokens.py</code></strong></p>
<pre><code class="language-python">def input_tokens(text):
    resp = runtime.converse(modelId=MODEL, inferenceConfig={&quot;maxTokens&quot;: 1},
                            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: text}]}])
    return resp[&quot;usage&quot;][&quot;inputTokens&quot;]

BASE = input_tokens(&quot;a&quot;) - 1        # 한 글자 요청의 토큰 − 1 = 메시지 래핑 오버헤드
print(f&quot;기본 오버헤드: {BASE} 토큰\n&quot;)

for label, text in [
    (&quot;영어 10단어&quot;, &quot;The quick brown fox jumps over the lazy dog today&quot;),
    (&quot;한글 같은 뜻&quot;, &quot;빠른 갈색 여우가 오늘 게으른 개를 뛰어넘는다&quot;),
    (&quot;숫자·기호&quot;,   &#39;2026-09-20T22:07:59+09:00 {&quot;a&quot;:1}&#39;),
]:
    n = input_tokens(text) - BASE
    print(f&quot;{label:&lt;12}{len(text):&gt;6}자 {n:&gt;6}토큰  {len(text)/n:&gt;5.2f}자/토큰&quot;)</code></pre>
<pre><code>$ python step4_tokens.py
기본 오버헤드: 7 토큰

영어 10단어      49자     11토큰   4.45자/토큰
한글 같은 뜻     25자     32토큰   0.78자/토큰
숫자·기호        33자     23토큰   1.43자/토큰</code></pre><!-- 📸 스크린샷 자리 #4a -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/02528996-fc41-4940-a80a-bc5c84c2bfb7/image.png" alt="토큰 실측"></p>
<p>이 결과를 처음 보고 좀 놀랐습니다.</p>
<table>
<thead>
<tr>
<th>발견</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>영어는 <strong>1토큰 ≈ 4.5글자</strong> (단어 하나 정도)</td>
<td>문서의 &quot;1토큰≈4자&quot;는 영어 기준</td>
</tr>
<tr>
<td><strong>한글은 1글자에 1토큰 이상</strong></td>
<td>같은 뜻인데 글자수는 절반, 토큰은 <strong>3배</strong></td>
</tr>
<tr>
<td>날짜·JSON은 중간</td>
<td>도구 결과로 JSON을 넣으면 생각보다 비쌈</td>
</tr>
<tr>
<td>빈 요청도 7토큰</td>
<td>메시지 형식 자체의 오버헤드</td>
</tr>
</tbody></table>
<p><strong>한국어로 쓰는 어시스턴트는 영어보다 입력 요금이 ~3배 나간다</strong>는 뜻입니다.
시스템 프롬프트를 한국어로 길게 쓰고 있다면 한 번 세어볼 가치가 있습니다.
매 요청마다 들어가는 시스템 프롬프트의 규칙 부분만 영어로 써도 입력 토큰이 눈에 띄게 줄어듭니다(모델은 영어 지시를 한국어 답변에 잘 적용합니다).</p>
<h3 id="요금-계산은-이렇게">요금 계산은 이렇게</h3>
<pre><code>비용 = 입력토큰 × 입력단가 + 출력토큰 × 출력단가      (단가는 &quot;백만 토큰당 $&quot;)</code></pre><!-- ✅ 발행 전 확인: Bedrock 요금 페이지(https://aws.amazon.com/bedrock/pricing/)에서 작성 시점 Haiku 4.5 단가 채우기 -->

<p>예를 들어 2장의 첫 호출(입력 26 + 출력 22 토큰)은 어떤 단가를 넣어도 <strong>$0.0001 미만</strong>입니다.
개인 어시스턴트는 호출 한 번이 아니라 <strong>대화가 길어지며 입력 토큰이 누적되는 것</strong>이 비용의 본체인데,
그 얘기는 7장에서 숫자로 봅니다.</p>
<blockquote>
<p>💡 <code>usage</code>는 <strong>모든 응답에</strong> 들어 있습니다. 첫날부터 이 값을 로그에 남기세요.
저는 SQLite에 호출 단위로 <code>inputTokens/outputTokens/model</code>을 쌓아두는데,
나중에 &quot;이번 달 왜 이렇게 나왔지&quot;를 추적할 때 이것 말고는 방법이 없습니다.</p>
</blockquote>
<hr>
<h2 id="🔧-5-시스템-프롬프트--모델에게-역할-주기">🔧 5. 시스템 프롬프트 — 모델에게 역할 주기</h2>
<p>지금까지는 질문만 던졌습니다. 어시스턴트답게 만들려면 <strong>역할과 규칙</strong>을 먼저 줘야 합니다.
그게 <code>system</code> 파라미터입니다. <code>messages</code>와 분리돼 있고, 대화 내내 유지됩니다.</p>
<p><strong><code>step5_system.py</code></strong></p>
<pre><code class="language-python">resp = runtime.converse(
    modelId=MODEL,
    system=[{&quot;text&quot;: &quot;너는 한국어로 짧고 정확하게 답하는 조수다.&quot;}],     # ← 역할·규칙
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: &quot;Bedrock Converse API를 한 문장으로 설명해줘.&quot;}]}],
    inferenceConfig={&quot;maxTokens&quot;: 300, &quot;temperature&quot;: 0.2},
)
print(&quot;답변:&quot;, &quot;&quot;.join(b.get(&quot;text&quot;, &quot;&quot;) for b in resp[&quot;output&quot;][&quot;message&quot;][&quot;content&quot;]))
print(&quot;토큰:&quot;, resp[&quot;usage&quot;])
print(&quot;지연(ms):&quot;, resp[&quot;metrics&quot;][&quot;latencyMs&quot;])</code></pre>
<pre><code>$ python step5_system.py
답변: Bedrock Converse API는 AWS에서 제공하는 통일된 인터페이스로, 다양한 기초 모델과의 멀티턴 대화를 간편하게 처리할 수 있는 API입니다.
토큰: {&#39;inputTokens&#39;: 54, &#39;outputTokens&#39;: 74, &#39;totalTokens&#39;: 128, &#39;cacheReadInputTokens&#39;: 0, &#39;cacheWriteInputTokens&#39;: 0}
지연(ms): 1071</code></pre><p>시스템 프롬프트에 대해 처음에 알았으면 좋았을 것들입니다.</p>
<table>
<thead>
<tr>
<th>알아둘 것</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong><code>system</code>도 배열</strong>이다</td>
<td>블록 여러 개를 넣을 수 있고, 9장의 캐시 포인트도 여기 들어감</td>
</tr>
<tr>
<td><strong>매 요청에 입력 토큰으로 과금</strong>된다</td>
<td>위 예시도 시스템 프롬프트 때문에 2장(26토큰)보다 입력이 두 배(54토큰)</td>
</tr>
<tr>
<td>모델별로 <code>system</code> 미지원인 경우가 있다</td>
<td>그땐 <code>ValidationException</code> — 첫 user 메시지에 합쳐 넣는 식으로 우회</td>
</tr>
<tr>
<td><strong>동적인 값은 끝에</strong></td>
<td>현재 시각처럼 매번 바뀌는 값을 앞에 넣으면 9장의 캐시가 매번 깨짐</td>
</tr>
</tbody></table>
<blockquote>
<p>💡 시스템 프롬프트는 &quot;누구인지 → 무엇을 하는지 → 하지 말 것 → 출력 형식&quot; 순서가 잘 먹혔습니다.
그리고 프롬프트는 코드보다 훨씬 자주 바뀝니다. 코드 여기저기에 문자열로 흩뿌리지 말고
한 곳(상수 하나 또는 파일 하나)에 모아두세요. SeanChat도 시스템 프롬프트가 설정 모듈 한 곳에 있습니다.</p>
</blockquote>
<hr>
<h2 id="🔧-6-스트리밍--이벤트-구조를-눈으로-보기">🔧 6. 스트리밍 — 이벤트 구조를 눈으로 보기</h2>
<p><code>converse</code>는 답변이 다 만들어질 때까지 기다렸다가 한 번에 옵니다. 긴 답변이면 5~10초 동안 화면이 멈춰 있습니다.
챗봇 느낌이 나려면 글자가 흘러나와야 하고, 그게 <strong><code>converse_stream</code></strong> 입니다.</p>
<p>처음엔 &quot;글자가 하나씩 오겠지&quot;라고 생각했는데, 실제로 어떤 이벤트가 오는지 그대로 찍어보니 달랐습니다.</p>
<p><strong><code>step6_stream.py</code></strong></p>
<pre><code class="language-python">resp = runtime.converse_stream(
    modelId=MODEL,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: &quot;1부터 5까지 세어줘. 숫자만.&quot;}]}],
    inferenceConfig={&quot;maxTokens&quot;: 200},
)

for ev in resp[&quot;stream&quot;]:                  # ← 응답이 아니라 이벤트 제너레이터
    kind = next(iter(ev))                  # 이벤트 이름이 곧 딕셔너리 키다
    if kind == &quot;contentBlockDelta&quot;:
        print(f&quot;  [delta] {ev[kind][&#39;delta&#39;].get(&#39;text&#39;, &#39;&#39;)!r}&quot;)
    else:
        print(f&quot;[{kind}] {json.dumps(ev[kind], ensure_ascii=False)[:120]}&quot;)</code></pre>
<pre><code>$ python step6_stream.py
[messageStart] {&quot;role&quot;: &quot;assistant&quot;}
  [delta] &#39;1&#39;
  [delta] &#39;\n2\n3\n4\n5&#39;
[contentBlockStop] {&quot;contentBlockIndex&quot;: 0}
[messageStop] {&quot;stopReason&quot;: &quot;end_turn&quot;}
[metadata] {&quot;usage&quot;: {&quot;inputTokens&quot;: 27, &quot;outputTokens&quot;: 13, &quot;totalTokens&quot;: 40}, &quot;metrics&quot;: {&quot;latencyMs&quot;: 664}}</code></pre><table>
<thead>
<tr>
<th>이벤트</th>
<th>하는 일</th>
<th>주의</th>
</tr>
</thead>
<tbody><tr>
<td><code>messageStart</code></td>
<td>역할 통보</td>
<td>—</td>
</tr>
<tr>
<td><code>contentBlockStart</code></td>
<td>블록 시작 (도구 호출이면 여기에 이름·ID)</td>
<td>텍스트만 오면 <strong>생략될 수 있음</strong>(위 출력에도 없음)</td>
</tr>
<tr>
<td><code>contentBlockDelta</code></td>
<td><strong>실제 조각</strong></td>
<td>글자 단위가 아니라 <strong>덩어리</strong>로 옴</td>
</tr>
<tr>
<td><code>contentBlockStop</code> / <code>messageStop</code></td>
<td>블록·메시지 끝</td>
<td><code>stopReason</code>은 <code>messageStop</code>에</td>
</tr>
<tr>
<td><code>metadata</code></td>
<td><strong>usage와 지연</strong></td>
<td>토큰 집계는 <strong>여기서만</strong> 가능 — 마지막에 옴</td>
</tr>
</tbody></table>
<p><code>delta</code>가 <code>&#39;1&#39;</code> 다음에 <code>&#39;\n2\n3\n4\n5&#39;</code>로 온 게 보이시죠.
<strong>청크 크기는 보장되지 않습니다.</strong> 한 글자일 때도 있고 문단째로 올 때도 있습니다.</p>
<p>이게 왜 중요하냐면, 마크다운을 실시간으로 렌더링하려고 하면 <strong>청크 경계에서 문법이 잘립니다.</strong>
<code>**굵게</code> 까지 온 상태에서 렌더하면 별표가 그대로 보이고, 다음 청크에 <code>**</code>가 와서 닫힙니다.
저는 iOS 앱에서 이걸로 한 번 크게 당해서, 결국 청크를 모아 블록(문단·코드블록) 단위로 파싱하는 쪽으로 정리했습니다.</p>
<blockquote>
<p>💡 <code>converse</code>의 응답 하나가 <code>converse_stream</code>에서는 이벤트 5~6종으로 흩어진 것뿐입니다.
스트림 이벤트를 다시 모으면 <code>converse</code>의 응답과 같은 메시지가 됩니다.
그래서 저는 스트림을 소비하면서 동시에 <strong>완성된 assistant 메시지를 조립</strong>해두고, 그걸 7장의 대화 기록에 넣습니다.</p>
</blockquote>
<hr>
<h2 id="🔧-7-대화-기억--상태는-서버가-local에-저장">🔧 7. 대화 기억 — 상태는 서버가 Local에 저장</h2>
<p>여기서 저는 처음으로 &quot;아, 이건 채팅 서비스가 아니라 함수구나&quot;를 느꼈습니다.</p>
<p><strong>Bedrock은 대화를 기억해주지 않습니다.</strong> 세션 개념이 없습니다.
매 요청에 지금까지의 대화 <strong>전체</strong>를 다시 보내야 합니다.</p>
<p><strong><code>step7_chat.py</code></strong></p>
<pre><code class="language-python">messages = []                                    # ← 이게 대화 기억의 전부다

def ask(text):
    messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: text}]})
    r = runtime.converse(modelId=MODEL, system=SYSTEM, messages=messages,   # 전체를 보낸다
                         inferenceConfig={&quot;maxTokens&quot;: 300})
    reply = r[&quot;output&quot;][&quot;message&quot;]
    messages.append(reply)                       # ← 응답도 반드시 append (형식이 같아서 그대로 넣는다)
    print(f&quot;👤 {text}\n🤖 {&#39;&#39;.join(b.get(&#39;text&#39;,&#39;&#39;) for b in reply[&#39;content&#39;])}&quot;
          f&quot;   (in={r[&#39;usage&#39;][&#39;inputTokens&#39;]} out={r[&#39;usage&#39;][&#39;outputTokens&#39;]})\n&quot;)

ask(&quot;내 이름은 Sean이고 AWS에서 일해.&quot;)
ask(&quot;내 이름이 뭐라고 했지?&quot;)                     # append 안 하면 이 답을 못 한다
print(&quot;messages 길이:&quot;, len(messages))</code></pre>
<pre><code>$ python step7_chat.py
👤 내 이름은 Sean이고 AWS에서 일해.
🤖 Sean님, AWS에서 근무하신다니 반갑습니다! 무엇을 도와드릴까요?   (in=55 out=38)

👤 내 이름이 뭐라고 했지?
🤖 당신의 이름은 Sean이라고 말씀하셨습니다.   (in=112 out=27)

messages 길이: 4</code></pre><p><code>messages.append(reply)</code> 한 줄을 빼고 돌려보면 두 번째 답이 &quot;이름을 말씀하지 않으셨습니다&quot;로 바뀝니다.
직접 한 번 지워보시길 권합니다. 이걸 눈으로 보면 &quot;대화 기억&quot;의 실체가 뭔지 확실해집니다.</p>
<h3 id="여기서-비용-구조가-보입니다">여기서 비용 구조가 보입니다</h3>
<p><strong><code>in=55</code> → <code>in=112</code>.</strong> 두 번째 질문은 아홉 글자인데 입력 토큰이 <strong>두 배</strong>가 됐습니다.
이전 대화(질문 + 답변)를 통째로 다시 보냈기 때문입니다.</p>
<pre><code>1턴: [Q1]                          → 입력 = Q1
2턴: [Q1, A1, Q2]                  → 입력 = Q1 + A1 + Q2
3턴: [Q1, A1, Q2, A2, Q3]          → 입력 = 지금까지 전부
...
N턴 누적 입력 토큰 ∝ N²</code></pre><blockquote>
<p><strong>100턴짜리 대화 하나가 짧은 대화 100개보다 수십 배 비쌉니다.</strong>
4장에서 &quot;호출 한 번은 $0.0001도 안 된다&quot;고 했는데, 비용은 여기서 나옵니다.</p>
</blockquote>
<p>이 문제를 푸는 도구가 두 가지고, 하나는 이 글에서, 하나는 5편에서 다룹니다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>하는 일</th>
<th>어디서</th>
</tr>
</thead>
<tbody><tr>
<td><strong>프롬프트 캐싱</strong></td>
<td>반복되는 앞부분(시스템 프롬프트·도구 스펙)을 싸게</td>
<td>9장</td>
</tr>
<tr>
<td><strong>컨텍스트 압축</strong></td>
<td>오래된 메시지를 요약 한 덩어리로 접기</td>
<td>5편</td>
</tr>
</tbody></table>
<p>지금은 <code>messages</code> 배열이 곧 대화이고, 그걸 <strong>내가 들고 있어야 한다</strong>는 것만 확실히 하면 됩니다.
SeanChat에서는 이 배열을 SQLite에 세션 단위로 저장해두고, CLI와 iPhone이 같은 세션을 이어서 봅니다.</p>
<hr>
<h2 id="🔧-8-도구-호출--에이전트의-실체는-while-loop">🔧 8. 도구 호출 — &quot;에이전트&quot;의 실체는 while Loop</h2>
<p>여기가 이 글에서 제일 재미있는 부분입니다.</p>
<p>AI 어시스턴트가 파일을 읽고, 시각을 알려주고, 명령을 실행하는 건 <strong>모델이 하는 일이 아닙니다.</strong>
모델은 &quot;이 도구를 이 인자로 불러줘&quot;라고 <strong>말만</strong> 하고, 실행은 내 코드가 합니다.
처음 이걸 이해했을 때 &quot;에이전트&quot;라는 단어의 신비함이 좀 걷혔습니다.</p>
<p>왕복 구조입니다.</p>
<pre><code>[1] 내 코드 → 모델 : messages + toolConfig(도구 설명서)
[2] 모델 → 내 코드 : stopReason=&quot;tool_use&quot;, content=[{toolUse: {name, input, toolUseId}}]
[3] 내 코드        : 실제 함수 실행 (파일 읽기·API 호출·셸… 뭐든)
[4] 내 코드 → 모델 : toolResult (toolUseId로 짝을 맞춰서)
[5] 모델 → 내 코드 : stopReason=&quot;end_turn&quot; → 최종 답변
                     (또는 다시 tool_use → [3]으로. 그래서 while 루프다)</code></pre><p><strong><code>step8_tool.py</code></strong> — 도구 2개(현재 시각, 파일 읽기)를 붙인 완전한 루프입니다.</p>
<p>먼저 <strong>도구 설명서</strong>입니다. 모델은 이 JSON Schema만 보고 언제·어떻게 부를지 결정합니다.
<code>description</code>이 곧 프롬프트라서 정성껏 써야 합니다.</p>
<pre><code class="language-python">TOOL_CONFIG = {&quot;tools&quot;: [
    {&quot;toolSpec&quot;: {
        &quot;name&quot;: &quot;get_current_time&quot;,
        &quot;description&quot;: &quot;현재 시각을 ISO8601로 반환한다.&quot;,
        &quot;inputSchema&quot;: {&quot;json&quot;: {&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: {}, &quot;required&quot;: []}}}},
    {&quot;toolSpec&quot;: {
        &quot;name&quot;: &quot;read_file&quot;,
        &quot;description&quot;: &quot;텍스트 파일 내용을 읽어 반환한다.&quot;,
        &quot;inputSchema&quot;: {&quot;json&quot;: {
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: {&quot;path&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;파일 경로&quot;}},
            &quot;required&quot;: [&quot;path&quot;]}}}},
]}

def run_tool(name, args):                         # 실제 실행은 내 코드가 한다
    if name == &quot;get_current_time&quot;:
        return datetime.datetime.now().astimezone().isoformat(timespec=&quot;seconds&quot;)
    if name == &quot;read_file&quot;:
        return pathlib.Path(args[&quot;path&quot;]).read_text()[:500]
    return f&quot;unknown tool: {name}&quot;</code></pre>
<p>그리고 <strong>루프</strong>입니다.</p>
<pre><code class="language-python">messages = [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: &quot;지금 몇 시야? 그리고 ./hello.txt 내용도 읽어줘.&quot;}]}]

for round_no in range(1, 6):                      # 무한루프 방지 상한은 필수
    r = runtime.converse(modelId=MODEL, messages=messages,
                         toolConfig=TOOL_CONFIG, inferenceConfig={&quot;maxTokens&quot;: 500})
    msg = r[&quot;output&quot;][&quot;message&quot;]
    messages.append(msg)                          # ① 모델 응답(toolUse 포함)을 그대로 append
    print(f&quot;── round {round_no}  stopReason={r[&#39;stopReason&#39;]}&quot;)

    if r[&quot;stopReason&quot;] != &quot;tool_use&quot;:             # 도구 요청이 아니면 최종 답변
        print(f&quot;\n🤖 최종답변: {&#39;&#39;.join(b.get(&#39;text&#39;,&#39;&#39;) for b in msg[&#39;content&#39;])}&quot;)
        break

    results = []
    for block in msg[&quot;content&quot;]:
        if &quot;toolUse&quot; not in block:
            continue
        tu = block[&quot;toolUse&quot;]
        out = run_tool(tu[&quot;name&quot;], tu.get(&quot;input&quot;, {}))
        print(f&quot;   🔧 {tu[&#39;name&#39;]}({json.dumps(tu.get(&#39;input&#39;, {}), ensure_ascii=False)}) → {out}&quot;)
        results.append({&quot;toolResult&quot;: {           # ② 결과는 toolUseId로 짝 맞춰서
            &quot;toolUseId&quot;: tu[&quot;toolUseId&quot;],
            &quot;content&quot;: [{&quot;text&quot;: out}]}})
    messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: results})    # ③ user 역할로 되돌린다</code></pre>
<p>실행하면, 한 라운드에서 <strong>도구 2개를 동시에</strong> 부르고 다음 라운드에서 답을 냈습니다.</p>
<pre><code>$ python step8_tool.py
── round 1  stopReason=tool_use
   🔧 get_current_time({}) → 2026-09-20T22:59:49+09:00
   🔧 read_file({&quot;path&quot;: &quot;./hello.txt&quot;}) → 안녕하세요! 이 파일은 Bedrock 도구 호출 테스트용입니다.

── round 2  stopReason=end_turn

🤖 최종답변: 현재 시간과 파일 내용을 알려드리겠습니다.

**현재 시각:** 2026년 9월 20일 22시 59분 49초 (한국 시간, UTC+9)

**./hello.txt 파일 내용:**
안녕하세요! 이 파일은 Bedrock 도구 호출 테스트용입니다.</code></pre><!-- 📸 스크린샷 자리 #4 : 위 도구 호출 결과 (round 1 → round 2 흐름이 보이는 화면) -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/6b6c692d-1a88-4bcf-9795-c2f1d5720d4c/image.png" alt="도구 호출 루프"></p>
<p>이 30줄이 SeanChat 백엔드의 뼈대입니다. 도구가 2개에서 수십 개로 늘고, <code>run_tool</code>이 MCP 서버 호출로 바뀌었을 뿐 루프는 같습니다.</p>
<h3 id="처음에-헷갈렸던-규칙-5개">처음에 헷갈렸던 규칙 5개</h3>
<ol>
<li><strong><code>toolUseId</code> 짝을 정확히 맞춘다.</strong> 하나라도 빠지면 <code>ValidationException</code>입니다.</li>
<li><strong>모델 응답 메시지를 그대로 append한다.</strong> <code>toolUse</code> 블록을 빼거나 텍스트만 추려 넣으면 다음 호출에서 &quot;짝이 안 맞는다&quot;고 거부됩니다.</li>
<li><strong><code>toolResult</code>는 <code>role: &quot;user&quot;</code></strong> 로 보냅니다. &quot;도구 결과인데 왜 user?&quot;가 첫 반응이었는데, 모델 입장에선 &quot;바깥 세계에서 온 입력&quot;이라 user 턴입니다. 규격입니다.</li>
<li><strong>한 라운드에 도구가 여러 개</strong> 올 수 있습니다(위 출력처럼). 결과를 <strong>한 메시지에 모아</strong> 보냅니다. 하나씩 따로 보내면 2번 규칙에 걸립니다.</li>
<li><strong>라운드 상한을 반드시 둡니다.</strong> 모델이 같은 도구를 계속 부르는 루프에 빠지면 요금이 그대로 나갑니다. 위에서 <code>range(1, 6)</code>으로 5회 상한을 둔 이유입니다.</li>
</ol>
<h3 id="⚠️-놓치기-쉬운-부분--빈-도구-배열">⚠️ 놓치기 쉬운 부분 — 빈 도구 배열</h3>
<p>권한이나 설정에 따라 도구 목록을 동적으로 필터링하다 보면 <strong>빈 배열</strong>이 될 때가 있습니다. 이건 거부됩니다.</p>
<pre><code>ParamValidationError: Invalid length for parameter toolConfig.tools,
value: 0, valid min length: 1</code></pre><p>→ 목록이 비면 <code>toolConfig</code> 키를 <strong>아예 빼야</strong> 합니다. <code>{&quot;tools&quot;: []}</code>로 보내면 안 됩니다.
저는 <code>kwargs = {...}; if tools: kwargs[&quot;toolConfig&quot;] = {...}</code> 형태로 조건부로 넣습니다.</p>
<hr>
<h2 id="🔧-9-프롬프트-캐싱--비용-절감은-한-줄">🔧 9. 프롬프트 캐싱 — 비용 절감은 한 줄</h2>
<p>7장에서 본 것처럼 시스템 프롬프트와 도구 설명서는 <strong>매 요청 똑같이</strong> 들어갑니다.
똑같은 걸 매번 정가로 사는 대신 캐시할 수 있습니다.
<code>system</code> 배열 끝에 <code>cachePoint</code> 블록 하나만 넣으면 됩니다.</p>
<p><strong><code>step9_cache.py</code></strong></p>
<pre><code class="language-python"># 캐시는 최소 토큰 수를 넘어야 동작한다(아래에서 실측). 긴 시스템 프롬프트를 흉내낸다.
BIG_SYSTEM = (&quot;너는 사내 인프라 규칙을 따르는 어시스턴트다.\n&quot;
              + &quot;\n&quot;.join(f&quot;규칙 {i}: 리전은 us-east-2를 사용하고, 태그 Owner/Env/CostCenter를 &quot;
                          f&quot;반드시 붙이며, 퍼블릭 S3 버킷은 금지한다.&quot; for i in range(1, 121)))

def call(q, cache: bool):
    system = [{&quot;text&quot;: BIG_SYSTEM}]
    if cache:
        system.append({&quot;cachePoint&quot;: {&quot;type&quot;: &quot;default&quot;}})   # ← 이 한 줄이 전부
    r = runtime.converse(modelId=MODEL, system=system,
                         messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: q}]}],
                         inferenceConfig={&quot;maxTokens&quot;: 100})
    u = r[&quot;usage&quot;]
    print(f&quot;cache={&#39;ON &#39; if cache else &#39;OFF&#39;} | input={u[&#39;inputTokens&#39;]:&gt;5} &quot;
          f&quot;write={u[&#39;cacheWriteInputTokens&#39;]:&gt;5} read={u[&#39;cacheReadInputTokens&#39;]:&gt;5} &quot;
          f&quot;| {r[&#39;metrics&#39;][&#39;latencyMs&#39;]:&gt;5}ms&quot;)

call(&quot;리전 규칙이 뭐야? 한 문장.&quot;, cache=False)
call(&quot;리전 규칙이 뭐야? 한 문장.&quot;, cache=True)    # 1회차 = 캐시 기록
call(&quot;태그 규칙이 뭐야? 한 문장.&quot;, cache=True)    # 2회차 = 캐시 읽기</code></pre>
<pre><code>$ python step9_cache.py
cache=OFF | input= 8214 write=    0 read=    0 |   811ms
cache=ON  | input=   26 write= 8188 read=    0 |   774ms
cache=ON  | input=   26 write=    0 read= 8188 |   772ms</code></pre><table>
<thead>
<tr>
<th>호출</th>
<th>무슨 일이</th>
<th>과금 배수</th>
</tr>
</thead>
<tbody><tr>
<td>1회차 (캐시 OFF)</td>
<td>8,214 토큰을 정가로 입력</td>
<td><strong>1.0x</strong></td>
</tr>
<tr>
<td>2회차 (캐시 ON, 첫 호출)</td>
<td>8,188 토큰을 <strong>캐시에 기록</strong> — <code>write</code></td>
<td><strong>1.25x</strong> (기록 수수료)</td>
</tr>
<tr>
<td>3회차 (캐시 ON, 재사용)</td>
<td>8,188 토큰을 <strong>캐시에서 읽기</strong> — <code>read</code></td>
<td><strong>0.1x</strong></td>
</tr>
</tbody></table>
<p>세 번째 줄이 핵심입니다. <strong>반복되는 입력 토큰이 90% 싸집니다.</strong>
<code>input</code>이 8,214에서 26으로 떨어진 게 보이시죠. 정가로 사는 건 질문 26토큰뿐입니다.</p>
<p>시스템 프롬프트 + 도구 스펙이 수만 토큰인 에이전트에서는 이 한 줄이 월 요금의 절반을 좌우합니다.
2장의 응답 구조에 <code>cacheReadInputTokens / cacheWriteInputTokens</code>가 항상 붙어 있던 이유가 이겁니다.</p>
<h3 id="최소-토큰--왜-캐시가-안-걸리지">최소 토큰 — &quot;왜 캐시가 안 걸리지?&quot;</h3>
<p>처음 캐시를 켰을 때 <code>write=0 read=0</code>만 나와서 한참 헤맸습니다.
<strong>캐시에는 최소 토큰 수가 있습니다.</strong> 프롬프트 길이를 바꿔가며 어디서부터 걸리는지 재봤습니다.</p>
<pre><code>$ python step9b_cache_min.py
규칙  10줄 | 총입력   670 | write=    0 read=    0 | 캐시 안 됨 ❌
규칙  30줄 | 총입력  1990 | write=    0 read=    0 | 캐시 안 됨 ❌
규칙  60줄 | 총입력  3970 | write=    0 read=    0 | 캐시 안 됨 ❌
규칙 120줄 | 총입력  7930 | write= 7921 read=    0 | 캐시 됨 ✅</code></pre><!-- 📸 스크린샷 자리 #9 -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/89697d48-f1de-48d8-8aec-f7f4b88a27de/image.png" alt="프롬프트 캐싱 실측"></p>
<p>Haiku 4.5는 <strong>4천 토큰 조금 아래까지는 캐시가 안 걸렸습니다.</strong> 모델마다 이 하한이 다릅니다(1,024~4,096 사이).
&quot;캐시 포인트를 넣었는데 <code>write</code>가 0&quot;이면 에러가 아니라 <strong>프롬프트가 짧아서 그냥 무시된 것</strong>입니다.
에러가 안 나서 더 헷갈립니다.</p>
<h3 id="캐싱을-쓸-때-알아야-할-것">캐싱을 쓸 때 알아야 할 것</h3>
<ul>
<li><strong>TTL 5분</strong>이고, <strong>읽힐 때마다 갱신</strong>됩니다. 5분 넘게 쉬면 다시 기록(1.25x)부터 시작합니다. 대화가 이어지는 동안은 계속 살아 있습니다.</li>
<li><strong>캐시 포인트는 요청당 최대 4개</strong>입니다. <code>system</code> 끝 / 도구 스펙 끝 / 대화 중간 / 마지막 메시지 앞 정도로 배치합니다.</li>
<li>⚠️ <strong>캐시는 &quot;앞에서부터 똑같은 부분&quot;만 적중합니다.</strong> 앞쪽이 한 글자만 달라져도 뒤가 전부 미스입니다.
그래서 5장에서 &quot;동적인 값은 끝에&quot;라고 한 겁니다. 시스템 프롬프트 맨 앞에 현재 시각을 찍으면 <strong>매 요청 캐시가 통째로 무효화</strong>됩니다.
(아주 흔한 실수입니다. 시각처럼 매번 바뀌는 값은 프롬프트 <strong>맨 뒤</strong>, 캐시 포인트 <strong>뒤</strong>에 두세요.)</li>
<li>⚠️ 캐싱 미지원 모델에 <code>cachePoint</code>를 넣으면 <code>ValidationException</code>이 아니라 <strong><code>AccessDeniedException</code></strong> 으로 오는 경우가 있습니다. 권한 문제로 착각하기 쉽습니다.</li>
</ul>
<hr>
<h2 id="🔧-10-추론-과정-보기--extended-thinking">🔧 10. 추론 과정 보기 — Extended Thinking</h2>
<p>Claude는 답하기 전에 &quot;생각하는 과정&quot;을 받아볼 수 있습니다. 복잡한 계산·계획에 정확도가 올라갑니다.
Converse에서는 <code>additionalModelRequestFields</code>로 켭니다 — 모델 전용 옵션은 여기로 들어갑니다.</p>
<p><strong><code>step10_thinking.py</code></strong></p>
<pre><code class="language-python">resp = runtime.converse_stream(
    modelId=&quot;us.anthropic.claude-sonnet-4-5-20250929-v1:0&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: &quot;17 * 23을 계산하고 검산까지 해줘.&quot;}]}],
    inferenceConfig={&quot;maxTokens&quot;: 3000},          # ← thinking budget보다 커야 한다
    additionalModelRequestFields={&quot;thinking&quot;: {&quot;type&quot;: &quot;enabled&quot;, &quot;budget_tokens&quot;: 1024}},
)
mode = None
for ev in resp[&quot;stream&quot;]:
    d = ev.get(&quot;contentBlockDelta&quot;, {}).get(&quot;delta&quot;, {})
    if &quot;reasoningContent&quot; in d:                               # 🧠 추론 블록
        if mode != &quot;think&quot;:
            print(&quot;\n🧠 [추론]&quot;, end=&quot; &quot;); mode = &quot;think&quot;
        print(d[&quot;reasoningContent&quot;].get(&quot;text&quot;, &quot;&quot;), end=&quot;&quot;)
    elif &quot;text&quot; in d:                                         # 💬 답변 블록
        if mode != &quot;text&quot;:
            print(&quot;\n\n💬 [답변]&quot;, end=&quot; &quot;); mode = &quot;text&quot;
        print(d[&quot;text&quot;], end=&quot;&quot;)</code></pre>
<pre><code>$ python step10_thinking.py

🧠 [추론] 17 * 23을 계산하겠습니다.

17 * 23
= 17 * 20 + 17 * 3
= 340 + 51
= 391

검산을 위해 다른 방법으로도 계산해보겠습니다.

방법 2:
23 * 17
= 23 * 10 + 23 * 7
= 230 + 161
= 391

또는 나눗셈으로 검산:
391 ÷ 17 = 23 ✓
391 ÷ 23 = 17 ✓

💬 [답변] # 17 × 23 계산

## 계산 과정
    23
  × 17
  ----
   161  (23 × 7)
  230   (23 × 10)
  ----
   391
...
**답: 391**</code></pre><!-- 📸 스크린샷 자리 #10 -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/0550dde3-5854-4a5c-9c8e-f9b23f24b238/image.png" alt="Extended Thinking — 추론 블록과 답변 블록"></p>
<p><code>content</code>에 <code>text</code> 말고 <strong><code>reasoningContent</code></strong> 라는 새 블록 종류가 생겼습니다.
2장에서 &quot;content는 블록 배열&quot;이라고 강조한 이유가 하나 더 늘었습니다.</p>
<p>처음 켰을 때 연달아 두 번 거부당했습니다. <strong>실제 에러 메시지</strong>입니다.</p>
<pre><code>ValidationException: `temperature` may only be set to 1 when thinking is enabled.
ValidationException: `max_tokens` must be greater than `thinking.budget_tokens`.</code></pre><table>
<thead>
<tr>
<th>제약</th>
<th>대응</th>
</tr>
</thead>
<tbody><tr>
<td><code>temperature</code> 지정 불가</td>
<td>thinking을 켤 땐 <code>inferenceConfig</code>에서 <code>temperature</code>를 <strong>뺀다</strong></td>
</tr>
<tr>
<td><code>maxTokens &gt; budget_tokens</code> 필수</td>
<td>추론 토큰도 <strong>출력 토큰에 포함</strong>된다 → <code>maxTokens</code>를 <code>budget + 답변 여유</code>로</td>
</tr>
</tbody></table>
<blockquote>
<p>두 번째를 아슬아슬하게 맞추면 <strong>추론이 출력 예산을 다 먹고 답변이 0자로 잘립니다.</strong>
<code>stopReason=max_tokens</code>인데 텍스트가 비어 있으면 이 경우입니다. 3장에서 <code>stopReason</code>을 보라고 한 이유가 또 나옵니다.</p>
</blockquote>
<hr>
<h2 id="🔧-11-fastapi--sse로-감싸기--백엔드의-씨앗">🔧 11. FastAPI + SSE로 감싸기 — 백엔드의 씨앗</h2>
<p>지금까지는 스크립트였습니다. 이걸 HTTP로 감싸면 터미널이든 모바일 앱이든 붙일 수 있습니다.
스트리밍 전송은 <strong>SSE(Server-Sent Events)</strong> 를 씁니다. <code>data: {...}\n\n</code> 형식으로 이벤트를 흘려보내는 단순한 HTTP 규격입니다.</p>
<p><strong><code>step11_server.py</code></strong> (핵심만)</p>
<pre><code class="language-python">from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()
class Ask(BaseModel):
    text: str

def sse(obj) -&gt; str:                       # SSE 프레이밍: &quot;data: {...}\n\n&quot;
    return f&quot;data: {json.dumps(obj, ensure_ascii=False)}\n\n&quot;

@app.post(&quot;/chat&quot;)
def chat(body: Ask):
    def gen():
        resp = runtime.converse_stream(
            modelId=MODEL,
            messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: [{&quot;text&quot;: body.text}]}],
            inferenceConfig={&quot;maxTokens&quot;: 500})
        for ev in resp[&quot;stream&quot;]:                        # 6장의 이벤트를 그대로 중계
            if &quot;contentBlockDelta&quot; in ev:
                t = ev[&quot;contentBlockDelta&quot;][&quot;delta&quot;].get(&quot;text&quot;)
                if t:
                    yield sse({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: t})
            elif &quot;metadata&quot; in ev:
                yield sse({&quot;type&quot;: &quot;usage&quot;, &quot;usage&quot;: ev[&quot;metadata&quot;][&quot;usage&quot;]})
        yield sse({&quot;type&quot;: &quot;done&quot;})
    return StreamingResponse(gen(), media_type=&quot;text/event-stream&quot;)</code></pre>
<pre><code class="language-bash">uvicorn step11_server:app --port 8599
curl -sN -X POST http://127.0.0.1:8599/chat \
  -H &#39;Content-Type: application/json&#39; -d &#39;{&quot;text&quot;:&quot;SSE가 뭔지 두 문장으로 설명해줘.&quot;}&#39;</code></pre>
<pre><code>data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;#&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot; SSE (&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;Server-Sent Events)&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;\n\nSSE는 서&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;버에&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;서 클&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;라이언트로&quot;}

data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot; 실시간 데이터&quot;}
...
data: {&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;.&quot;}

data: {&quot;type&quot;: &quot;usage&quot;, &quot;usage&quot;: {&quot;inputTokens&quot;: 29, &quot;outputTokens&quot;: 145, &quot;totalTokens&quot;: 174}}

data: {&quot;type&quot;: &quot;done&quot;}</code></pre><!-- 📸 스크린샷 자리 #11 : curl로 SSE가 흘러나오는 화면 -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/3f4e5309-8fe6-44e5-aa63-9be9d7aaaea7/image.png" alt="curl로 받은 SSE 스트림"></p>
<p><code>curl -N</code>(버퍼링 끄기)으로 글자가 실시간으로 떨어지는 걸 처음 봤을 때 &quot;이제 앱이 되겠다&quot; 싶었습니다.
<code>data:</code> 한 줄이 이벤트 하나이고, <strong>빈 줄이 이벤트의 끝</strong>입니다. 조각이 <code>&quot;서&quot;</code>, <code>&quot;버에&quot;</code>, <code>&quot;서 클&quot;</code>처럼 어절 중간에서 잘리는 것도 그대로 보입니다 — 6장에서 말한 &quot;청크 크기는 보장되지 않는다&quot;가 HTTP 위에서도 그대로입니다.</p>
<p><strong>왜 WebSocket이 아니고 SSE인가?</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>SSE</th>
<th>WebSocket</th>
</tr>
</thead>
<tbody><tr>
<td>방향</td>
<td>서버 → 클라이언트 단방향</td>
<td>양방향</td>
</tr>
<tr>
<td>프로토콜</td>
<td>그냥 HTTP</td>
<td>업그레이드 핸드셰이크 필요</td>
</tr>
<tr>
<td>프록시·ALB·CDN</td>
<td>잘 통과</td>
<td>설정 추가 필요</td>
</tr>
<tr>
<td>디버깅</td>
<td><strong><code>curl</code>로 됨</strong></td>
<td>전용 클라이언트 필요</td>
</tr>
</tbody></table>
<p>챗봇은 &quot;질문 한 번, 응답 스트림 한 번&quot;이라 <strong>단방향으로 충분합니다.</strong>
그리고 HTTP라서 <code>curl</code>로 디버깅되는 게 실무에서 생각보다 큰 장점이었습니다.</p>
<p>여기까지가 개인 챗봇 백엔드의 최소 형태입니다.
실제로 SeanChat은 이 구조를 그대로 키워서 CLI와 iOS 앱이 같은 백엔드에 붙는 형태로 쓰고 있습니다.
다만 이 코드를 <strong>그대로 EC2에 올리면 몇 가지 문제가 생기는데</strong>(60초 넘는 추론에서 ALB가 끊음, 스트림 중 끊기면 재시도 안 됨 등), 그건 3편에서 다룹니다.</p>
<hr>
<h2 id="⚠️-첫날-만나는-에러-모음">⚠️ 첫날 만나는 에러 모음</h2>
<p>이 글을 쓰면서 실제로 만난 에러를 한 곳에 모았습니다. 전부 <strong>실제 메시지</strong>입니다.
Bedrock 에러는 크게 세 부류로 나뉘고, <strong>예외 클래스 이름만 봐도 어디를 봐야 하는지</strong> 정해집니다.</p>
<table>
<thead>
<tr>
<th>예외 클래스</th>
<th>누가 거부했나</th>
<th>봐야 할 곳</th>
</tr>
</thead>
<tbody><tr>
<td><code>NoCredentialsError</code></td>
<td>boto3 (요청 전)</td>
<td>환경변수·프로필</td>
</tr>
<tr>
<td><code>ParamValidationError</code></td>
<td>boto3 (요청 전)</td>
<td>내 코드의 파라미터 형식</td>
</tr>
<tr>
<td><code>ValidationException</code></td>
<td>Bedrock 또는 모델</td>
<td>모델 ID·요청 내용·모델별 제약</td>
</tr>
<tr>
<td><code>AccessDeniedException</code></td>
<td>Bedrock (권한)</td>
<td>IAM 정책 → 모델 액세스 → 기능 지원 여부 순서로</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>#</th>
<th>에러 메시지(발췌)</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>NoCredentialsError: Unable to locate credentials</code></td>
<td>자격증명 없음</td>
<td><code>AWS_BEARER_TOKEN_BEDROCK</code> 또는 <code>AWS_PROFILE</code> 확인</td>
</tr>
<tr>
<td>2</td>
<td><code>AccessDeniedException: ... is not authorized to perform: bedrock:CountTokens</code></td>
<td>IAM에 그 액션이 없음</td>
<td>메시지의 액션을 정책에 추가</td>
</tr>
<tr>
<td>3</td>
<td><code>ValidationException: Invocation of model ID ... with on-demand throughput isn&#39;t supported</code></td>
<td>추론 프로파일 없이 호출</td>
<td><code>us.</code> 접두어 ID 사용 (1장)</td>
</tr>
<tr>
<td>4</td>
<td><code>ValidationException: The text field in the ContentBlock object at messages.0.content.0 is blank</code></td>
<td>빈 문자열 전송</td>
<td>빈 입력은 호출 전에 걸러라 (사용자가 엔터만 쳤을 때 흔함)</td>
</tr>
<tr>
<td>5</td>
<td><code>ParamValidationError: Invalid length for parameter toolConfig.tools, value: 0, valid min length: 1</code></td>
<td>도구 배열이 빔</td>
<td><code>toolConfig</code> 키 자체를 뺀다 (8장)</td>
</tr>
<tr>
<td>6</td>
<td><code>ValidationException: \</code>temperature` may only be set to 1 when thinking is enabled`</td>
<td>thinking + temperature</td>
<td><code>temperature</code> 제거 (10장)</td>
</tr>
<tr>
<td>7</td>
<td><code>ValidationException: \</code>max_tokens` must be greater than `thinking.budget_tokens``</td>
<td>출력 상한 &lt; 추론 예산</td>
<td><code>maxTokens</code> 올리기 (10장)</td>
</tr>
</tbody></table>
<!-- 📸 스크린샷 자리 #12 -->
<p><img src="https://velog.velcdn.com/images/seanson2709/post/6ef49ff5-fada-4486-add8-d27496ec9e4e/image.png" alt="첫날 만나는 에러 7종 — 실제 메시지"></p>
<p>4번은 처음엔 목록에 없었는데, 4장의 토큰 실험에서 빈 문자열의 토큰 수를 재보려다 만났습니다.
실제 서비스에서는 사용자가 빈 메시지를 보내는 일이 흔해서, 서버 입구에서 걸러두는 게 좋습니다.</p>
<h3 id="accessdeniedexception이-났을-때--어디가-문제인지-구분하기"><code>AccessDeniedException</code>이 났을 때 — 어디가 문제인지 구분하기</h3>
<p><code>AccessDeniedException</code>은 ①IAM 권한 없음 ②모델 액세스(구독) 안 켬 ③그 모델이 그 기능을 미지원 — 세 경우에 똑같이 뜹니다.
2번 에러처럼 메시지에 액션이 적혀 있으면 ①이고, 그렇지 않으면 <strong>컨트롤 플레인에 물어보면</strong> 구분됩니다(조회는 무료).</p>
<pre><code class="language-bash">aws bedrock get-foundation-model-availability \
  --model-id anthropic.claude-haiku-4-5-20251001-v1:0 --region us-east-2</code></pre>
<pre><code class="language-json">{
    &quot;modelId&quot;: &quot;anthropic.claude-haiku-4-5-20251001-v1&quot;,
    &quot;agreementAvailability&quot;: { &quot;status&quot;: &quot;AVAILABLE&quot; },
    &quot;authorizationStatus&quot;: &quot;AUTHORIZED&quot;,
    &quot;entitlementAvailability&quot;: &quot;AVAILABLE&quot;,
    &quot;regionAvailability&quot;: &quot;AVAILABLE&quot;
}</code></pre>
<table>
<thead>
<tr>
<th>필드</th>
<th>뜻</th>
<th>여기가 문제면</th>
</tr>
</thead>
<tbody><tr>
<td><code>agreementAvailability</code></td>
<td>모델 사용 약관(구독) 상태</td>
<td>콘솔 Model access에서 활성화 (0-1)</td>
</tr>
<tr>
<td><code>authorizationStatus</code></td>
<td>내 자격증명의 권한</td>
<td>IAM 정책 수정 (0-2)</td>
</tr>
<tr>
<td><code>regionAvailability</code></td>
<td>이 리전에 모델이 있나</td>
<td>리전 변경 또는 추론 프로파일 (1장)</td>
</tr>
</tbody></table>
<p>넷이 다 <code>AVAILABLE</code>/<code>AUTHORIZED</code>인데도 <code>AccessDenied</code>가 나면 <strong>그 기능(캐싱 등)을 그 모델이 지원하지 않는 것</strong>입니다(9장 마지막 항목).</p>
<hr>
<h2 id="📎-정리--이번-편에서-만든-것">📎 정리 — 이번 편에서 만든 것</h2>
<pre><code>step2_converse.py     첫 호출 + 응답 구조 해부 (26 in / 22 out / 650ms)
step3_params.py       maxTokens·stopSequences·temperature 체감 (stopReason 3종)
step4_tokens.py       토큰 실측 — 영어 4.45자/토큰 vs 한글 0.78자/토큰
step5_system.py       시스템 프롬프트
step6_stream.py       converse_stream 이벤트 5종
step7_chat.py         멀티턴 — messages 배열이 대화 기억의 전부 (in 55 → 112)
step8_tool.py         도구 호출 루프 (tool_use → toolResult → end_turn)
step9_cache.py        프롬프트 캐싱 (input 8,214 → read 8,188) + 최소 토큰 실측
step10_thinking.py    Extended Thinking (reasoningContent)
step11_server.py      FastAPI + SSE 백엔드</code></pre><p>파일 10개, 전부 합쳐 <strong>250줄</strong>(주석·빈 줄 빼면 213줄)입니다. 프레임워크 없이도 이만큼은 됩니다.</p>
<p>이 글에서 꼭 가져가셨으면 하는 것 다섯 개입니다.</p>
<ol>
<li><strong>모델 ID는 <code>us.</code> 접두어(추론 프로파일)</strong> — 목록에 있어도 그대로는 못 부른다</li>
<li><strong><code>content</code>는 블록 배열, <code>stopReason</code>이 진짜 결과 코드</strong> — <code>content[0][&quot;text&quot;]</code>는 언젠가 터진다</li>
<li><strong>한글은 영어보다 토큰이 ~3배</strong> — 시스템 프롬프트 길이를 한 번 재보자</li>
<li><strong>대화 기억은 내가 들고 있는 <code>messages</code> 배열</strong> — 비용은 N²로 늘고, 캐싱은 한 줄로 켠다</li>
<li><strong>에이전트 = <code>stopReason == &quot;tool_use&quot;</code>인 동안 도는 while 루프</strong> — 실행은 내 코드가 한다</li>
</ol>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>이 글은 터미널에서 <code>curl</code>로 SSE가 흘러나오는 화면에서 끝났습니다. 그 화면을 보고 있으면 &quot;이걸 폰에서 보고 싶다&quot;는 생각이 먼저 듭니다.
2편은 그래서 <strong>iPhone 앱</strong>입니다 — Xcode 새 프로젝트 만들기부터 SwiftUI 채팅 화면, <code>URLSession.bytes(for:)</code> 두 줄로 끝나는 SSE 파싱,
서버 주소 설정 화면, 실기기 설치까지 Swift 약 260줄로 따라갑니다. Swift를 처음 보는 분도 따라올 수 있게 파일 단위로 그대로 실을 예정입니다.</p>
<p>원래 2편으로 예고했던 운영 이슈들 — botocore 타임아웃 기본값(60초)이 스트리밍에 맞지 않는 이유, <code>retries</code> 설정이 스트림 소비 중에는 동작하지 않는 것,
ALB idle timeout, 모델마다 다른 파라미터 지원 — 은 앱을 실제로 매일 써야 만나는 것들이라 <strong>3편</strong>으로 미뤘습니다. 먼저 쓸 수 있는 물건을 만들고, 그다음에 운영 이야기를 하는 게 순서에 맞겠다고 판단했습니다.</p>
<hr>
<p><em>이 글의 내용은 개인 AWS 계정에서 개인적으로 실험한 결과이며, 모든 의견은 제 개인 의견입니다.
요금·모델 지원 사항은 작성 시점(2026년 9월, us-east-2) 기준입니다.</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[쉽게 따라하는 LZA를 활용한 AWS Control Tower 구축 - Stack / CodePipeline 배포]]></title>
            <link>https://velog.io/@seanson2709/%EC%89%BD%EA%B2%8C-%EB%94%B0%EB%9D%BC%ED%95%98%EB%8A%94-LZA%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-AWS-Control-Tower-%EA%B5%AC%EC%B6%95</link>
            <guid>https://velog.io/@seanson2709/%EC%89%BD%EA%B2%8C-%EB%94%B0%EB%9D%BC%ED%95%98%EB%8A%94-LZA%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-AWS-Control-Tower-%EA%B5%AC%EC%B6%95</guid>
            <pubDate>Mon, 30 Jun 2025 01:16:50 GMT</pubDate>
            <description><![CDATA[<h2 id="📌-개요">📌 개요</h2>
<p>이 글에서는 AWS에서 제공하는 <strong>Landing Zone Accelerator (LZA)</strong>를 활용하여 <strong>Control Tower 및 AWS Organizations를 구축하는 방법</strong>을 다룹니다.<br>LZA를 통해 조직 전체의 네트워크, 보안, 계정, 로깅 등의 구성요소를 <strong>코드 기반으로 중앙에서 통합 관리</strong>할 수 있으며, 특히 <strong>엔터프라이즈 환경</strong>에서 강력한 거버넌스를 제공합니다.</p>
<hr>
<h2 id="🛠️-사전-준비-사항">🛠️ 사전 준비 사항</h2>
<ul>
<li>AWS Organizations Root 계정 접근 권한</li>
<li>AWS CLI + CDK 환경 구성 (추가 설명)</li>
</ul>
<hr>
<h2 id="📦-구성-요소-요약">📦 구성 요소 요약</h2>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>AWS Control Tower</strong></td>
<td>계정 프로비저닝, 기본 보안 OU 생성</td>
</tr>
<tr>
<td><strong>Landing Zone Accelerator (LZA)</strong></td>
<td>네트워크, 로깅, 보안 규칙 코드 기반 자동화</td>
</tr>
<tr>
<td><strong>S3</strong></td>
<td>구성 파일 저장소</td>
</tr>
<tr>
<td><strong>CodePipeline</strong></td>
<td>구성 변경 자동 반영</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔧-실제-구축-순서">🔧 실제 구축 순서</h2>
<h3 id="1-aws-root-account로-로그인-및-control-tower-enable">1. AWS Root Account로 로그인 및 Control Tower Enable</h3>
<p>먼저 Landing Zone Accelator Code를 사용하여 Control Tower를 배포하기 위해서는 AWS Control Tower가 Enable 되어 있어야 합니다.</p>
<p>1) AWS Console에서 Home Region으로 사용하실 리전을 선택하고 Landing Zone을 Set up합니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/3112d385-146a-4e14-862c-87afd080f96f/image.png" alt=""></p>
<p>리전 등을 설정하고 OU 명칭을 입력해줍니다.
이때 OU명칭은 나중에 변경하기 쉽지 않으니 신중히 선택해주세요.
<img src="https://velog.velcdn.com/images/seanson2709/post/dad8be79-beae-4720-ae37-0f42f01100a9/image.png" alt=""></p>
<p>Log Archive 및 Audit Log의 이메일의 경우 LZA 코드에서 활용됩니다. 기록하여 나중에 활용되도록 합시다.
<img src="https://velog.velcdn.com/images/seanson2709/post/e97616cd-3a31-43a2-ba51-936535e6526c/image.png" alt=""></p>
<p>나머지 설정의 경우 Default로 하여 Control Tower를 설정합니다.
초기 설정의 경우 약 한시간의 시간이 소요됩니다.</p>
<h3 id="2-lza-github-repository-복제">2. LZA GitHub Repository 복제</h3>
<p>Landing Zone Accelator 코드 실행을 위해서는 먼저 LZA Sample Code를 Clone합니다.</p>
<pre><code class="language-bash">git clone https://github.com/awslabs/landing-zone-accelerator-on-aws.git
cd source
yarn install # 만약 yarn install 실패 시 yarn.lock 파일 지우고 재 실행
cd packages/@aws-accelerator/installer
yarn build &amp;&amp; yarn cdk synth</code></pre>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/07a013cc-d78e-4d26-b689-cdfe6b53fcfe/image.png" alt=""></p>
<p>그후 yarn을 이용해 template 생성을 진행합니다.
NPM 및 Yarn Install은 환경에 따라 다르므로 개인적으로 설치 및 환경변수 등록 하시면 됩니다.</p>
<p>맥북 기준</p>
<pre><code class="language-bash">brew install node
npm install -g aws-cdk
npm install -g yarn</code></pre>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/a98936b8-98c5-45e6-9519-1ad4c62cda2d/image.png" alt=""></p>
<p>여기까지 잘 따라오셨다면, 아래와 같이 json파일이 생성된 것을 확인할 수 있습니다.
해당 파일의 경로는 아래와 같습니다.</p>
<pre><code class="language-bash">/source/packages/@aws-accelerator/installer/cdk.out/</code></pre>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/4ba6fdd3-2f16-4b6c-8a92-8daab5fb1435/image.png" alt=""></p>
<h3 id="3-lza-template-배포secret-생성">3. LZA template 배포(Secret 생성)</h3>
<p>먼저 CodePipeline 수행을 위한 github personal key를 만들어줍니다.</p>
<p>개인 github에 로그인 후
Settings -&gt; Developer Settings -&gt; Personal access Token으로 이동합니다.</p>
<p>이후 Generate New token을 클릭하여 Token을 발행해줍니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/4e2ae2ad-ac1a-4a32-947e-788268898cc8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/seanson2709/post/d3544ab5-be3e-4634-b552-1b27c533829c/image.png" alt=""></p>
<p>이후 생성된 Personal token은 AWS환경에서 사용될 예정이므로 잘 저장해두도록 합니다.</p>
<p>다시 AWS 콘솔로 돌아가 AWS Secret Manager를 생성해줍니다.</p>
<pre><code>AWS Secret Manager -&gt; Secrets -&gt; Store a new secret -&gt; Other typs of secret -&gt; Plaintext 선택 후 token 붙여넣기</code></pre><p><img src="https://velog.velcdn.com/images/seanson2709/post/11c4a9ff-16c2-4c93-9611-b205ba5225bc/image.png" alt=""></p>
<p>Encryption key의 경우 그대로 두고 다음으로 넘어갑니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/3875aec0-94da-40f4-80be-f57c070d7a7d/image.png" alt=""></p>
<p>Secret Name의 경우 아래 명칭을 그대로 입력합니다.</p>
<pre><code>accelerator/github-token</code></pre><p><img src="https://velog.velcdn.com/images/seanson2709/post/5d30aadb-2175-4cf1-817f-450b6c28d7e8/image.png" alt=""></p>
<p>나머지 설정의 경우 default로 하여 설정하면 secret이 설정됩니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/90c92901-5481-4c56-a462-21e4e070fed4/image.png" alt=""></p>
<h3 id="3-lza-template-배포s3-생성-및-stack-배포">3. LZA template 배포(S3 생성 및 stack 배포)</h3>
<p>위에 생성된 template 파일의 경우 stack생성 및 배포를 위해 활용될 예정입니다.
이 template 파일의 저장을 위해 S3를 생성합니다.</p>
<pre><code>Bucket 명칭 예시 : seungwoo-lza-template
(나머지는 default로 설정합니다)</code></pre><p><img src="https://velog.velcdn.com/images/seanson2709/post/e978d5ba-371f-478e-8de7-ea3c963041e7/image.png" alt=""></p>
<hr>
<p>S3에 템플릿 파일을 업로드 하기 위해 aws cli를 설정합니다.
(profile 설정을 하는 것을 추천드리나 이 블로그에서는 프로파일 설정 없이 진행하도록 하겠습니다)</p>
<p>AWS Cli access-key를 IAM으로 설정하거나 아니면 아래와 같은 방법으로 설정합니다.</p>
<pre><code>IAM Identity Center 접속
Access URL 확인</code></pre><p><img src="https://velog.velcdn.com/images/seanson2709/post/d4f67526-cb1d-44b6-a1df-883724db1bed/image.png" alt=""></p>
<p>해당 URL로 접속 시 로그인 페이지 확인 가능하며 기존 설정해둔 이메일로 로그인합니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/dd64b76f-10f7-4088-9761-744259d1f162/image.png" alt=""></p>
<p>이후 Access Key를 눌러 임시 토큰을 가이드에 따라 배포합니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/21c2b4d2-2683-406c-9dcf-004bdf504809/image.png" alt=""></p>
<p>토큰 배포
<img src="https://velog.velcdn.com/images/seanson2709/post/2c5e3990-6bba-435c-a0a3-025b237c37f9/image.png" alt=""></p>
<hr>
<p>AWS CLI 설정이 완료되었다면 아까 만들었던 S3에 Template 파일을 업로드합니다.</p>
<pre><code>cd source/packages/@aws-accelerator/installer
aws s3 mb s3://seungwoo-lza-template # (S3 버킷 명)
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
aws s3api head-bucket --bucket seungwoo-lza-template # (S3 버킷 명) --expected-bucket-owner $ACCOUNT_ID
aws s3 cp ./cdk.out/AWSAccelerator-InstallerStack.template.json s3://seungwoo-lza-template # (S3 버킷 명)</code></pre><p><img src="https://velog.velcdn.com/images/seanson2709/post/657f66ee-4583-4073-88b5-67bdef4f23e8/image.png" alt=""></p>
<p>아래와 같이 생성된 S3버킷에서 템플릿 파일 확인이 가능해야 합니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/ee62c933-11e7-4566-8928-f9a6785ea5b7/image.png" alt=""></p>
<p>이제 거의 완료되었습니다. Stack을 배포해봅시다.</p>
<pre><code>aws cloudformation create-stack \
--stack-name AWSAccelerator-InstallerStack \
--template-url [https://seungwoo-lza-template.s3.us-west-2.amazonaws.com/AWSAccelerator-InstallerStack.template.json] \
--parameters \
ParameterKey=RepositoryName,ParameterValue=[landing-zone-accelerator-on-aws] \
ParameterKey=RepositoryBranchName,ParameterValue=[main] \
ParameterKey=ConfigurationRepositoryLocation,ParameterValue=s3 \
ParameterKey=ManagementAccountEmail,ParameterValue=[seungwooson2709@gmail.com] \
ParameterKey=LogArchiveAccountEmail,ParameterValue=[logarchive@sean-chloe.com ]\
ParameterKey=AuditAccountEmail,ParameterValue=[audit@sean-chloe.com] \
ParameterKey=ApprovalStageNotifyEmailList,ParameterValue=[seungwooson2709@gmail.com] \
ParameterKey=EnableApprovalStage,ParameterValue=Yes \
ParameterKey=ControlTowerEnabled,ParameterValue=Yes \
--capabilities CAPABILITY_IAM</code></pre><p><img src="https://velog.velcdn.com/images/seanson2709/post/a0035ee0-6aae-4d60-af33-17f0faaf38c8/image.png" alt=""></p>
<p>아래와 같이 Stack이 생성되면 정상적으로 수행된 것입니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/f4e57206-fa34-44b7-b647-fd1c35d8a339/image.png" alt=""></p>
<p>자동적으로 Code Pipeline이 수행되며, Control 타워를 구성하게 됩니다.
<img src="https://velog.velcdn.com/images/seanson2709/post/10edd048-1b4b-4a0d-95da-4f02e0440256/image.png" alt=""></p>
<p>✅ 정리
Landing Zone Accelerator는 단순한 VPC 템플릿 그 이상입니다.
제대로 구성하면 AWS 인프라의 표준화, 보안 정책 통제, 계정 자동화까지 가능해집니다.</p>
<pre><code>LZA + Control Tower 조합은 AWS 거버넌스의 핵심입니다.</code></pre><p>여기까지 오게 되면 기본적으로 LZA를 활용하여 AWS Control Tower를 구성할수 있게되는 기본 조건이 만족됩니다.
다음 포스트에서는 LZA 코드를 활용해 기본적인 OU / Accounts 구성 및 네트워크 구성에 대해 알아봅시다.</p>
]]></description>
        </item>
    </channel>
</rss>