Capstone Hands-on Lab / Chapter 1-5 통합

Mission: Ship It, 나의 Claude Ops Center를 배포하라

Claude Code Deep Dive Workshop, Capstone - AWS Full Stack 실전 (135분 목표 / 150분 상한)

지난 5개 챕터의 학습을 전부 소환하는 캡스톤입니다. 각자 superpowers 플러그인의 brainstorm → plan → execute 워크플로로 공개 URL로 접속되는 풀스택 서비스를 배포합니다. 서비스의 정체는 "내 Claude Code 세션을 관측하는 대시보드 + Bedrock 질의 패널", 즉 랩 내내 쓰는 도구가 자기 자신을 관측하게 만드는 앱입니다. 완주하면 두 번째 창의 손님이 되어 내 옵스 센터에 방문 이벤트를 남기고, 관측 루프가 외부 요청까지 기록하는 것을 확인합니다.

환경 Claude Code on EC2 + Admin 계정 리전 ap-northeast-2 모델 Sonnet 4.6 (Global CRIS) Update 2026.07
MISSION BRIEFING

무엇을, 어떻게 만드는가

최종 산출물은 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)
규칙 1 - 코드와 IaC는 전부 Claude에게 시켜서 만든다 (superpowers 워크플로) 규칙 2 - 각 미션은 Definition of Done으로만 판정한다 규칙 3 - 막히면 "막힐 때 열어보기" 완성본으로 복구하고 전진한다

시간 배분은 130분 설계 + 20분 버퍼입니다. 90분 경과 시점에 M3를 시작하지 못했다면 부록 컷라인을 적용하세요.

MISSION 00

발사 준비, superpowers 장착과 설계 승인

20분

환경을 점검하고, superpowers를 설치한 뒤, 제공된 요구사항으로 brainstorm → plan을 돌려 4-Phase 실행 계획을 승인합니다. CDK bootstrap은 백그라운드로 겹칩니다.

1사전 점검, Bedrock smoke까지 한 번에
Terminal, 전체 복사
aws sts get-caller-identity --query Account --output text
node -v && git --version && jq --version
npx cdk --version

Bedrock 호출은 다른 계정에서 발급받은 Bedrock API 키(Bearer 토큰)로 인증합니다. SigV4 자격증명 없이 환경변수 하나면 됩니다. 세 단계로 스모크 테스트합니다.

Terminal 1/3, .env에 Bearer 토큰 저장
mkdir -p ~/capstone
cat > ~/capstone/.env << 'ENVEOF'
AWS_BEARER_TOKEN_BEDROCK=<발급받은 Bedrock API 키>
ENVEOF

저장 후 파일을 열어 <발급받은 Bedrock API 키> 자리를 실제 키로 바꾸세요. 리포 바깥(~/capstone/.env)에 두므로 git add -A에 딸려 커밋될 일이 원천적으로 없습니다.

Terminal 2/3, 셸에 로드 (값 노출 없이)
set -a; source ~/capstone/.env; set +a
Terminal 3/3, Converse API 스모크
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
smoke가 실패한다면 401 또는 UnrecognizedClientException은 키 오탈자나 만료입니다, ~/capstone/.env를 고치고 2/3 로드를 다시 실행하세요. AccessDeniedException은 발급 계정의 모델 액세스 미승인, ValidationException(on-demand 미지원)은 베이스 모델 ID를 쓴 경우입니다. 신형 모델은 반드시 global. 프로파일 ID로 호출합니다. Bearer 키는 Bedrock 호출 전용이고, CDK 배포는 기존 EC2 롤(SigV4)을 그대로 씁니다. 여기서 막히면 부록 트러블슈팅의 모델 항목을 확인하세요. 이 관문만 넘으면 M3는 순항입니다.
2프로젝트 스캐폴드와 bootstrap (백그라운드)
Terminal, 전체 복사
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 신원 설정은 실수 커밋과 신원 경고를 예방하는 안전벨트입니다.

