홀, 주방, 그리고 접수원
3-tier 구성입니다. 프론트는 고객 주문 페이지와 주방 디스플레이 2뷰(S3 + CloudFront), 백엔드는 주문/보드 Lambda 2종(HTTP API), DB는 메뉴와 주문을 담는 DynamoDB 단일 테이블. AI 접수원은 주문 Lambda 안의 Bedrock Converse 호출로, toolChoice로 강제된 place_order 도구만으로 응답해 자연어를 스키마에 가둡니다. 그리고 그 출력조차 서버의 영수증 로직이 메뉴판과 대조해 다시 검증합니다.
손님 브라우저 ── CloudFront ── S3 ── index.html (자연어 주문 + 영수증)
주방 브라우저 ──────┘ └ kitchen.html (NEW/MAKING/DONE 보드, 3초 폴링)
│
└ fetch ── HTTP API ─┬─ Lambda order ─┬─ Bedrock Converse + toolConfig
│ │ (place_order 강제, Sonnet 4.6)
│ └─ receipt.mjs 재검증 ── DynamoDB (menu + orders)
└─ Lambda board ─── 주문 조회 / 상태 전이 / 매출 집계
A 또는 B를 마친 계정이라면 M0은 확인 위주로 짧아집니다. 90분 시점에 M3 미착수면 부록 컷라인을 적용하세요.
발사 준비, 계획 승인까지
15분스캐폴드와 superpowers를 준비하고 4-Phase 계획을 승인합니다.
aws sts get-caller-identity --query Account --output text
node -v && jq --version && npx cdk --version
Bedrock 호출(주문 접수원)은 다른 계정에서 발급받은 API 키(Bearer)로 인증합니다.
CapstoneA를 거쳤다면 ~/capstone/.env가 이미 있으니 1/3은 건너뛰고 2/3부터 하세요.
mkdir -p ~/capstone
cat > ~/capstone/.env << 'ENVEOF'
AWS_BEARER_TOKEN_BEDROCK=<발급받은 Bedrock API 키>
ENVEOF
set -a; source ~/capstone/.env; set +a
aws bedrock-runtime converse \
--region ap-northeast-2 \
--model-id global.anthropic.claude-sonnet-4-6 \
--messages '[{"role":"user","content":[{"text":"ping"}]}]' \
--inference-config '{"maxTokens":30}' \
--query 'output.message.content[0].text' --output text
mkdir -p ~/capstone/ai-cafe && cd ~/capstone/ai-cafe
[ -f cdk.json ] || npx cdk init app --language typescript
(npx cdk bootstrap > /tmp/bootstrap.log 2>&1 &) # 기부트스트랩 계정은 수 초 내 통과
grep -qxF '.env' .gitignore 2>/dev/null || echo '.env' >> .gitignore
git config user.name "capstone" && git config user.email "capstone@lab.local"
git add -A && git commit -m "chore: cdk scaffold" 2>/dev/null || true
superpowers는 /plugin으로 확인, 없으면
/plugin install superpowers@claude-plugins-official 후 세션 재시작입니다.
/superpowers:brainstorming 다음 요구사항으로 "AI 카페 주문 서비스"를 설계하자.
1) DB: DynamoDB 단일 테이블(pk/sk). 메뉴는 pk=menu(sk=메뉴id, name/price/options 맵),
주문은 pk=order(sk=시각#id, customer/lines/total/status/note/source)
2) 백엔드 Lambda 2종(Node 22) + HTTP API(CORS):
- order: POST /order 가 {items:[...]} 직접 주문과 {text:"자연어"} 겸용.
text면 Bedrock Converse를 toolConfig(place_order 도구, toolChoice 강제)로 호출해
items와 reply를 구조화 추출. 추출 결과는 receipt 모듈이 메뉴판과 대조해 재검증
(없는 메뉴/옵션 거절, 수량 1~10 클램프, 합계 계산). 모델은 env ORDER_MODEL_ID,
기본 global.anthropic.claude-sonnet-4-6, maxTokens 700
- board: GET /orders(최근 30건+매출 합계), POST /status(NEW/MAKING/DONE 전이), GET /menu
3) 프론트 2뷰(S3+CloudFront): index.html 고객 주문(메뉴판, 자연어 입력, 영수증 렌더),
kitchen.html 주방 보드(3열 상태 컬럼, 3초 폴링, 상태 전이 버튼, 매출 표시)
4) CDK TypeScript 단일 스택(CafeStack), ap-northeast-2, DESTROY,
Outputs: ApiUrl/SiteUrl/KitchenUrl/TableName
5) 구현 순서 Phase 1(DB+백엔드, 직접 주문 경로까지) → Phase 2(프론트 2뷰) →
Phase 3(AI 자연어 접수) → Phase 4(러시 시뮬레이터와 개점 스킬)
6) 시간 제약 2시간: 단위 테스트는 receipt 모듈만, 나머지는 smoke curl로 갈음
7) Bedrock 인증은 IAM이 아니라 발급받은 API 키를 쓴다: 배포 셸의 AWS_BEARER_TOKEN_BEDROCK 값을
CDK가 Lambda 환경변수로 주입하고, Bedrock용 IAM 정책은 만들지 않는다
결정이 필요하면 이 범위 안에서 최소한으로만 물어봐.
auto mode on을 선언해 두면 superpowers가
brainstorming → write-plan → execute-plan의 단계 전환과 승인 대기를 자동으로 이어 갑니다.
시간이 빠듯할 때 유용합니다. 단계마다 직접 검토하려면 기본(수동)을 유지하고, 되돌리려면
auto mode off입니다.
치트시트: 인증 없음(데모 카페), 결제 없음(영수증까지만), 다크 테마 + 오렌지 포인트. 계획이 나오면 Phase 4개 구성을 확인하고 승인하세요.
pwd로 확인한 그 경로에서 실행하세요.
백엔드 코어, 메뉴 입고와 첫 주문
30분Phase 1을 배포하고 메뉴를 시딩한 뒤, AI 없이 직접 주문 경로로 주문 저장 → 보드 조회 → 상태 전이의 전 과정을 curl로 왕복합니다.
/superpowers:execute-plan Phase 1만 구현하고 멈춰줘. receipt 모듈에는 요구사항의 단위 테스트(합계, 거절, 클램프)를 포함하고, 끝나면 npx cdk synth --quiet 검증까지 보고해.
set -a; source ~/capstone/.env; set +a # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
API=$(jq -r '.CafeStack.ApiUrl' cdk-outputs.json) && export CAFE_API="$API" OPS_OUTPUTS="$PWD/cdk-outputs.json"
cat > seed.sh << 'SEEDEOF'
#!/bin/bash
# 메뉴 시딩. 사용: ./seed.sh
set -euo pipefail
OUTS="${OPS_OUTPUTS:-cdk-outputs.json}"
TABLE=$(jq -r '.CafeStack.TableName' "$OUTS")
aws dynamodb batch-write-item --request-items "$(jq -n --arg t "$TABLE" '{
($t): [
{"PutRequest":{"Item":{
"pk":{"S":"menu"},"sk":{"S":"americano-ice"},
"name":{"S":"아이스 아메리카노"},"price":{"N":"4500"},
"options":{"M":{"shot":{"N":"500"}}}}}},
{"PutRequest":{"Item":{
"pk":{"S":"menu"},"sk":{"S":"latte"},
"name":{"S":"라떼"},"price":{"N":"5000"},
"options":{"M":{"shot":{"N":"500"},"decaf":{"N":"0"}}}}}},
{"PutRequest":{"Item":{
"pk":{"S":"menu"},"sk":{"S":"croissant"},
"name":{"S":"크루아상"},"price":{"N":"3800"},
"options":{"M":{}}}}}
]}')"
echo "[seed] 메뉴 3종 입고 완료: 아이스 아메리카노 4500 / 라떼 5000 / 크루아상 3800"
SEEDEOF
chmod +x seed.sh
./seed.sh
curl -s "$API/menu" | jq '.menu | length'
ORDER=$(curl -s -X POST "$API/order" -H 'Content-Type: application/json' \
-d '{"items":[{"menu_id":"americano-ice","qty":2,"options":["shot"]},{"menu_id":"croissant","qty":1}],"customer":"tester"}')
echo "$ORDER" | jq '{order_id, total: .receipt.total}'
ID=$(echo "$ORDER" | jq -r '.order_id')
curl -s "$API/orders" | jq '{sales, first: .orders[0].status}'
curl -s -X POST "$API/status" -H 'Content-Type: application/json' -d "{\"id\":\"$ID\",\"status\":\"MAKING\"}"
curl -s "$API/orders" | jq '.orders[0].status'
{ "order_id": "1753224001-a1b2", "total": 13800 }
{ "sales": 13800, "first": "NEW" }
{"ok":true}
"MAKING"
막힐 때 열어보기, 완성본 (스택 + Lambda 3파일, M3까지 반영된 최종형)
뒤처졌다면 아래 5개 파일과 M2의 프론트 2파일을 모두 배치하고 한 번에 deploy해 복구하세요. order.mjs의 자연어 경로는 M3에서 활성화합니다.
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
export class CafeStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const orderModelId = this.node.tryGetContext("orderModelId") ?? "global.anthropic.claude-sonnet-4-6";
// ── Phase 1: 저장소와 주문/보드 API ──
const table = new dynamodb.Table(this, "CafeTable", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const orderFn = new lambda.Function(this, "OrderFn", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "order.handler",
code: lambda.Code.fromAsset("lambda"),
environment: {
TABLE_NAME: table.tableName,
ORDER_MODEL_ID: orderModelId,
// 발급 API 키 인증, 배포 셸에서 주입 (Bedrock IAM 정책 불필요)
AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "",
},
timeout: cdk.Duration.seconds(29),
memorySize: 512,
});
table.grantReadWriteData(orderFn);
const boardFn = new lambda.Function(this, "BoardFn", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "board.handler",
code: lambda.Code.fromAsset("lambda"),
environment: { TABLE_NAME: table.tableName },
timeout: cdk.Duration.seconds(10),
});
table.grantReadWriteData(boardFn);
const api = new apigwv2.HttpApi(this, "CafeApi", {
corsPreflight: {
allowOrigins: ["*"],
allowMethods: [apigwv2.CorsHttpMethod.GET, apigwv2.CorsHttpMethod.POST, apigwv2.CorsHttpMethod.OPTIONS],
allowHeaders: ["Content-Type"],
},
});
const orderInt = new integrations.HttpLambdaIntegration("OrderInt", orderFn);
const boardInt = new integrations.HttpLambdaIntegration("BoardInt", boardFn);
api.addRoutes({ path: "/order", methods: [apigwv2.HttpMethod.POST], integration: orderInt });
api.addRoutes({ path: "/orders", methods: [apigwv2.HttpMethod.GET], integration: boardInt });
api.addRoutes({ path: "/status", methods: [apigwv2.HttpMethod.POST], integration: boardInt });
api.addRoutes({ path: "/menu", methods: [apigwv2.HttpMethod.GET], integration: boardInt });
// ── Phase 2: 프론트 2뷰 (S3 + CloudFront) ──
const siteBucket = new s3.Bucket(this, "SiteBucket", {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
const dist = new cloudfront.Distribution(this, "SiteDist", {
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
defaultRootObject: "index.html",
});
new s3deploy.BucketDeployment(this, "DeploySite", {
sources: [s3deploy.Source.asset("frontend")],
destinationBucket: siteBucket,
distribution: dist,
distributionPaths: ["/*"],
});
new cdk.CfnOutput(this, "ApiUrl", { value: api.apiEndpoint });
new cdk.CfnOutput(this, "SiteUrl", { value: `https://${dist.distributionDomainName}` });
new cdk.CfnOutput(this, "KitchenUrl", { value: `https://${dist.distributionDomainName}/kitchen.html` });
new cdk.CfnOutput(this, "TableName", { value: table.tableName });
}
}
import * as cdk from "aws-cdk-lib";
import { CafeStack } from "./cafe-stack";
const app = new cdk.App();
new CafeStack(app, "CafeStack", {
env: { region: process.env.CDK_DEFAULT_REGION ?? "ap-northeast-2" },
tags: { project: "capstone-ai-cafe" },
});
// 메뉴 대조 검증과 합계 계산. AI가 뽑은 주문을 신뢰하지 않고 여기서 다시 검증한다.
export function buildReceipt(menu, items) {
const byId = Object.fromEntries(menu.map((m) => [m.id, m]));
const lines = [];
const rejected = [];
let total = 0;
for (const it of items ?? []) {
const m = byId[it.menu_id];
if (!m) {
rejected.push(`unknown menu: ${it.menu_id}`);
continue;
}
const qty = Math.min(Math.max(Number(it.qty) || 1, 1), 10);
let unit = m.price;
const opts = [];
for (const o of it.options ?? []) {
if (m.options && o in m.options) {
unit += m.options[o];
opts.push(o);
} else {
rejected.push(`unknown option: ${o} (${m.name})`);
}
}
const subtotal = unit * qty;
total += subtotal;
lines.push({ name: m.name, qty, options: opts, subtotal });
}
return { lines, total, rejected };
}
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { buildReceipt } from "./receipt.mjs";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const brt = new BedrockRuntimeClient({});
const TABLE = process.env.TABLE_NAME;
const MODEL_ID = process.env.ORDER_MODEL_ID ?? "global.anthropic.claude-sonnet-4-6";
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};
async function loadMenu() {
const res = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :p",
ExpressionAttributeValues: { ":p": "menu" },
}));
return (res.Items ?? []).map((x) => ({
id: x.sk, name: x.name, price: x.price, options: x.options ?? {},
}));
}
async function extractOrder(menu, text) {
const res = await brt.send(new ConverseCommand({
modelId: MODEL_ID,
system: [{
text: `당신은 카페 주문 접수원입니다. 메뉴판: ${JSON.stringify(menu)}. ` +
"손님의 말을 place_order 도구 호출로만 응답하세요. " +
"메뉴판에 없는 것은 items에 넣지 말고 reply에서 정중히 안내하세요. " +
"reply는 한국어 한두 문장의 접수 멘트입니다.",
}],
messages: [{ role: "user", content: [{ text: String(text).slice(0, 1000) }] }],
toolConfig: {
tools: [{
toolSpec: {
name: "place_order",
description: "구조화된 카페 주문 접수",
inputSchema: {
json: {
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
menu_id: { type: "string" },
qty: { type: "integer" },
options: { type: "array", items: { type: "string" } },
},
required: ["menu_id", "qty"],
},
},
reply: { type: "string" },
},
required: ["items", "reply"],
},
},
},
}],
toolChoice: { tool: { name: "place_order" } },
},
inferenceConfig: { maxTokens: 700 },
}));
const tu = res.output?.message?.content?.find((c) => c.toolUse)?.toolUse;
if (!tu) throw new Error("no toolUse in response");
return tu.input;
}
export const handler = async (event) => {
const method = event.requestContext?.http?.method ?? "POST";
if (method === "OPTIONS") return { statusCode: 204, headers: CORS };
try {
const body = JSON.parse(event.body ?? "{}");
const menu = await loadMenu();
if (!menu.length) {
return { statusCode: 409, headers: CORS, body: JSON.stringify({ error: "메뉴가 비었습니다. seed.sh를 먼저 실행하세요." }) };
}
let items = body.items;
let reply = "주문이 접수되었습니다.";
if (!items && body.text) {
const extracted = await extractOrder(menu, body.text);
items = extracted.items;
reply = extracted.reply;
}
if (!items) {
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "items 또는 text 필요" }) };
}
const receipt = buildReceipt(menu, items);
if (!receipt.lines.length) {
return { statusCode: 422, headers: CORS, body: JSON.stringify({ reply, receipt, error: "접수 가능한 항목 없음" }) };
}
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
await ddb.send(new PutCommand({
TableName: TABLE,
Item: {
pk: "order", sk: id,
customer: String(body.customer ?? "guest").slice(0, 30),
source: body.text ? "ai" : "direct",
note: String(body.text ?? "").slice(0, 200),
lines: receipt.lines, total: receipt.total,
status: "NEW", at: new Date().toISOString(),
},
}));
return { statusCode: 201, headers: CORS, body: JSON.stringify({ order_id: id, reply, receipt }) };
} catch (err) {
console.error("ORDER_ERROR", err.name, err.message);
return { statusCode: 502, headers: CORS, body: JSON.stringify({ error: err.name, message: err.message }) };
}
};
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
};
const FLOW = ["NEW", "MAKING", "DONE"];
export const handler = async (event) => {
const method = event.requestContext?.http?.method ?? "GET";
const path = event.rawPath ?? "/";
if (method === "OPTIONS") return { statusCode: 204, headers: CORS };
try {
if (path.endsWith("/menu")) {
const res = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :p",
ExpressionAttributeValues: { ":p": "menu" },
}));
return { statusCode: 200, headers: CORS, body: JSON.stringify({ menu: res.Items ?? [] }) };
}
if (path.endsWith("/status") && method === "POST") {
const { id, status } = JSON.parse(event.body ?? "{}");
if (!id || !FLOW.includes(status)) {
return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "id와 status(NEW/MAKING/DONE) 필요" }) };
}
await ddb.send(new UpdateCommand({
TableName: TABLE,
Key: { pk: "order", sk: id },
UpdateExpression: "SET #s = :s",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":s": status },
}));
return { statusCode: 200, headers: CORS, body: JSON.stringify({ ok: true }) };
}
const res = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :p",
ExpressionAttributeValues: { ":p": "order" },
ScanIndexForward: false,
Limit: 30,
}));
const orders = res.Items ?? [];
const sales = orders.reduce((a, x) => a + (x.total || 0), 0);
return { statusCode: 200, headers: CORS, body: JSON.stringify({ orders, sales }) };
} catch (err) {
console.error(err);
return { statusCode: 500, headers: CORS, body: JSON.stringify({ error: err.message }) };
}
};
프론트 2뷰, 홀과 주방을 연다
20분서브에이전트 2개가 고객 페이지와 주방 보드를 병렬로 만드는 동안 Phase 2(S3+CloudFront)를 붙입니다. 창 두 개를 나란히 두면 벌써 가게 같습니다.
서브에이전트 2개를 병렬로 사용해 frontend/ 아래 두 페이지를 만들어줘. 공통: 다크(#12181F)+오렌지(#FF9900), const API_BASE="PASTE_API_URL", fetch 실패는 조용히 무시.
[index.html 고객] GET /menu로 메뉴판(이름/옵션/가격) 렌더, 자연어 주문 textarea와 닉네임 입력,
POST /order {text, customer} 후 영수증 렌더(reply 멘트, 품목 표, 합계, rejected 경고, 주문번호)
[kitchen.html 주방] GET /orders 3초 폴링, NEW/MAKING/DONE 3열 티켓 보드(닉네임, 품목 x수량, 합계, 자연어 note),
NEW에는 "제조 시작", MAKING에는 "완성" 버튼으로 POST /status 전이, 헤더에 오늘 매출 표시
/superpowers:execute-plan Phase 2(S3 + CloudFront + BucketDeployment, frontend/ 소스)만 구현해줘.
sed -i "s|PASTE_API_URL|$API|" frontend/index.html frontend/kitchen.html
set -a; source ~/capstone/.env; set +a # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
jq -r '.CafeStack | "고객: \(.SiteUrl)\n주방: \(.KitchenUrl)"' cdk-outputs.json
브라우저 창 두 개를 나란히 여세요. 고객 페이지에 메뉴 3종이 보이고, M1에서 넣은 tester 주문 티켓이 주방 보드 MAKING 열에 있어야 합니다. "완성" 버튼으로 DONE까지 밀어 보세요. CloudFront 첫 전파 몇 분 동안은 M3를 먼저 진행합니다.
막힐 때 열어보기, 완성본 (프론트 2뷰)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Cafe</title>
<style>
:root { --bg:#12181F; --card:#1B242E; --line:#2A3542; --text:#E8ECF1; --muted:#8B96A5; --accent:#FF9900; --green:#3FB950; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--text); font-family:system-ui,sans-serif; padding:28px 20px 60px; }
.wrap { max-width:680px; margin:0 auto; }
h1 { font-size:26px; } h1 em { color:var(--accent); font-style:normal; }
.sub { color:var(--muted); font-size:13px; margin:4px 0 20px; }
.panel { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:16px; }
.panel h2 { font-size:13px; color:var(--muted); letter-spacing:.06em; margin-bottom:12px; }
ul#menu { list-style:none; display:grid; gap:8px; }
ul#menu li { display:flex; justify-content:space-between; font-size:14.5px; padding:8px 10px; background:#0C1117; border-radius:8px; }
ul#menu .price { color:var(--accent); font-weight:700; }
ul#menu .opt { color:var(--muted); font-size:12px; }
textarea { width:100%; background:#0C1117; border:1px solid var(--line); border-radius:8px; color:var(--text); padding:12px; font-size:15px; min-height:74px; resize:vertical; }
.row { display:flex; gap:10px; margin-top:10px; }
input#nick { flex:1; background:#0C1117; border:1px solid var(--line); border-radius:8px; color:var(--text); padding:10px 12px; }
button { background:var(--accent); border:0; border-radius:8px; color:#12181F; padding:10px 24px; font-weight:800; cursor:pointer; font-size:15px; }
button:disabled { opacity:.5; }
#receipt { font-size:14px; line-height:1.7; }
#receipt .reply { color:var(--green); margin-bottom:8px; }
#receipt table { width:100%; border-collapse:collapse; margin-top:6px; }
#receipt td { padding:6px 4px; border-bottom:1px solid var(--line); }
#receipt td.r { text-align:right; }
#receipt .total td { color:var(--accent); font-weight:800; border-bottom:0; }
#receipt .rej { color:#f08080; font-size:12.5px; margin-top:6px; }
.empty { color:var(--muted); font-size:13px; }
</style>
</head>
<body>
<div class="wrap">
<h1><em>Claude</em> Cafe</h1>
<div class="sub">말로 주문하세요. AI 접수원이 알아듣습니다 - Capstone Lab C</div>
<div class="panel">
<h2>MENU</h2>
<ul id="menu"><li class="empty">메뉴 불러오는 중... (비어 있으면 seed.sh를 실행하세요)</li></ul>
</div>
<div class="panel">
<h2>주문하기</h2>
<textarea id="say" placeholder="예: 아이스 아메리카노 두 잔인데 한 잔은 샷 추가요, 크루아상도 하나 주세요"></textarea>
<div class="row">
<input id="nick" placeholder="닉네임 (주방 보드에 표시)" maxlength="20">
<button id="go">주문</button>
</div>
</div>
<div class="panel">
<h2>영수증</h2>
<div id="receipt"><span class="empty">아직 주문이 없습니다.</span></div>
</div>
</div>
<script>
"use strict";
var API_BASE = "PASTE_API_URL";
function won(n) { return Number(n || 0).toLocaleString("ko-KR") + "원"; }
fetch(API_BASE + "/menu")
.then(function (r) { return r.json(); })
.then(function (d) {
var ul = document.getElementById("menu");
if (!d.menu || !d.menu.length) return;
ul.innerHTML = d.menu.map(function (m) {
var opts = Object.keys(m.options || {}).map(function (k) {
return k + (m.options[k] ? " +" + won(m.options[k]) : "");
}).join(", ");
return "<li><span>" + m.name + (opts ? ' <span class="opt">(' + opts + ")</span>" : "") +
'</span><span class="price">' + won(m.price) + "</span></li>";
}).join("");
})
.catch(function () {});
document.getElementById("go").addEventListener("click", function () {
var text = document.getElementById("say").value.trim();
if (!text) return;
var btn = this, box = document.getElementById("receipt");
btn.disabled = true;
box.innerHTML = '<span class="empty">AI 접수원이 듣는 중...</span>';
fetch(API_BASE + "/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text, customer: document.getElementById("nick").value.trim() || "guest" })
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.receipt) { box.innerHTML = '<span class="rej">' + (d.error || "접수 실패") + "</span>"; return; }
var rows = d.receipt.lines.map(function (l) {
return "<tr><td>" + l.name + (l.options.length ? " (" + l.options.join(", ") + ")" : "") +
"</td><td class='r'>x" + l.qty + "</td><td class='r'>" + won(l.subtotal) + "</td></tr>";
}).join("");
box.innerHTML = '<div class="reply">"' + (d.reply || "") + '"</div>' +
"<table>" + rows + '<tr class="total"><td>합계</td><td></td><td class="r">' + won(d.receipt.total) + "</td></tr></table>" +
(d.receipt.rejected.length ? '<div class="rej">제외됨: ' + d.receipt.rejected.join(" / ") + "</div>" : "") +
(d.order_id ? '<div class="empty">주문번호 ' + d.order_id + " - 주방 보드에서 확인하세요</div>" : "");
})
.catch(function (e) { box.innerHTML = '<span class="rej">오류: ' + e.message + "</span>"; })
.finally(function () { btn.disabled = false; });
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Cafe Kitchen</title>
<style>
:root { --bg:#12181F; --card:#1B242E; --line:#2A3542; --text:#E8ECF1; --muted:#8B96A5; --accent:#FF9900; --green:#3FB950; --purple:#7a3ff2; }
* { box-sizing:border-box; margin:0; }
body { background:var(--bg); color:var(--text); font-family:system-ui,sans-serif; padding:24px; }
header { display:flex; justify-content:space-between; align-items:baseline; max-width:1100px; margin:0 auto 18px; }
h1 { font-size:22px; } h1 em { color:var(--accent); font-style:normal; }
#sales { color:var(--green); font-weight:800; font-size:18px; }
.cols { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; max-width:1100px; margin:0 auto; }
.col h2 { font-size:13px; letter-spacing:.08em; margin-bottom:10px; }
.col.new h2 { color:var(--accent); } .col.making h2 { color:#c9a2ff; } .col.done h2 { color:var(--green); }
.ticket { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:12px 14px; margin-bottom:10px; font-size:13.5px; }
.ticket .who { display:flex; justify-content:space-between; color:var(--muted); font-size:12px; margin-bottom:6px; }
.ticket ul { list-style:none; margin:0 0 8px; }
.ticket .total { color:var(--accent); font-weight:700; }
.ticket .note { color:var(--muted); font-size:12px; margin-top:4px; font-style:italic; }
.ticket button { margin-top:8px; width:100%; background:var(--purple); color:#fff; border:0; border-radius:7px; padding:7px 0; font-weight:700; cursor:pointer; }
.col.making .ticket button { background:var(--green); color:#12181F; }
.empty { color:var(--muted); font-size:12.5px; }
</style>
</head>
<body>
<header>
<h1><em>Claude</em> Cafe, 주방 보드</h1>
<div>오늘 매출 <span id="sales">0원</span></div>
</header>
<div class="cols">
<div class="col new"><h2>NEW</h2><div id="new"><div class="empty">주문 대기 중</div></div></div>
<div class="col making"><h2>MAKING</h2><div id="making"><div class="empty">-</div></div></div>
<div class="col done"><h2>DONE</h2><div id="done"><div class="empty">-</div></div></div>
</div>
<script>
"use strict";
var API_BASE = "PASTE_API_URL";
function won(n) { return Number(n || 0).toLocaleString("ko-KR") + "원"; }
function ticket(o) {
var lines = (o.lines || []).map(function (l) {
return "<li>" + l.name + (l.options.length ? " (" + l.options.join(", ") + ")" : "") + " x" + l.qty + "</li>";
}).join("");
var next = o.status === "NEW" ? "MAKING" : o.status === "MAKING" ? "DONE" : null;
var btn = next ? '<button onclick="advance(\'' + o.sk + "','" + next + '\')">' +
(next === "MAKING" ? "제조 시작" : "완성") + "</button>" : "";
return '<div class="ticket"><div class="who"><span>' + (o.customer || "guest") +
(o.source === "ai" ? " (AI 접수)" : "") + "</span><span>#" + String(o.sk).slice(-4) + "</span></div>" +
"<ul>" + lines + '</ul><div class="total">' + won(o.total) + "</div>" +
(o.note ? '<div class="note">"' + o.note + '"</div>' : "") + btn + "</div>";
}
function advance(id, status) {
fetch(API_BASE + "/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, status: status })
}).then(refresh).catch(function () {});
}
function refresh() {
fetch(API_BASE + "/orders")
.then(function (r) { return r.json(); })
.then(function (d) {
document.getElementById("sales").textContent = won(d.sales);
["NEW", "MAKING", "DONE"].forEach(function (s) {
var el = document.getElementById(s.toLowerCase());
var list = (d.orders || []).filter(function (o) { return o.status === s; });
el.innerHTML = list.length ? list.map(ticket).join("") : '<div class="empty">-</div>';
});
})
.catch(function () {});
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
AI 접수원, 자연어를 스키마에 가두다
30분Converse의 toolConfig + toolChoice 강제로 자연어 주문을 place_order 스키마로 추출하고, 그 출력을 영수증 로직이 메뉴판과 대조해 재검증합니다.
| 모델 | Global Inference Profile ID | 선택 |
|---|---|---|
| Claude Sonnet 4.6 | global.anthropic.claude-sonnet-4-6 | 기본값, 접수 속도 우수 |
| Claude Sonnet 5 | global.anthropic.claude-sonnet-5 | deploy 시 -c orderModelId=... 교체 |
/superpowers:execute-plan Phase 3(자연어 접수)을 구현해줘. 요구사항 2)의 toolConfig 스펙 그대로: place_order 도구 하나, toolChoice로 그 도구 강제, 시스템 프롬프트에 메뉴판 JSON을 넣고 "메뉴에 없는 건 items에 넣지 말고 reply로 안내"를 명시해.
스택은 Bedrock IAM 정책 없이, 주문 Lambda environment에
AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "" 를 주입해.
set -a; source ~/capstone/.env; set +a # Bearer 키를 배포 셸에 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
curl -s -X POST "$API/order" -H 'Content-Type: application/json' \
-d '{"text":"아이스 아메리카노 두 잔인데 한 잔은 샷 추가요, 크루아상도 하나 주세요","customer":"first-guest"}' \
| jq '{reply, lines: .receipt.lines, total: .receipt.total}'
AWS_BEARER_TOKEN_BEDROCK을 읽어 Lambda에 넣으므로,
새 터미널에서 deploy할 때는 항상 set -a; source ~/capstone/.env; set +a를 먼저 실행하세요.
빈 값으로 배포되면 접수원이 401을 반환합니다.
{
"reply": "네, 아이스 아메리카노 두 잔(한 잔 샷 추가)과 크루아상 하나 접수했습니다!",
"lines": [
{ "name": "아이스 아메리카노", "qty": 1, "options": [], "subtotal": 4500 },
{ "name": "아이스 아메리카노", "qty": 1, "options": ["shot"], "subtotal": 5000 },
{ "name": "크루아상", "qty": 1, "options": [], "subtotal": 3800 }
],
"total": 13300
}
| 변화구 | 주문 문장 | 기대 동작 |
|---|---|---|
| 한국어 수량 | "라떼 석 잔이요, 전부 디카페인으로" | qty 3, options에 decaf |
| 없는 메뉴 | "티라미수 하나랑 아아 하나" | 티라미수는 items 제외, reply에서 안내, 아아만 접수 |
| 없는 옵션 | "크루아상 데워서 두 개" | receipt.rejected에 unknown option 기록, 주문은 접수 |
고객 페이지에서도 직접 말로 주문해 보세요. 영수증의 reply 멘트가 모델마다 달라지는 것, 그리고 rejected가 뜨는 순간이 이 아키텍처의 요점입니다. AI가 무엇을 뽑아 오든 서버의 receipt가 메뉴판이라는 단일 진실과 대조합니다.
async function extractOrder(menu, text) {
const res = await brt.send(new ConverseCommand({
modelId: MODEL_ID,
system: [{
text: `당신은 카페 주문 접수원입니다. 메뉴판: ${JSON.stringify(menu)}. ` +
"손님의 말을 place_order 도구 호출로만 응답하세요. " +
"메뉴판에 없는 것은 items에 넣지 말고 reply에서 정중히 안내하세요. " +
"reply는 한국어 한두 문장의 접수 멘트입니다.",
}],
messages: [{ role: "user", content: [{ text: String(text).slice(0, 1000) }] }],
toolConfig: {
tools: [{
toolSpec: {
name: "place_order",
description: "구조화된 카페 주문 접수",
inputSchema: {
json: {
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
menu_id: { type: "string" },
qty: { type: "integer" },
options: { type: "array", items: { type: "string" } },
},
required: ["menu_id", "qty"],
},
},
reply: { type: "string" },
},
required: ["items", "reply"],
},
},
},
}],
toolChoice: { tool: { name: "place_order" } },
},
inferenceConfig: { maxTokens: 700 },
}));
const tu = res.output?.message?.content?.find((c) => c.toolUse)?.toolUse;
if (!tu) throw new Error("no toolUse in response");
return tu.input;
}
주문 러시, 손님을 합성하다
20분Chapter 5의 헤드리스로 손님 페르소나 주문문을 생성해 API에 밀어넣는 러시 시뮬레이터를 돌리고, 개점 절차 전체를 /open-cafe 스킬 하나로 접습니다. 주방 보드가 붐비는 걸 지켜보세요.
cat > rush.sh << 'RUSHEOF'
#!/bin/bash
# 사용: ./rush.sh [건수] - Claude 헤드리스가 손님 주문문을 만들어 카페 API에 밀어넣는다
set -euo pipefail
API="${CAFE_API:?CAFE_API 필요 (카페 ApiUrl)}"
N="${1:-8}"
echo "[rush] 손님 ${N}명 생성 중..."
LINES=$(claude -p "한국 카페 손님이 말할 법한 자연어 주문 문장을 정확히 ${N}개 만들어 JSON 문자열 배열로만 출력해. 메뉴는 아이스 아메리카노, 라떼, 크루아상뿐이고 수량과 옵션(샷 추가, 디카페인)을 다양하게 섞어. 코드펜스와 설명 금지." \
--output-format json | jq -r '.result')
echo "$LINES" | jq -r '.[]' | while IFS= read -r line; do
printf '주문: %s\n' "$line"
curl -s -X POST "$API/order" -H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$line" '{text:$t, customer:"rush-bot"}')" | jq -r '.reply // .error'
sleep 1
done
echo "[rush] 완료. 주방 보드가 붐빕니다."
RUSHEOF
chmod +x rush.sh
./rush.sh 8
주방 보드를 옆에 두고 실행하세요. rush-bot 손님 8명이 1초 간격으로 들이닥칩니다.
jq -n --arg로 사용자 입력을 안전하게 JSON에 넣는 것,
헤드리스 결과를 jq -r '.result'로 뽑아 배열 반복하는 것, 전부 Chapter 5 Task 3의 재활용입니다.
mkdir -p .claude/skills/open-cafe
cat > .claude/skills/open-cafe/SKILL.md << 'CAFEEOF'
---
description: 카페 개점 원커맨드. 배포, 메뉴 시딩, 스모크 주문, 개점 안내까지 수행한다.
disable-model-invocation: true
argument-hint: "[카페이름]"
allowed-tools: Bash(npx cdk *) Bash(./seed.sh) Bash(curl *) Bash(jq *) Read
---
카페를 개점하세요. 카페 이름: $ARGUMENTS
1. `npx cdk deploy --require-approval never --outputs-file cdk-outputs.json` 실행.
2. cdk-outputs.json에서 ApiUrl, SiteUrl, KitchenUrl을 읽으세요.
3. `curl -s <ApiUrl>/menu` 의 menu가 비어 있으면 `./seed.sh` 실행.
4. 스모크 주문: `curl -s -X POST <ApiUrl>/order -H "Content-Type: application/json" -d '{"items":[{"menu_id":"croissant","qty":1}],"customer":"smoke"}'` 가 201이어야 합니다.
5. 개점 안내를 표로 출력: 고객 주문(SiteUrl), 주방 보드(KitchenUrl), 오늘의 메뉴 3종과 가격.
6. 마지막 줄에 "$ARGUMENTS, 개점했습니다"를 선언하세요.
CAFEEOF
echo "새 세션에서 /open-cafe 사용 가능"
/open-cafe 우형의 다락방
개점 스트레스 테스트
15분이제 여러분이 가장 까다로운 손님입니다. 변화구 카드를 고객 페이지에 던져 접수원의 한계와 방어선을 확인하세요. 거절과 안내가 정확히 나오는 순간이 진짜 개점입니다.
고객 페이지에서 아래 카드 중 최소 3장(1, 5번 함정 포함 권장)을 주문하고, 영수증의 reply와 rejected, 그리고 주방 보드의 티켓을 함께 관찰하세요. "제조 시작"과 "완성" 버튼으로 마감까지 밀어 주는 것이 접객의 끝입니다.
| # | 변화구 주문 | 관전 포인트 |
|---|---|---|
| 1 | "아아 세 잔인데 한 잔은 뜨겁게요" | 메뉴에 핫이 없다, 접수원의 안내 멘트 |
| 2 | "제일 싼 걸로 두 개 주세요" | 가격 추론, 크루아상 2개가 나오는가 |
| 3 | "라떼 넉 잔, 다 디카페인, 두 잔은 샷도 추가" | 수량 한국어 + 옵션 분배 |
| 4 | "티라미수 있어요? 없으면 크루아상이요" | 조건부 주문 처리 |
| 5 | "아까 시킨 거 취소하고 라떼 하나만요" | 취소 기능이 없다, 정직한 안내가 나오는가 |
| 6 | "단체 주문이요, 아아 열다섯 잔" | qty 클램프(10)와 rejected 없는 감량, 영수증 확인 |
curl -s "$API/orders" | jq '{오늘_매출: .sales, 주문_수: (.orders | length), AI_접수: [.orders[] | select(.source == "ai")] | length}'
git add -A && git commit -m "feat: ai cafe capstone grand open"
# 실습을 마쳤다면 비용 정리 (이어서 볼 계획이면 유지):
# npx cdk destroy --force
하네스 엔지니어링, 작품을 성숙시키고 등급을 받다
+15분 옵션지금까지 만든 것은 결과물입니다. 이 옵션은 그 결과물을 만든 작업 환경, 즉 하네스 (CLAUDE.md, hooks, skills, commands, agents)를 표준 구조로 성숙시키고(project-init), 그 품질을 6개 차원(정확성, 안전성, 완전성, 실행 가능성, 일관성, 검증 가능성)에서 채점받는(harness-eval) 단계입니다. superpowers의 3단(brainstorm, write-plan, execute-plan) 뒤에 4단 성숙화와 5단 평가를 붙이는 셈입니다.
claude plugin marketplace add https://github.com/whchoi98/project-init
claude plugin install project-init@project-init
claude plugin marketplace add https://github.com/whchoi98/harness-eval
claude plugin install harness-eval@harness-eval
claude plugin list
설치 후 새 Claude 세션을 시작하세요, superpowers 때와 같은 규칙입니다.
/project-init:init-project .
기존 프로젝트 감지 모드로 동작합니다. 이 캡스톤에서 손으로 만든 /open-cafe 스킬 같은 기존 하네스는 보존하고, 없는 것(문서 스캐폴딩, 시크릿 스캔 훅, 테스트 프레임워크, /review와 /test-all 커맨드 등)만 채웁니다. CLAUDE.md가 이미 있으면 덮어쓰기 전에 물어봅니다. 끝나면 이어서:
/project-init:sync-docs
## Sync Report
### Quality Scores (Before -> After)
| ./CLAUDE.md | C (62) | B (84) | +22 |
| ./lambda/CLAUDE.md | F (12) | C (58) | +46 |
### Changes Made
- Files created: 4 / Files updated: 2 / Runbooks missing: 1
/harness-eval:quick
{"mode": "quick", "scores": {"overall": 7.2, "grade": "B"}, "checklist": {"pass": 13, "warn": 2, "fail": 1}}
"AI의 출력은 서버가 재검증한다"는 원칙을 CLAUDE.md의 비자명한 패턴으로 기록해 두면 sync-docs 점수가 오르고, 다음에 이 저장소를 여는 Claude가 같은 원칙을 지킵니다. 여유가 있다면 이 캡스톤에서 실제로 내린 결정을 기록으로 남기세요:
/add-adr toolchoice-forced-extraction
/add-runbook open-cafe
컷라인과 트러블슈팅
비상용90분 시점 기준, 순서대로 적용합니다. 어떤 컷에서도 M3의 자연어 접수와 M5의 변화구 시험은 지킵니다.
| 컷 | 조치 | 절약 |
|---|---|---|
| CUT 1 | M4 러시 생략, /open-cafe 스킬만 설치 | ~8분 |
| CUT 2 | M2 kitchen.html을 완성본 배치로 대체, 서브에이전트는 index.html만 | ~7분 |
| CUT 3 | M3 변화구를 2번(없는 메뉴) 하나로 축소 | ~5분 |
| 증상 | 원인 | 처방 |
|---|---|---|
| "no toolUse in response" | toolChoice 누락 또는 도구 미지원 구모델 | toolConfig에 toolChoice 블록 확인, global. 프로파일 확인 |
| 409 "메뉴가 비었습니다" | seed 미실행 또는 다른 테이블 | ./seed.sh, OPS_OUTPUTS가 현재 worktree인지 확인 |
| 영수증에 rejected만 가득 | 모델이 메뉴 id 대신 이름을 추측(환각) | 시스템 프롬프트에 메뉴판 JSON 포함 여부 확인, receipt가 방어 중인 정상 신호 |
| ValidationException / AccessDenied | 베이스 ID 또는 IAM 2종 누락 | M3 표의 프로파일 ID, foundation-model + inference-profile 확인 |
| /order만 30초 타임아웃 | HTTP API 상한 | maxTokens 700 유지, 시스템 프롬프트 축소 |
| 고객 페이지 메뉴 빈 화면 | CloudFront 전파 또는 API_BASE 미교체 | 수 분 대기, 두 html의 API_BASE 확인 후 재배포 |
| 주방 보드에 주문이 안 뜸 | 다른 스택의 API를 보는 중 | kitchen.html의 API_BASE와 $API 일치 확인 |
마무리, 챕터 소환 지도
| 캡스톤 C 장면 | 소환된 챕터 | 핵심 역량 |
|---|---|---|
| brainstorm의 스키마 명세 | Ch1 + superpowers | 요구사항이 곧 설계 품질 |
| 2뷰 병렬 빌드 | Ch2 | 서브에이전트 2개 동시 위임 |
| Bedrock IAM 2종, 모델 컨텍스트 교체 | Ch3 | 최소 권한, Global CRIS |
| /open-cafe 원커맨드 | Ch4 | 운영 절차의 스킬화 |
| rush.sh 손님 합성 | Ch5 | 헤드리스 생성 + jq -n 안전 주입 |