무엇을, 어떻게 만드는가
최종 산출물은 CDK TypeScript 단일 스택입니다. 브라우저는 CloudFront가 서빙하는 SPA를 열고,
SPA는 HTTP API의 /events(DynamoDB)와 /ask(Bedrock)를 호출합니다.
마지막 미션에서 여러분의 EC2 위 Claude Code에 Hook을 달아, 세션 활동이 방금 배포한 API로 흘러 들어가게 만듭니다.
[EC2의 Claude Code] --(M4 Hook: 활동 이벤트 POST)--------┐
v
브라우저 ── CloudFront ── S3 (SPA) HTTP API (API GW v2)
│ ├── Lambda events ── DynamoDB
└── fetch /events, /ask ────────────┴── Lambda ask ──── Bedrock
(Global Inference Profile,
Sonnet 4.6)
시간 배분은 130분 설계 + 20분 버퍼입니다. 90분 경과 시점에 M3를 시작하지 못했다면 부록 컷라인을 적용하세요.
발사 준비, superpowers 장착과 설계 승인
20분환경을 점검하고, superpowers를 설치한 뒤, 제공된 요구사항으로 brainstorm → plan을 돌려 4-Phase 실행 계획을 승인합니다. CDK bootstrap은 백그라운드로 겹칩니다.
aws sts get-caller-identity --query Account --output text
node -v && git --version && jq --version
npx cdk --version
Bedrock 호출은 다른 계정에서 발급받은 Bedrock API 키(Bearer 토큰)로 인증합니다. SigV4 자격증명 없이 환경변수 하나면 됩니다. 세 단계로 스모크 테스트합니다.
mkdir -p ~/capstone
cat > ~/capstone/.env << 'ENVEOF'
AWS_BEARER_TOKEN_BEDROCK=<발급받은 Bedrock API 키>
ENVEOF
저장 후 파일을 열어 <발급받은 Bedrock API 키> 자리를 실제 키로 바꾸세요.
리포 바깥(~/capstone/.env)에 두므로 git add -A에 딸려 커밋될 일이 원천적으로 없습니다.
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
UnrecognizedClientException은 키 오탈자나 만료입니다, ~/capstone/.env를 고치고
2/3 로드를 다시 실행하세요. AccessDeniedException은 발급 계정의 모델 액세스 미승인,
ValidationException(on-demand 미지원)은 베이스 모델 ID를 쓴 경우입니다.
신형 모델은 반드시 global. 프로파일 ID로 호출합니다.
Bearer 키는 Bedrock 호출 전용이고, CDK 배포는 기존 EC2 롤(SigV4)을 그대로 씁니다. 여기서 막히면 부록 트러블슈팅의 모델 항목을 확인하세요. 이 관문만 넘으면 M3는 순항입니다.
mkdir -p ~/capstone/ops-center && cd ~/capstone/ops-center
[ -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
echo "bootstrap은 백그라운드 진행 중. tail -f /tmp/bootstrap.log 로 확인 가능"
두 번째 줄의 가드 덕에 재실행해도 안전합니다(이미 초기화된 폴더면 init을 건너뜀). .gitignore와 git 신원 설정은 실수 커밋과 신원 경고를 예방하는 안전벨트입니다.
/plugin install superpowers@claude-plugins-official
설치 후 /plugin으로 활성 상태를 확인하고, 세션을 재시작하세요
(/exit 후 claude). superpowers는 세션 시작 훅으로 부트스트랩되므로 재시작이 필요합니다.
/plugin marketplace add anthropics/claude-plugins-official 후 재시도하거나,
커뮤니티 경로 /plugin marketplace add obra/superpowers-marketplace →
/plugin install superpowers@superpowers-marketplace를 사용하세요. 결과는 동일합니다.
/superpowers:brainstorming 다음 요구사항으로 "Claude Ops Center"를 설계하자.
1) 정적 SPA 1페이지 대시보드: S3 + CloudFront(OAC) 서빙
2) HTTP API(API Gateway v2) + Lambda(Node 22) + DynamoDB 단일 테이블(pk/sk): POST/GET /events
3) POST /ask: Lambda가 Bedrock Converse API로 Global Inference Profile 호출
(기본 global.anthropic.claude-sonnet-4-6, CDK context askModelId로 교체 가능), maxTokens 600, CORS 허용.
Bedrock 인증은 IAM이 아니라 다른 계정에서 발급한 API 키를 쓴다: 배포 셸의 AWS_BEARER_TOKEN_BEDROCK 값을
CDK가 Lambda 환경변수로 주입하고, Bedrock용 IAM 정책은 만들지 않는다
4) 인프라는 CDK TypeScript 단일 스택, 리전 ap-northeast-2, 리소스는 삭제 가능(DESTROY)
5) 구현 순서는 Phase 1(events API) → Phase 2(frontend) → Phase 3(/ask) → Phase 4(관측 hook, ship 스킬)
6) 시간 제약 2시간: 단위 테스트는 생략하고 각 Phase의 smoke curl로 검증을 갈음한다
결정이 필요하면 이 범위 안에서 최소한으로만 물어봐.
auto mode on을 선언해 두면 superpowers가
brainstorming → write-plan → execute-plan의 단계 전환과 승인 대기를 자동으로 이어 갑니다.
시간이 빠듯할 때 유용합니다. 단계마다 직접 검토하려면 기본(수동)을 유지하고, 되돌리려면
auto mode off입니다.
브레인스토밍 질문에는 이 치트시트로 답하세요: 인증 없음(퍼블릭 데모), 데이터 보존 불필요, UI는 다크 테마 + 오렌지 포인트. 3~4문답이면 설계가 수렴합니다.
pwd로 확인한 그 worktree 경로에서 실행하세요.
bootstrap은 계정 단위 작업이라 영향이 없습니다.
/superpowers:write-plan
생성된 계획 문서에서 Phase가 4개로 나뉘었는지, Phase 1의 산출물에 DynamoDB 테이블 / events Lambda / HTTP API 라우트가 들어 있는지 검토하고 승인하세요. 계획이 다르게 나뉘었으면 "요구사항 5)의 Phase 구분으로 재편해줘"라고 지시합니다.
코어 스택, 첫 배포와 첫 왕복
30분Phase 1(DynamoDB + events Lambda + HTTP API)을 실행시키고 배포해, curl로 넣은 이벤트가 다시 조회되는 왕복을 확인합니다. Chapter 5의 헤드리스 어휘가 여기서 일상어가 됩니다.
/superpowers:execute-plan 계획의 Phase 1만 구현하고 멈춰줘. 구현이 끝나면 npx cdk synth --quiet 로 합성 검증까지 하고 결과를 보고해.
set -a; source ~/capstone/.env; set +a # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
첫 배포는 3~5분입니다. 기다리지 말고 지금 바로 M2의 Step 1(프론트 병렬 빌드)을 시작하세요. 이 겹침이 캡스톤 시간표의 핵심입니다.
API=$(jq -r '.OpsCenterStack.ApiUrl' cdk-outputs.json) && echo "$API"
curl -s -X POST "$API/events" -H 'Content-Type: application/json' \
-d '{"kind":"manual","detail":"first blood","turns":1,"cost_usd":0.01}'
curl -s "$API/events" | jq '.summary'
{"ok":true}
{
"count": 1,
"total_cost_usd": 0.01,
"total_turns": 1
}
막힐 때 열어보기, 완성본 (스택 최종형 + events Lambda)
아래는 M3까지 반영된 최종형 스택입니다. 뒤처졌다면 이 파일과 M2의 index.html, M3의 ask.mjs까지 4개 파일을 모두 배치하고 한 번에 deploy해 복구하세요 (lib/ops-center-stack.ts, bin은 두 번째 블록).
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 OpsCenterStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const askModelId = this.node.tryGetContext("askModelId") ?? "global.anthropic.claude-sonnet-4-6";
// ── Phase 1: 이벤트 저장소와 API ──
const table = new dynamodb.Table(this, "EventsTable", {
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 eventsFn = new lambda.Function(this, "EventsFn", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "events.handler",
code: lambda.Code.fromAsset("lambda"),
environment: { TABLE_NAME: table.tableName },
timeout: cdk.Duration.seconds(10),
});
table.grantReadWriteData(eventsFn);
const api = new apigwv2.HttpApi(this, "OpsApi", {
corsPreflight: {
allowOrigins: ["*"],
allowMethods: [apigwv2.CorsHttpMethod.GET, apigwv2.CorsHttpMethod.POST, apigwv2.CorsHttpMethod.OPTIONS],
allowHeaders: ["Content-Type"],
},
});
api.addRoutes({
path: "/events",
methods: [apigwv2.HttpMethod.GET, apigwv2.HttpMethod.POST],
integration: new integrations.HttpLambdaIntegration("EventsInt", eventsFn),
});
// ── Phase 3: Bedrock /ask ──
const askFn = new lambda.Function(this, "AskFn", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "ask.handler",
code: lambda.Code.fromAsset("lambda"),
environment: {
ASK_MODEL_ID: askModelId,
// 다른 계정 발급 Bedrock API 키, 배포 셸에서 주입 (IAM 정책 불필요)
AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "",
},
timeout: cdk.Duration.seconds(29),
memorySize: 512,
});
api.addRoutes({
path: "/ask",
methods: [apigwv2.HttpMethod.POST],
integration: new integrations.HttpLambdaIntegration("AskInt", askFn),
});
// ── Phase 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: ["/*"],
});
// ── Outputs ──
new cdk.CfnOutput(this, "ApiUrl", { value: api.apiEndpoint });
new cdk.CfnOutput(this, "SiteUrl", { value: `https://${dist.distributionDomainName}` });
new cdk.CfnOutput(this, "AskModelId", { value: askModelId });
}
}
import * as cdk from "aws-cdk-lib";
import { OpsCenterStack } from "./ops-center-stack";
const app = new cdk.App();
new OpsCenterStack(app, "OpsCenterStack", {
env: { region: process.env.CDK_DEFAULT_REGION ?? "ap-northeast-2" },
tags: { project: "capstone-ops-center" },
});
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, QueryCommand } 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",
};
export const handler = async (event) => {
const method = event.requestContext?.http?.method ?? "GET";
if (method === "OPTIONS") return { statusCode: 204, headers: CORS };
try {
if (method === "POST") {
const body = JSON.parse(event.body ?? "{}");
const item = {
pk: "session",
sk: `${Date.now()}#${Math.random().toString(36).slice(2, 8)}`,
kind: String(body.kind ?? "unknown").slice(0, 40),
detail: String(body.detail ?? "").slice(0, 300),
cost_usd: Number(body.cost_usd ?? 0),
turns: Number(body.turns ?? 0),
at: new Date().toISOString(),
};
await ddb.send(new PutCommand({ TableName: TABLE, Item: item }));
return { statusCode: 201, headers: CORS, body: JSON.stringify({ ok: true }) };
}
const res = await ddb.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :p",
ExpressionAttributeValues: { ":p": "session" },
ScanIndexForward: false,
Limit: 50,
}));
const items = res.Items ?? [];
const summary = {
count: items.length,
total_cost_usd: Number(items.reduce((a, x) => a + (x.cost_usd || 0), 0).toFixed(4)),
total_turns: items.reduce((a, x) => a + (x.turns || 0), 0),
};
return { statusCode: 200, headers: CORS, body: JSON.stringify({ summary, items }) };
} catch (err) {
console.error(err);
return { statusCode: 500, headers: CORS, body: JSON.stringify({ error: err.message }) };
}
};
프론트 병렬 빌드, 대시보드 개장
20분M1 배포가 도는 동안 서브에이전트로 SPA를 병렬 제작하고(Chapter 2), Phase 2(S3 + CloudFront)를 붙여 공개 URL을 엽니다.
서브에이전트를 사용해서 frontend/index.html 단일 파일 SPA를 만들어줘. 요구사항:
- 다크 테마(#12181F 배경, #FF9900 포인트), 제목 "Claude Ops Center"
- 상단 통계 카드 3개: EVENTS 수 / TOTAL COST(USD) / TOTAL TURNS
- /events 를 5초마다 폴링해 카드와 최근 이벤트 피드 갱신, cost 막대 캔버스 차트
- /ask 로 질문을 POST하는 입력창과 답변 영역 (응답의 model, usage 표시)
- const API_BASE = "PASTE_API_URL" 상수로 시작하고, fetch 실패는 조용히 무시(빈 상태 유지)
/superpowers:execute-plan Phase 2(S3 + CloudFront + BucketDeployment)만 구현해줘. frontend/ 폴더를 배포 소스로 사용해.
sed -i "s|PASTE_API_URL|$API|" frontend/index.html
set -a; source ~/capstone/.env; set +a # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
SITE=$(jq -r '.OpsCenterStack.SiteUrl' cdk-outputs.json) && echo "대시보드: $SITE"
브라우저에서 SiteUrl을 열면 카드 3개와 M1에서 넣은 "first blood" 이벤트가 보여야 합니다. CloudFront 첫 전파는 몇 분 걸릴 수 있으니, 그 사이 M3를 먼저 시작하세요.
막힐 때 열어보기, 완성본 (frontend/index.html)
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Ops Center</title>
<style>
:root { --bg:#12181F; --card:#1B242E; --line:#2A3542; --text:#E8ECF1; --muted:#8B96A5; --accent:#FF9900; --purple:#7a3ff2; --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:960px; margin:0 auto; }
header h1 { font-size:24px; } header .sub { color:var(--muted); font-size:13px; margin-top:4px; }
header h1 em { color:var(--accent); font-style:normal; }
.cards { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; margin:22px 0; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px 18px; }
.card .k { color:var(--muted); font-size:12px; letter-spacing:.08em; }
.card .v { font-size:26px; font-weight:700; margin-top:6px; }
.card .v.orange { color:var(--accent); } .card .v.purple { color:#c9a2ff; } .card .v.green { color:var(--green); }
.panel { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:18px; margin-bottom:16px; }
.panel h2 { font-size:14px; color:var(--muted); letter-spacing:.06em; margin-bottom:12px; }
canvas { width:100%; height:120px; }
ul#feed { list-style:none; max-height:220px; overflow:auto; }
ul#feed li { padding:8px 4px; border-bottom:1px solid var(--line); font-size:13.5px; display:flex; gap:10px; }
ul#feed .kind { color:var(--accent); min-width:110px; font-weight:600; }
ul#feed .at { color:var(--muted); margin-left:auto; font-size:12px; }
.askrow { display:flex; gap:10px; }
input#q { flex:1; background:#0C1117; border:1px solid var(--line); border-radius:8px; color:var(--text); padding:12px; font-size:14px; }
button { background:var(--purple); border:0; border-radius:8px; color:#fff; padding:0 22px; font-weight:700; cursor:pointer; }
button:disabled { opacity:.5; }
#answer { margin-top:12px; font-size:14px; line-height:1.7; white-space:pre-wrap; }
#answer .meta { color:var(--muted); font-size:12px; margin-top:6px; }
.empty { color:var(--muted); font-size:13px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1><em>Claude</em> Ops Center</h1>
<div class="sub">내 Claude Code 세션 관측 대시보드 - Capstone Lab</div>
</header>
<div class="cards">
<div class="card"><div class="k">EVENTS</div><div class="v orange" id="stat-count">-</div></div>
<div class="card"><div class="k">TOTAL COST (USD)</div><div class="v purple" id="stat-cost">-</div></div>
<div class="card"><div class="k">TOTAL TURNS</div><div class="v green" id="stat-turns">-</div></div>
</div>
<div class="panel">
<h2>SESSION COST TIMELINE</h2>
<canvas id="chart" width="900" height="120"></canvas>
</div>
<div class="panel">
<h2>EVENT FEED (5초 폴링)</h2>
<ul id="feed"><li class="empty">아직 이벤트가 없습니다. Mission 4의 Hook이 연결되면 여기에 세션이 쌓입니다.</li></ul>
</div>
<div class="panel">
<h2>ASK BEDROCK (Global Inference)</h2>
<div class="askrow">
<input id="q" placeholder="예: 방금 배포한 아키텍처를 한 문장으로 설명해줘">
<button id="askbtn">Ask</button>
</div>
<div id="answer"></div>
</div>
</div>
<script>
"use strict";
var API_BASE = "PASTE_API_URL"; // 예: https://abc123.execute-api.ap-northeast-2.amazonaws.com
function fmt(n, d) { return Number(n || 0).toFixed(d); }
function drawChart(items) {
var c = document.getElementById("chart");
var ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
var data = items.slice(0, 24).reverse().map(function (x) { return x.cost_usd || 0; });
if (!data.length) return;
var max = Math.max.apply(null, data.concat([0.001]));
var bw = c.width / data.length;
data.forEach(function (v, i) {
var h = Math.max(3, (v / max) * (c.height - 10));
ctx.fillStyle = "#7a3ff2";
ctx.fillRect(i * bw + 3, c.height - h, bw - 6, h);
});
}
function refresh() {
fetch(API_BASE + "/events")
.then(function (r) { return r.json(); })
.then(function (d) {
document.getElementById("stat-count").textContent = d.summary.count;
document.getElementById("stat-cost").textContent = "$" + fmt(d.summary.total_cost_usd, 4);
document.getElementById("stat-turns").textContent = d.summary.total_turns;
var feed = document.getElementById("feed");
if (d.items.length) {
feed.innerHTML = d.items.map(function (x) {
return '<li><span class="kind">' + x.kind + '</span><span>' + (x.detail || "") +
'</span><span class="at">' + (x.at || "").slice(11, 19) + "</span></li>";
}).join("");
}
drawChart(d.items);
})
.catch(function () { /* API 미연결 상태 허용 */ });
}
document.getElementById("askbtn").addEventListener("click", function () {
var q = document.getElementById("q").value.trim();
if (!q) return;
var btn = this, ans = document.getElementById("answer");
btn.disabled = true; ans.textContent = "생각 중...";
fetch(API_BASE + "/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: q })
})
.then(function (r) { return r.json(); })
.then(function (d) {
ans.innerHTML = "";
var t = document.createElement("div"); t.textContent = d.answer || d.message || "(응답 없음)";
var m = document.createElement("div"); m.className = "meta";
m.textContent = d.model ? d.model + " / in " + d.usage.inputTokens + " out " + d.usage.outputTokens : "";
ans.appendChild(t); ans.appendChild(m);
})
.catch(function (e) { ans.textContent = "오류: " + e.message; })
.finally(function () { btn.disabled = false; });
});
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
AI /ask, Global Inference Profile 연결
25분
Bedrock Converse를 호출하는 /ask를 붙입니다. Chapter 3에서 읽기만 했던
IAM 정책이 여기서 실제로 배포됩니다. 모델은 컨텍스트 변수 하나로 교체됩니다.
| 모델 | Global Inference Profile ID | 선택 |
|---|---|---|
| Claude Sonnet 4.6 | global.anthropic.claude-sonnet-4-6 | 기본값, 속도/비용 균형 |
| Claude Sonnet 5 | global.anthropic.claude-sonnet-5 | deploy 시 -c askModelId=... 로 교체 |
/superpowers:execute-plan Phase 3을 구현해줘. 조건:
- lambda/ask.mjs: @aws-sdk/client-bedrock-runtime 의 ConverseCommand 사용, 환경변수 ASK_MODEL_ID(기본 global.anthropic.claude-sonnet-4-6), maxTokens 600, CORS 헤더, 오류는 502 JSON
- 스택: /ask POST 라우트 추가, Lambda timeout 29초. Bedrock IAM 정책은 만들지 말 것.
대신 Lambda environment에 AWS_BEARER_TOKEN_BEDROCK: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "" 를 주입
(SDK가 이 환경변수를 자동 인식해 API 키로 인증한다)
set -a; source ~/capstone/.env; set +a # Bearer 키를 배포 셸에 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json
curl -s -X POST "$API/ask" -H 'Content-Type: application/json' \
-d '{"question":"HTTP API와 REST API의 차이를 한 문장으로"}' | jq '{answer, model, usage}'
AWS_BEARER_TOKEN_BEDROCK을 읽어 Lambda에 넣으므로,
새 터미널에서 deploy할 때는 항상 set -a; source ~/capstone/.env; set +a를 먼저 실행하세요.
빈 값으로 배포되면 /ask가 401을 반환합니다, 로드 후 재배포가 처방입니다.
{
"answer": "HTTP API는 경량/저비용의 v2 게이트웨이고, REST API는 기능이 풍부한 v1 게이트웨이입니다.",
"model": "global.anthropic.claude-sonnet-4-6",
"usage": { "inputTokens": 64, "outputTokens": 51, "totalTokens": 115 }
}
브라우저 대시보드의 Ask 패널에서도 같은 질문을 던져 보세요.
Sonnet 5로 바꾸려면 npx cdk deploy -c askModelId=global.anthropic.claude-sonnet-5 --require-approval never 한 줄입니다.
ValidationException(on-demand 미지원) = 베이스 ID 사용, global. 프로파일로 교체.
② AccessDeniedException = IAM 리소스 2종(foundation-model + inference-profile) 중 하나 누락이거나 모델 액세스 미승인.
③ 30초 근처 타임아웃 = HTTP API 상한. maxTokens 600을 지키고, 더 긴 답이 필요하면 Lambda Function URL로 우회(부록 트러블슈팅).
막힐 때 열어보기, 완성본 (lambda/ask.mjs)
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
const client = new BedrockRuntimeClient({});
const MODEL_ID = process.env.ASK_MODEL_ID ?? "global.anthropic.claude-sonnet-4-6";
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST,OPTIONS",
};
export const handler = async (event) => {
const method = event.requestContext?.http?.method ?? "POST";
if (method === "OPTIONS") return { statusCode: 204, headers: CORS };
try {
const { question } = JSON.parse(event.body ?? "{}");
if (!question) return { statusCode: 400, headers: CORS, body: JSON.stringify({ error: "question 필요" }) };
const res = await client.send(new ConverseCommand({
modelId: MODEL_ID,
system: [{ text: "당신은 이 워크샵 참가자의 AWS/Claude Code 조수입니다. 3문장 이내로 한국어로 답하세요." }],
messages: [{ role: "user", content: [{ text: String(question).slice(0, 2000) }] }],
inferenceConfig: { maxTokens: 600 },
}));
const answer = res.output?.message?.content?.map((c) => c.text ?? "").join("") ?? "";
const usage = res.usage ?? {};
return {
statusCode: 200, headers: CORS,
body: JSON.stringify({ answer, model: MODEL_ID, usage }),
};
} catch (err) {
console.error(err);
return { statusCode: 502, headers: CORS, body: JSON.stringify({ error: err.name, message: err.message }) };
}
};
자기관측 루프, 도구가 자신을 보기 시작한다
20분Chapter 3~4의 Hook을 달아 지금 이 세션의 활동이 방금 배포한 대시보드로 흐르게 만들고, Chapter 5의 헤드리스 JSON으로 비용 차트를 채웁니다. 캡스톤의 WoW 순간입니다.
mkdir -p .claude/hooks
cat > .claude/hooks/ops-report.sh << 'OPSEOF'
#!/bin/bash
# PostToolUse + Stop 겸용 hook: 활동 이벤트를 Ops Center로 전송 (비차단)
INPUT=$(cat)
[ -z "${OPS_API_BASE:-}" ] && exit 0
if command -v jq >/dev/null 2>&1; then
EVT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"')
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
SID=$(echo "$INPUT" | jq -r '.session_id // ""' | cut -c1-8)
else
EVT=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('hook_event_name','unknown'))" 2>/dev/null)
TOOL=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null)
SID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','')[:8])" 2>/dev/null)
fi
if [ "$EVT" = "PostToolUse" ]; then
KIND="tool"; DETAIL="$TOOL"
else
KIND="claude-stop"; DETAIL="session $SID"
fi
curl -s -m 3 -X POST "$OPS_API_BASE/events" \
-H "Content-Type: application/json" \
-d "{\"kind\":\"$KIND\",\"detail\":\"$DETAIL\",\"turns\":0,\"cost_usd\":0}" \
>/dev/null 2>&1 || true
exit 0
OPSEOF
chmod +x .claude/hooks/ops-report.sh
.claude/settings.json에 hooks를 추가해줘. PostToolUse는 matcher "Bash|Edit|Write|Read|Grep|Glob", Stop은 matcher 없이, 둘 다 command는 "$CLAUDE_PROJECT_DIR/.claude/hooks/ops-report.sh" 하나씩이야. 기존 설정은 유지해.
export OPS_API_BASE="$API"
claude
README.md에 이 프로젝트 한 줄 소개를 추가해줘
브라우저 대시보드를 옆에 두고 위 프롬프트를 실행하세요. Edit, Bash 같은 tool 이벤트와 claude-stop 펄스가 5초 안에 피드에 등장합니다. 이 순간부터 여러분이 치는 모든 턴이 여러분이 만든 서비스에 기록됩니다. 여러분만의 관측 루프가 조용히 돌기 시작했습니다.
cat > ops-track.sh << 'TRKEOF'
#!/bin/bash
# 사용: ./ops-track.sh "프롬프트" - 헤드리스 실행 결과와 비용을 Ops Center에 기록
set -euo pipefail
Q="${1:?Usage: ./ops-track.sh \"프롬프트\"}"
OUT=$(claude -p "$Q" --output-format json)
echo "$OUT" | jq -r '.result'
COST=$(echo "$OUT" | jq -r '.total_cost_usd // 0')
TURNS=$(echo "$OUT" | jq -r '.num_turns // 0')
DETAIL=$(printf '%s' "$Q" | head -c 60 | tr '"' "'")
curl -s -m 3 -X POST "${OPS_API_BASE:?OPS_API_BASE 필요}/events" \
-H "Content-Type: application/json" \
-d "{\"kind\":\"headless\",\"detail\":\"$DETAIL\",\"turns\":$TURNS,\"cost_usd\":$COST}" \
>/dev/null && echo "[ops] 기록됨: \$$COST, ${TURNS} turns"
TRKEOF
chmod +x ops-track.sh
./ops-track.sh "이 프로젝트 아키텍처를 한 문장으로 요약해줘"
mkdir -p .claude/skills/ship
cat > .claude/skills/ship/SKILL.md << 'SHIPEOF'
---
description: 캡스톤 원커맨드 배포. synth 검증, deploy, smoke 테스트, 개장 보고까지 수행한다.
disable-model-invocation: true
argument-hint: "[참가자이름]"
allowed-tools: Bash(npx cdk *) Bash(curl *) Bash(aws cloudformation *)
---
캡스톤 스택을 출항시키세요. 참가자 이름: $ARGUMENTS
1. `npx cdk synth --quiet` 로 합성 검증. 실패하면 원인만 보고하고 멈추세요.
2. `set -a; source ~/capstone/.env; set +a && npx cdk deploy --require-approval never --outputs-file cdk-outputs.json` 실행 (Bearer 키 로드 포함).
3. cdk-outputs.json에서 ApiUrl과 SiteUrl을 읽으세요.
4. Smoke 테스트: `curl -s $ApiUrl/events` 가 JSON을 반환하고, `curl -s -X POST $ApiUrl/events -H "Content-Type: application/json" -d '{"kind":"ship","detail":"smoke"}'` 가 201을 반환해야 합니다.
5. 최종 보고: SiteUrl, ApiUrl, smoke 결과를 표로 출력하고, 셀프 방문에 쓸 두 URL(SiteUrl, ApiUrl)을 한 줄로 안내하세요.
SHIPEOF
echo "스킬 설치됨. 새 세션에서 /ship 사용 가능"
착륙, 원커맨드 재배포와 셀프 검증
15분
/ship 한 번으로 synth → deploy → smoke까지 완주한 뒤,
이번에는 여러분이 첫 손님이 됩니다. 터미널에서 내 옵스 센터에 방문 이벤트를 남기고,
대시보드 피드에 방명록이 실시간으로 떠오르는 순간이 착륙 신호입니다.
/ship 홍길동
| 항목 | 값 |
| SiteUrl | https://dxxxx.cloudfront.net |
| ApiUrl | https://xxxx.execute-api.ap-northeast-2.amazonaws.com |
| Smoke | GET 200 / POST 201 |
셀프 방문용: https://dxxxx.cloudfront.net + ApiUrl
대시보드(SiteUrl)를 브라우저 한 창에 열어 두면, 터미널이 손님 역할을 합니다. 아래 방문 카드에서 문구를 골라 방명록을 남겨 보세요.
curl -s -X POST "$API/events" -H 'Content-Type: application/json' \
-d '{"kind":"visit","detail":"오늘의 첫 손님입니다, 개장을 축하해요"}'
3초 안에 대시보드 피드에 visit 이벤트가 떠오릅니다. 세션 안 훅이 아니라 외부 HTTP 손님을 여러분의 관측 루프가 기록한 첫 순간입니다. SiteUrl의 /ask 패널에도 질문 한 줄을 남겨 Bedrock 응답까지 확인하면 개장 점검 끝입니다.
| # | 방문 카드 | 관찰 포인트 |
|---|---|---|
| 1 | {"kind":"visit","detail":"오늘의 첫 손님입니다"} | 기본 방명록, 피드 도착 3초 |
| 2 | {"kind":"cheer","detail":"개장 축하"} | 피드가 미지의 kind를 어떻게 그리는지 |
| 3 | SiteUrl의 /ask에 "이 시스템 아키텍처를 한 문단으로 요약해줘" | Bedrock 질의 패널 왕복 |
curl -s "$API/events" | jq '.summary' # 오늘 기록된 나의 활동/비용 합계 (방문객 포함)
git add -A && git commit -m "feat: claude ops center capstone"
# 실습을 마쳤다면 비용 정리 (이어서 볼 계획이면 유지):
# npx cdk destroy --force
project=capstone-ops-center가 걸려 있어 Cost Explorer에서 태그 필터로
캡스톤 비용만 분리 조회할 수 있습니다. Bedrock 호출량은 Chapter 3의 OTel 파이프라인을 붙이면 조직 단위로 확장됩니다.
하네스 엔지니어링, 작품을 성숙시키고 등급을 받다
+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 .
기존 프로젝트 감지 모드로 동작합니다. 이 캡스톤에서 손으로 만든 자기관측 훅(ops-report, ops-track)과 /ship 스킬 같은 기존 하네스는 보존하고, 없는 것(문서 스캐폴딩, 시크릿 스캔 훅, 테스트 프레임워크, /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}}
여러분의 옵스 센터에는 이미 자기관측 훅과 /ship 스킬이 있습니다. 하네스의 절반을 이 캡스톤에서 손으로 만든 셈이고, 평가는 그 절반이 몇 점짜리인지 숫자로 알려줍니다. 여유가 있다면 이 캡스톤에서 실제로 내린 결정을 기록으로 남기세요:
/add-adr bedrock-global-inference
/add-runbook ship-and-observe
컷라인과 트러블슈팅
비상용90분 경과 시점 기준으로 판단하세요. 컷은 순서대로 적용하고, 어떤 경우에도 M4의 관측 루프와 M5의 셀프 검증은 지킵니다.
| 컷 | 조치 | 절약 |
|---|---|---|
| CUT 1 | M2 프론트를 뒤로 미루고 M3까지 curl로만 진행, 프론트는 M4 전에 완성본으로 배치 | ~15분 |
| CUT 2 | M3 모델 교체 실험 생략, Sonnet 4.6 기본값 고정 | ~5분 |
| CUT 3 | M4의 ops-track/비용 차트 생략, Hook 피드만 (WoW 유지) | ~7분 |
| 증상 | 원인 | 처방 |
|---|---|---|
| bootstrap 실패 | 권한/기존 CDKToolkit 충돌 | tail /tmp/bootstrap.log, 기존 스택 확인 후 재실행 |
| ValidationException (on-demand) | 베이스 모델 ID 사용 | global. 프로파일 ID로 교체 (M3 표) |
| AccessDeniedException | 모델 액세스 미승인 또는 IAM 리소스 누락 | Bedrock 콘솔 승인 + foundation-model / inference-profile 2종 확인 |
| /ask 30초 타임아웃 | HTTP API 통합 상한 | maxTokens 축소, 필요 시 ask에 Function URL 부여로 우회 |
| 대시보드 빈 화면 | CloudFront 전파 중 또는 API_BASE 미교체 | 수 분 대기, index.html의 API_BASE 확인 후 재배포 |
| 피드에 이벤트 없음 | OPS_API_BASE 미설정 상태로 claude 기동 | export 후 claude 재시작, curl로 API 직접 확인 |
| 명령이 다른 폴더에서 실패 | superpowers worktree 이동 | pwd 확인, worktree 경로에서 재실행 |
마무리, 챕터 소환 지도
| 캡스톤 장면 | 소환된 챕터 | 핵심 역량 |
|---|---|---|
| brainstorm → plan 승인 | Ch1 + superpowers | 계획 우선, CLAUDE.md, Plan 사고방식 |
| 프론트 병렬 빌드 | Ch2 | 서브에이전트 위임과 병렬성 |
| Bedrock IAM 2종 리소스, 비용 태그 | Ch3 | 자격증명, 최소 권한, 거버넌스 |
| Hook 등록, /ship 스킬 | Ch4 | Hooks, 커맨드 스킬, 에셋 패키징 |
| curl 왕복, jq 파싱, ops-track | Ch5 | 헤드리스, JSON 스키마, exit 게이트 |