3superpowers 설치
Claude 세션 입력 (claude 실행 후)
/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를 사용하세요. 결과는 동일합니다.
4브레인스토밍, 요구사항은 이걸 그대로
Claude 세션 입력, 전체 복사
/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 세션에 auto mode on을 선언해 두면 superpowers가 brainstorming → write-plan → execute-plan의 단계 전환과 승인 대기를 자동으로 이어 갑니다. 시간이 빠듯할 때 유용합니다. 단계마다 직접 검토하려면 기본(수동)을 유지하고, 되돌리려면 auto mode off입니다.

브레인스토밍 질문에는 이 치트시트로 답하세요: 인증 없음(퍼블릭 데모), 데이터 보존 불필요, UI는 다크 테마 + 오렌지 포인트. 3~4문답이면 설계가 수렴합니다.

worktree 순간이동 주의 superpowers는 브레인스토밍이 끝나면 git worktree를 만들어 그 디렉토리로 이동합니다. 이후 모든 터미널 명령(deploy, curl)은 pwd로 확인한 그 worktree 경로에서 실행하세요. bootstrap은 계정 단위 작업이라 영향이 없습니다.
5계획 생성과 승인
Claude 세션 입력
/superpowers:write-plan

생성된 계획 문서에서 Phase가 4개로 나뉘었는지, Phase 1의 산출물에 DynamoDB 테이블 / events Lambda / HTTP API 라우트가 들어 있는지 검토하고 승인하세요. 계획이 다르게 나뉘었으면 "요구사항 5)의 Phase 구분으로 재편해줘"라고 지시합니다.

DEFINITION OF DONE
MISSION 01

코어 스택, 첫 배포와 첫 왕복

30분

Phase 1(DynamoDB + events Lambda + HTTP API)을 실행시키고 배포해, curl로 넣은 이벤트가 다시 조회되는 왕복을 확인합니다. Chapter 5의 헤드리스 어휘가 여기서 일상어가 됩니다.

1Phase 1 실행 지시
Claude 세션 입력
/superpowers:execute-plan 계획의 Phase 1만 구현하고 멈춰줘. 구현이 끝나면 npx cdk synth --quiet 로 합성 검증까지 하고 결과를 보고해.
superpowers가 테스트를 쓰려고 하면 요구사항 6)을 상기시키세요: "이 프로젝트는 smoke curl로 검증을 갈음하기로 했어". TDD 강제는 superpowers의 미덕이지만, 오늘은 시간이 상사입니다.
2배포
Terminal, worktree에서
set -a; source ~/capstone/.env; set +a   # Bearer 키 로드
npx cdk deploy --require-approval never --outputs-file cdk-outputs.json

첫 배포는 3~5분입니다. 기다리지 말고 지금 바로 M2의 Step 1(프론트 병렬 빌드)을 시작하세요. 이 겹침이 캡스톤 시간표의 핵심입니다.

3첫 왕복 검증
Terminal, 전체 복사
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은 두 번째 블록).

lib/ops-center-stack.ts
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 });
  }
}
bin/ops-center.ts (cdk init 산출물 교체)
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" },
});
lambda/events.mjs
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 }) };
  }
};
DEFINITION OF DONE
MISSION 02

프론트 병렬 빌드, 대시보드 개장

20분

M1 배포가 도는 동안 서브에이전트로 SPA를 병렬 제작하고(Chapter 2), Phase 2(S3 + CloudFront)를 붙여 공개 URL을 엽니다.

1병렬 빌드 지시 (M1 Step 2와 동시에)
Claude 세션 입력, 전체 복사
서브에이전트를 사용해서 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 실패는 조용히 무시(빈 상태 유지)
2Phase 2 실행과 API 주소 주입
Claude 세션 입력
/superpowers:execute-plan Phase 2(S3 + CloudFront + BucketDeployment)만 구현해줘. frontend/ 폴더를 배포 소스로 사용해.
Terminal, 전체 복사
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)
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>
DEFINITION OF DONE
MISSION 03

AI /ask, Global Inference Profile 연결

25분

Bedrock Converse를 호출하는 /ask를 붙입니다. Chapter 3에서 읽기만 했던 IAM 정책이 여기서 실제로 배포됩니다. 모델은 컨텍스트 변수 하나로 교체됩니다.

모델Global Inference Profile ID선택
Claude Sonnet 4.6global.anthropic.claude-sonnet-4-6기본값, 속도/비용 균형
Claude Sonnet 5global.anthropic.claude-sonnet-5deploy 시 -c askModelId=... 로 교체
1Phase 3 실행 지시
Claude 세션 입력, 전체 복사
/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 키로 인증한다)
2배포와 검증
Terminal, 전체 복사
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}'
이후의 모든 deploy는 .env 로드가 선행 스택이 배포 셸의 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 한 줄입니다.

이 미션의 3대 함정ValidationException(on-demand 미지원) = 베이스 ID 사용, global. 프로파일로 교체. ② AccessDeniedException = IAM 리소스 2종(foundation-model + inference-profile) 중 하나 누락이거나 모델 액세스 미승인. ③ 30초 근처 타임아웃 = HTTP API 상한. maxTokens 600을 지키고, 더 긴 답이 필요하면 Lambda Function URL로 우회(부록 트러블슈팅).
막힐 때 열어보기, 완성본 (lambda/ask.mjs)
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 }) };
  }
};
DEFINITION OF DONE
MISSION 04

자기관측 루프, 도구가 자신을 보기 시작한다

20분

Chapter 3~4의 Hook을 달아 지금 이 세션의 활동이 방금 배포한 대시보드로 흐르게 만들고, Chapter 5의 헤드리스 JSON으로 비용 차트를 채웁니다. 캡스톤의 WoW 순간입니다.

1관측 Hook 배치
Terminal, 전체 복사 (worktree에서)
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
2settings에 등록
Claude 세션 입력
.claude/settings.json에 hooks를 추가해줘. PostToolUse는 matcher "Bash|Edit|Write|Read|Grep|Glob", Stop은 matcher 없이, 둘 다 command는 "$CLAUDE_PROJECT_DIR/.claude/hooks/ops-report.sh" 하나씩이야. 기존 설정은 유지해.
3루프 점화
Terminal, 전체 복사
export OPS_API_BASE="$API"
claude
Claude 세션 입력, 아무 작업이나
README.md에 이 프로젝트 한 줄 소개를 추가해줘

브라우저 대시보드를 옆에 두고 위 프롬프트를 실행하세요. Edit, Bash 같은 tool 이벤트와 claude-stop 펄스가 5초 안에 피드에 등장합니다. 이 순간부터 여러분이 치는 모든 턴이 여러분이 만든 서비스에 기록됩니다. 여러분만의 관측 루프가 조용히 돌기 시작했습니다.

4비용 차트 채우기 + /ship 스킬
Terminal, 전체 복사
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 "이 프로젝트 아키텍처를 한 문장으로 요약해줘"
Terminal, /ship 스킬 설치
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 사용 가능"
왜 비용은 Hook이 아니라 헤드리스로 채우나 Stop hook의 stdin에는 비용 필드가 없습니다. 검증 가능한 필드(session_id, tool_name)는 Hook으로 피드를 만들고, 비용/턴은 헤드리스 결과 JSON(total_cost_usd, num_turns)에서 가져오는 것이 정직한 설계입니다. Chapter 5 Task 1의 재활용입니다.
DEFINITION OF DONE
MISSION 05

착륙, 원커맨드 재배포와 셀프 검증

15분

/ship 한 번으로 synth → deploy → smoke까지 완주한 뒤, 이번에는 여러분이 첫 손님이 됩니다. 터미널에서 내 옵스 센터에 방문 이벤트를 남기고, 대시보드 피드에 방명록이 실시간으로 떠오르는 순간이 착륙 신호입니다.

1출항
Claude 세션 입력 (새 세션, 본인 이름으로)
/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
2개장 셀프 방문, 내가 첫 손님

대시보드(SiteUrl)를 브라우저 한 창에 열어 두면, 터미널이 손님 역할을 합니다. 아래 방문 카드에서 문구를 골라 방명록을 남겨 보세요.

Terminal, 전체 복사
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를 어떻게 그리는지
3SiteUrl의 /ask에 "이 시스템 아키텍처를 한 문단으로 요약해줘"Bedrock 질의 패널 왕복
3정산과 정리
Terminal, 전체 복사
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 파이프라인을 붙이면 조직 단위로 확장됩니다.
DEFINITION OF DONE
OPTION

하네스 엔지니어링, 작품을 성숙시키고 등급을 받다

+15분 옵션

지금까지 만든 것은 결과물입니다. 이 옵션은 그 결과물을 만든 작업 환경, 즉 하네스 (CLAUDE.md, hooks, skills, commands, agents)를 표준 구조로 성숙시키고(project-init), 그 품질을 6개 차원(정확성, 안전성, 완전성, 실행 가능성, 일관성, 검증 가능성)에서 채점받는(harness-eval) 단계입니다. superpowers의 3단(brainstorm, write-plan, execute-plan) 뒤에 4단 성숙화와 5단 평가를 붙이는 셈입니다.

1플러그인 2종 설치
Terminal, 전체 복사
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 때와 같은 규칙입니다.

2성숙화, /project-init:init-project와 /project-init:sync-docs
Claude 세션 입력 (새 세션, worktree에서)
/project-init:init-project .

기존 프로젝트 감지 모드로 동작합니다. 이 캡스톤에서 손으로 만든 자기관측 훅(ops-report, ops-track)과 /ship 스킬 같은 기존 하네스는 보존하고, 없는 것(문서 스캐폴딩, 시크릿 스캔 훅, 테스트 프레임워크, /review와 /test-all 커맨드 등)만 채웁니다. CLAUDE.md가 이미 있으면 덮어쓰기 전에 물어봅니다. 끝나면 이어서:

Claude 세션 입력
/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
3평가, /harness-eval로 등급 받기
Claude 세션 입력
/harness-eval:quick
출력 예시
{"mode": "quick", "scores": {"overall": 7.2, "grade": "B"}, "checklist": {"pass": 13, "warn": 2, "fail": 1}}

여러분의 옵스 센터에는 이미 자기관측 훅과 /ship 스킬이 있습니다. 하네스의 절반을 이 캡스톤에서 손으로 만든 셈이고, 평가는 그 절반이 몇 점짜리인지 숫자로 알려줍니다. 여유가 있다면 이 캡스톤에서 실제로 내린 결정을 기록으로 남기세요:

Claude 세션 입력
/add-adr bedrock-global-inference
/add-runbook ship-and-observe
시간과 신뢰 가드 잔여 시간이 빠듯하면 설치와 /harness-eval:quick(30초 체크리스트)만으로 충분합니다, /project-init:init-project는 17단계라 수 분이 걸립니다. /harness-eval:standard의 동적 분석은 대상 프로젝트의 훅과 테스트를 실제로 실행합니다. 방금 본인이 만든 프로젝트라 안전하지만, 남의 저장소를 평가할 때는 --static-only 플래그가 예의이자 안전입니다.
OPTION DONE, 진행률 미집계
APPENDIX

컷라인과 트러블슈팅

비상용

90분 경과 시점 기준으로 판단하세요. 컷은 순서대로 적용하고, 어떤 경우에도 M4의 관측 루프와 M5의 셀프 검증은 지킵니다.

조치절약
CUT 1M2 프론트를 뒤로 미루고 M3까지 curl로만 진행, 프론트는 M4 전에 완성본으로 배치~15분
CUT 2M3 모델 교체 실험 생략, Sonnet 4.6 기본값 고정~5분
CUT 3M4의 ops-track/비용 차트 생략, Hook 피드만 (WoW 유지)~7분
T트러블슈팅 표
증상원인처방
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 스킬Ch4Hooks, 커맨드 스킬, 에셋 패키징
curl 왕복, jq 파싱, ops-trackCh5헤드리스, JSON 스키마, exit 게이트
NEXT

Chapter 6 - Agent SDK

오늘 ask Lambda는 Bedrock을 한 번 호출하는 함수였습니다. Chapter 6에서는 같은 자리를 Agent SDK로 바꿔, 도구를 쓰고 멀티턴으로 일하는 에이전트 서비스로 진화시킵니다. 오늘의 스택이 그대로 실습 무대가 됩니다.