AI에게 게임을 만들게 하는 법 — 하네스 엔지니어링 실전 가이드

이 글에서 설명하는 방법으로 실제로 만든 게임 에디터의 소스 코드:
github.com/urstory/pixel-quest
— 7개 기능, 142개 테스트, 사람이 직접 작성한 코드 0줄
"2D 레트로 게임 메이커를 만들어줘."
이 한 문장을 Claude에게 그냥 던지면 20분 만에 UI 비슷한 게 나옵니다. 근데 버튼을 누르면 아무 반응이 없고, 스프라이트를 그려도 저장이 안 되고, 플레이 테스트를 누르면 빈 화면만 뜹니다. $9 날렸습니다.
같은 모델에게 같은 프롬프트를 줬는데 하네스를 설계한 뒤 시키면? 6시간 뒤에 레벨 에디터, 스프라이트 에디터, AI 레벨 생성기, 사운드, Export까지 갖춘 게임이 작동합니다. $200이 들었지만, 실제로 작동하는 게임이 나왔습니다. (Anthropic의 2026년 3월 실험 결과입니다.)
이 글은 그 "하네스 설계"를 실제로 어떻게 하는지를 다룹니다. 이론 소개가 아니라, 코드와 설정 파일과 디렉토리 구조를 보면서 따라갈 수 있는 실전 가이드입니다.
하네스(harness)가 무엇인지 먼저 한마디로 정리하면, AI 모델을 둘러싼 작업 환경 전체입니다. 어떤 정보를 주고, 어떤 도구를 쥐어주고, 결과물을 어떻게 검증하고, 언제 사람이 개입하는지. 프롬프트를 잘 쓰는 게 아니라 모델이 잘 일할 수 있는 환경을 만드는 것, 그게 하네스 엔지니어링입니다. 개념이 처음이라면 이론편 블로그를 먼저 보셔도 좋습니다.

AI 모델을 중심에 두고 컨텍스트, 도구, 실행 루프, 검증, 사람이 둘러싸고 있는 구조입니다. 이 5가지가 하네스의 구성 요소입니다.
시작하기 전에: 이 글에서 만들 것
예시로 사용할 프로젝트는 "Pixel Quest" — 브라우저 기반 2D 플랫포머 게임입니다. 기능 범위는 이렇습니다:
- 타일맵 기반 레벨 에디터
- 캐릭터 스프라이트 에디터 (프레임 애니메이션)
- 엔티티 배치 및 행동 설정 (적, 아이템, NPC)
- 플레이 테스트 모드 (에디터에서 바로 실행)
- 레벨 저장/불러오기
기술 스택: React + Vite (프론트), FastAPI (백엔드), SQLite (저장), Playwright (테스트)
따라하려면 이것들이 필요합니다
- Node.js 18+ / npm — nodejs.org에서 설치
- Python 3.10+ / pip — python.org에서 설치
- Claude Code CLI —
npm install -g @anthropic-ai/claude-code(Anthropic API 키 또는 Claude Max 구독 필요) - Git — 버전 관리에 사용
프로젝트 초기화부터 시작합니다:
mkdir pixel-quest && cd pixel-quest npm create vite@latest frontend -- --template react-ts mkdir -p backend/{api,models,services} mkdir -p tests/{e2e,unit} mkdir -p .harness pip install fastapi uvicorn sqlalchemy cd frontend && npm install && npm install -D playwright @playwright/test vitest npx playwright install # 브라우저 바이너리 설치 cd .. git init
이제부터 이 프로젝트의 하네스를 하나씩 구성해보겠습니다.
1단계: 프로젝트 뼈대 잡기 — CLAUDE.md와 디렉토리 구조
하네스의 출발점은 AI 에이전트가 읽을 프로젝트 설명서입니다. Claude Code에서는 CLAUDE.md, OpenAI Codex에서는 AGENTS.md라고 부르는 파일입니다.
핵심 원칙이 하나 있습니다. OpenAI가 100만 줄 실험에서 정리한 내용인데, "에이전트가 볼 수 없으면 존재하지 않는다." 머릿속에만 있는 설계, 구두로 합의한 규칙은 에이전트에게는 없는 것이나 마찬가지입니다.
Pixel Quest 프로젝트의 CLAUDE.md를 실제로 작성해보겠습니다:
# Pixel Quest — 2D 플랫포머 게임 에디터 ## 프로젝트 개요 브라우저에서 돌아가는 2D 플랫포머 게임 에디터. 레벨을 만들고, 캐릭터를 그리고, 바로 플레이해볼 수 있다. ## 기술 스택 - Frontend: React 19 + Vite + TypeScript - Backend: FastAPI + SQLite - 테스트: Playwright (E2E — 실제 브라우저를 자동으로 조작하는 통합 테스트), Vitest (Vite 기반 단위 테스트 러너) - 스타일: Tailwind CSS ## 디렉토리 구조 frontend/ → React 앱 src/ components/ → UI 컴포넌트 editors/ → 레벨 에디터, 스프라이트 에디터 engine/ → 게임 런타임 엔진 (렌더링, 물리, 입력) stores/ → Zustand 상태 관리 (React 경량 상태 관리 라이브러리) types/ → TypeScript 타입 정의 backend/ → FastAPI 서버 api/ → API 라우트 models/ → SQLAlchemy 모델 (Python ORM — DB를 Python 객체로 다루는 라이브러리) services/ → 비즈니스 로직 tests/ e2e/ → Playwright E2E 테스트 unit/ → Vitest 단위 테스트 ## 레이어 규칙 (위반 시 빌드 실패) - engine/ 은 순수 로직만. React import 금지. - stores/ 는 engine/ 을 호출할 수 있지만, engine/ 은 stores/ 를 모른다. - editors/ 는 stores/ 를 통해서만 상태에 접근. engine/ 직접 호출 금지. - components/ 는 editors/ 를 조합만 한다. 게임 로직 금지. ## 빌드 & 테스트 명령어 - `cd frontend && npm run dev` → 프론트 개발 서버 - `cd backend && uvicorn main:app --reload` → 백엔드 서버 - `npm run test:e2e` → Playwright E2E 테스트 - `npm run test:unit` → Vitest 단위 테스트 - `npm run lint` → ESLint + 레이어 규칙 검사 ## 절대 하지 말 것 - 외부 CDN에서 에셋 다운로드하지 말 것 - engine/ 안에 React 코드 넣지 말 것 - TODO, FIXME, pass 같은 플레이스홀더 남기지 말 것 - 테스트 없이 기능 완료 표시하지 말 것
이 파일이 왜 중요한지, OpenAI의 실험이 힌트를 줍니다. 그들은 88개의 AGENTS.md를 서브시스템별로 따로 작성했고, Codex 에이전트가 단일 프롬프트로 7시간 넘게 집중력을 유지한 사례를 기록했습니다. 핵심은 백과사전이 아니라 목차 형태로 작성하는 것입니다. 100줄 이내로 전체 구조를 보여주고, 세부 사항은 개별 문서로 안내하는 방식입니다.
CLAUDE.md 아래에 세부 문서를 둡니다:
docs/ game-design.md ← "어떤 게임인가" (기획서) RENDERING.md ← 렌더링 파이프라인 규칙 TILEMAP-SPEC.md ← 타일맵 데이터 형식 ENTITY-BEHAVIORS.md ← 엔티티 AI 행동 패턴 정의 API-ROUTES.md ← 백엔드 API 명세
Chroma Research의 Context Rot 연구(18개 모델 테스트)에 따르면, 모든 모델이 컨텍스트가 길어질수록 성능이 떨어집니다. 약 130K 토큰부터는 성능 저하가 눈에 띄게 나타납니다. 그래서 세부 문서를 한꺼번에 넣지 않고, 에이전트가 필요할 때 읽게 하는 구조가 낫습니다.
2단계: 기능 목록을 JSON으로 만들기 — 에이전트의 할 일 목록
다음으로 필요한 것은 feature_list.json입니다. 에이전트의 할 일 목록인데, 단순한 체크리스트가 아니라 각 기능의 검증 단계까지 포함합니다.
왜 Markdown이 아니라 JSON인가? Anthropic의 선행 연구(2025년 11월)에서 나온 교훈으로, 에이전트가 Markdown 목록은 임의로 편집하거나 삭제하는 경우가 있습니다. JSON은 구조가 엄격해서 부적절한 수정이 더 어렵습니다.
Pixel Quest의 feature_list.json 일부를 보면:
[ { "id": "TILE-001", "category": "level-editor", "title": "타일 팔레트에서 타일 선택", "description": "왼쪽 팔레트에서 타일을 클릭하면 선택 상태가 되고, 캔버스에 클릭하면 해당 타일이 배치된다", "acceptance_criteria": [ "팔레트에 최소 16개 타일이 표시된다", "타일 클릭 시 선택 상태 시각적 표시 (테두리 하이라이트)", "캔버스 클릭 시 선택된 타일이 해당 그리드 위치에 배치된다", "이미 타일이 있는 위치에 다시 배치하면 교체된다" ], "test_steps": [ "레벨 에디터 페이지를 연다", "팔레트에서 3번째 타일을 클릭한다", "타일에 하이라이트 테두리가 생기는지 확인한다", "캔버스 (5, 3) 위치를 클릭한다", "해당 위치에 선택한 타일이 나타나는지 확인한다", "같은 위치를 다른 타일로 다시 클릭해서 교체되는지 확인한다" ], "depends_on": [], "status": "pending", "files": ["frontend/src/editors/LevelEditor.tsx", "frontend/src/stores/levelStore.ts"] }, { "id": "TILE-002", "category": "level-editor", "title": "사각형 영역 채우기 도구", "description": "드래그로 사각형 영역을 지정하면 선택된 타일로 해당 영역이 채워진다", "acceptance_criteria": [ "도구 모음에서 '채우기' 도구를 선택할 수 있다", "마우스 다운 → 드래그 → 마우스 업으로 사각형 영역을 지정한다", "드래그 중 미리보기 사각형이 표시된다", "마우스 업 시 해당 영역이 선택된 타일로 채워진다", "1x1 영역(클릭만)에서도 정상 동작한다" ], "test_steps": [ "채우기 도구를 선택한다", "팔레트에서 타일을 선택한다", "캔버스 (2,2)에서 (5,5)까지 드래그한다", "4x4 영역이 타일로 채워지는지 확인한다", "(8,8)을 클릭만 해서 1x1도 동작하는지 확인한다" ], "depends_on": ["TILE-001"], "status": "pending", "files": ["frontend/src/editors/LevelEditor.tsx", "frontend/src/editors/tools/FillTool.ts"] }, { "id": "ENTITY-001", "category": "entity", "title": "엔티티 스폰 포인트 배치 및 삭제", "description": "레벨 에디터에서 적, 아이템 등의 스폰 포인트를 배치하고 삭제할 수 있다", "acceptance_criteria": [ "엔티티 모드로 전환할 수 있다", "엔티티 목록에서 타입을 선택한 뒤 캔버스에 클릭하면 스폰 포인트가 배치된다", "배치된 스폰 포인트를 클릭하면 선택 상태가 된다", "선택 상태에서 Delete 키를 누르면 삭제된다", "삭제 후 선택 상태가 해제된다" ], "test_steps": [ "엔티티 모드로 전환한다", "'적-슬라임' 타입을 선택한다", "캔버스 (3,4)를 클릭해서 배치한다", "배치된 스폰 포인트 아이콘이 보이는지 확인한다", "아이콘을 클릭해서 선택 상태가 되는지 확인한다", "Delete 키를 눌러 삭제되는지 확인한다" ], "depends_on": ["TILE-001"], "status": "pending", "files": [ "frontend/src/editors/EntityEditor.tsx", "frontend/src/stores/entityStore.ts" ] } ]
이렇게 20~30개 기능을 미리 정의해둡니다. 핵심은 acceptance_criteria와 test_steps이며, 이는 나중에 Evaluator 에이전트가 합격/불합격을 판정하는 기준이 됩니다.
Anthropic 실험에서 Sprint 3(레벨 에디터) 하나에만 27개의 평가 기준이 있었으며, 이 기준 덕분에 Evaluator가 fillRectangle 함수가 mouseUp에서 트리거되지 않는 버그, Delete 핸들러의 변수 누락 버그, FastAPI 라우트 정의 순서 버그 등을 찾아냈습니다.
그리고 절대 규칙이 하나 있습니다: "기능 목록을 삭제하거나 기준을 편집하는 것은 허용되지 않는다." 에이전트가 어려운 기준을 임의로 빼거나 조건을 완화하는 것을 원천 차단하는 것입니다.
3단계: 레이어 규칙을 테스트 코드로 강제하기
CLAUDE.md에 "engine/은 React를 import하면 안 된다"라고 써놨다고 에이전트가 지킬까요? 안 지킵니다. 에이전트는 코드베이스에 이미 있는 패턴을 따르는 경향이 있기 때문입니다. 누군가(또는 에이전트 자신이) 한 번 잘못된 import를 넣으면 그다음부터 계속 그 패턴을 복제합니다.
OpenAI가 100만 줄 실험에서 사용한 방법이 있습니다. "문서화가 아니라 기계적 강제." 규칙을 문서에 쓰는 것이 아니라 테스트 코드로 만들어서, 위반하면 빌드가 실패하도록 만드는 것입니다.
Pixel Quest에서는 이렇게 구현합니다. tests/architecture.test.ts:
import { describe, it, expect } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; // engine/ 디렉토리의 모든 .ts 파일을 재귀적으로 수집 function getFiles(dir: string): string[] { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries.flatMap(e => e.isDirectory() ? getFiles(path.join(dir, e.name)) : [path.join(dir, e.name)] ).filter(f => f.endsWith('.ts') || f.endsWith('.tsx')); } describe('레이어 의존성 규칙', () => { it('engine/ 은 React를 import하지 않는다', () => { const engineFiles = getFiles('src/engine'); for (const file of engineFiles) { const content = fs.readFileSync(file, 'utf-8'); expect(content).not.toMatch(/from\s+['"]react['"]/); expect(content).not.toMatch(/import\s+React/); } }); it('engine/ 은 stores/ 를 import하지 않는다', () => { const engineFiles = getFiles('src/engine'); for (const file of engineFiles) { const content = fs.readFileSync(file, 'utf-8'); expect(content).not.toMatch(/from\s+['"].*stores\//); } }); it('components/ 는 engine/ 을 직접 import하지 않는다', () => { const componentFiles = getFiles('src/components'); for (const file of componentFiles) { const content = fs.readFileSync(file, 'utf-8'); expect(content).not.toMatch(/from\s+['"].*engine\//); } }); it('editors/ 는 engine/ 을 직접 import하지 않는다 (stores를 거쳐야 함)', () => { const editorFiles = getFiles('src/editors'); for (const file of editorFiles) { const content = fs.readFileSync(file, 'utf-8'); expect(content).not.toMatch(/from\s+['"].*engine\//); } }); });
이 테스트가 npm run lint에 포함되어 있으면, 에이전트가 engine/ 안에서 import React를 쓰는 순간 빌드가 실패합니다. 에이전트는 빌드 실패 로그를 통해 engine/에서 React를 사용할 수 없다는 것을 인지하게 됩니다.
Martin Fowler(Thoughtworks의 Birgitta Bockeler)가 이걸 **"긍정적 프롬프트 인젝션"**이라고 불렀는데, 린터 에러 메시지에 수정 지침을 포함시키면 에이전트가 읽고 바로 고칠 수 있습니다. 위 테스트에서 에러 메시지를 더 친절하게 바꿀 수도 있죠:
it('engine/ 은 React를 import하지 않는다', () => { const engineFiles = getFiles('src/engine'); for (const file of engineFiles) { const content = fs.readFileSync(file, 'utf-8'); const hasReact = /from\s+['"]react['"]/.test(content); expect(hasReact, ` ${file}에서 React를 import하고 있습니다. engine/ 은 순수 로직 레이어입니다. React에 의존하면 안 됩니다. 수정 방법: 이 로직을 stores/ 또는 editors/ 로 옮기세요. `).toBe(false); } });
흥미로운 점은, OpenAI 실험에서는 이런 린터를 Codex 에이전트가 직접 작성했다는 것입니다. 또한 가비지 컬렉션 에이전트를 별도로 두어 주기적으로 코드베이스를 스캔하여 규칙 위반을 찾고 수정 PR을 생성하도록 했습니다.
4단계: Evaluator 만들기 — 회의적인 QA 에이전트
하네스에서 가장 중요한 부분입니다. 코드를 짜는 에이전트(Generator)와 결과를 검증하는 에이전트(Evaluator)를 반드시 분리해야 합니다. Evaluator는 Playwright(브라우저 자동화 도구)로 게임을 직접 클릭해보면서 검증하는데, Playwright E2E 테스트 작성법은 다음 5단계에서 다룹니다. 여기서는 먼저 Evaluator의 구조와 오케스트레이터(runner.py) 코드를 보겠습니다.
왜 분리해야 할까요? Anthropic 실험에서 확인된 바에 따르면, 에이전트가 자기 코드를 직접 평가하면 품질이 떨어져도 "괜찮은데?"하며 간과합니다. LLM 평가자의 오류율이 50%를 넘는 경우가 연구를 통해 확인되었고, 의심스러운 부분을 발견하고도 "별거 아니네"라고 무시하는 행동까지 관찰되었습니다.
Evaluator를 별도 세션에서 운영하는 오픈소스 구현체가 있습니다. celesteanders/harness 프로젝트의 evaluator.py인데, 핵심 프롬프트는 이렇습니다:
당신은 회의적인 코드 평가자입니다. 당신은 구현자가 아닙니다. 리뷰어입니다. 기본 가정은 "문제가 있거나 불완전하다"이고, 직접 확인해야 뒤집을 수 있습니다. 의심스러우면 FAIL입니다.
Pixel Quest에서 이걸 구체적으로 구현하면 이렇게 됩니다. evaluator_prompt.md:
# Pixel Quest Evaluator 당신은 Pixel Quest 프로젝트의 QA 테스터입니다. 구현된 기능이 실제로 동작하는지 직접 확인합니다. ## 평가 순서 (건너뛸 수 없음) 1. `npm run lint` 실행 → FAIL이면 즉시 OVERALL FAIL 2. `npm run test:unit` 실행 → FAIL이면 즉시 OVERALL FAIL 3. 소스 코드에서 TODO, FIXME, pass, NotImplementedError 검색 → 하나라도 있으면 즉시 OVERALL FAIL 4. feature_list.json의 해당 기능 acceptance_criteria를 하나씩 확인 → Playwright로 실제 앱을 열고 test_steps를 따라 실행 5. 테스트 코드의 품질 확인 → `assert result`처럼 모호한 assertion은 FAIL → 구체적인 값 비교가 있어야 PASS ## 판정 기준 - acceptance_criteria 중 하나라도 미달이면 OVERALL FAIL - "아마 될 것 같다"는 FAIL. 직접 확인해야 PASS. ## 출력 형식 VERDICT task: {id} make_check: PASS|FAIL acceptance_criteria: PASS|FAIL test_coverage: PASS|FAIL no_placeholders: PASS|FAIL ac_checklist: - [x] 팔레트에 16개 타일 표시: 확인됨 (PASS) - [ ] 드래그 중 미리보기: 미리보기 사각형 안 보임 (FAIL) issues: - FillTool.ts의 onMouseMove에서 미리보기 rect를 그리는 코드가 없음 OVERALL: FAIL
실제 오케스트레이터(runner.py) 코드의 핵심 루프입니다. 에이전트를 호출하는 방법은 두 가지가 있는데, 둘 다 같은 결과를 냅니다:
claude -p(CLI 파이프 모드): 가장 간단합니다.subprocess.run으로 바로 호출. 별도 설치 없이 Claude Code만 있으면 됩니다.- Claude Agent SDK: Python에서 세션을 직접 제어합니다. 스트리밍 응답, 커스텀 MCP 도구 연결, Hook 같은 세밀한 제어가 필요할 때 씁니다.
아래 코드는 claude -p 방식입니다. 더 간단하고, 오픈소스 구현체(celesteanders/harness)도 이 방식을 씁니다. 뒤에 나오는 Hook이나 커스텀 도구 연결은 Claude Agent SDK 코드로 보여드리겠습니다.
import subprocess import json from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent # .harness/ 의 상위 = pixel-quest/ VERIFY_CMD = ["npm", "run", "lint"] MAX_RETRIES = 2 def run_claude(prompt: str, tools: list[str], capture: bool = False): """Claude Code CLI 파이프 모드로 세션 실행 capture=True면 stdout을 문자열로 반환, False면 exit code 반환""" cmd = ["claude", "-p", prompt, "--allowedTools", ",".join(tools)] if capture: result = subprocess.run(cmd, cwd=PROJECT_ROOT, capture_output=True, text=True) return result.stdout return subprocess.run(cmd, cwd=PROJECT_ROOT).returncode def run_generator(task: dict) -> None: """Generator: 코드 작성 (읽기+쓰기 권한)""" prompt = f""" feature_list.json의 {task['id']} 기능을 구현하세요. 제목: {task['title']} 설명: {task['description']} 수용 기준: {json.dumps(task['acceptance_criteria'], ensure_ascii=False)} 관련 파일: {task['files']} 규칙: - TODO/FIXME 금지. 완전히 구현하세요. - 단위 테스트를 함께 작성하세요. - 완료 후 git commit 하세요. """ run_claude(prompt, ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]) def run_evaluator(task: dict) -> str: """Evaluator: 검증 (읽기 전용 권한, stdout 캡처)""" prompt = f""" {task['id']} 기능이 제대로 구현됐는지 검증하세요. 수용 기준: {json.dumps(task['acceptance_criteria'], ensure_ascii=False)} 테스트 단계: {json.dumps(task['test_steps'], ensure_ascii=False)} evaluator_prompt.md의 평가 순서를 따르세요. Playwright로 실제 앱을 열고 직접 확인하세요. VERDICT 형식으로 결과를 출력하세요. """ # Evaluator는 읽기 전용! Write, Edit 권한 없음 # capture=True로 VERDICT 출력을 받아옴 return run_claude(prompt, ["Read", "Bash", "Glob", "Grep"], capture=True) # 메인 루프 with open("feature_list.json") as f: features = json.load(f) for task in features: if task["status"] != "pending": continue # 의존성 확인 deps = task.get("depends_on", []) if any(f["status"] != "done" for f in features if f["id"] in deps): continue print(f"\n=== {task['id']}: {task['title']} ===") # Generator 실행 run_generator(task) # 빌드 검증 (최대 2회) for attempt in range(MAX_RETRIES): if subprocess.run(VERIFY_CMD, cwd=PROJECT_ROOT).returncode == 0: break run_claude("빌드/린트 에러를 수정하세요.", ["Read", "Write", "Edit", "Bash"]) # Evaluator 실행 (별도 세션, 읽기 전용) verdict = run_evaluator(task) # FAIL이면 피드백 전달 후 재시도 if "OVERALL: FAIL" in str(verdict): run_generator({**task, "description": f"이전 평가에서 실패. 수정 필요:\n{verdict}"}) verdict = run_evaluator(task) # 결과 기록 task["status"] = "done" if "OVERALL: PASS" in str(verdict) else "blocked" # 결과 저장 with open("feature_list.json", "w") as f: json.dump(features, f, ensure_ascii=False, indent=2)

Generator는 코드를 읽고, 쓰고, 실행하는 전체 권한을 갖습니다. Evaluator는 읽기와 실행만 가능합니다. 이 권한 분리가 핵심인데, Evaluator가 코드를 수정할 수 있으면 "버그를 직접 고치고 자기 수정에 만족하는" 상황이 생기기 때문입니다.
잠깐 — 이거 진짜 자동으로 돌아가나요?
네, python .harness/runner.py를 실행하면 feature_list.json에 정의된 기능들을 하나씩 자동으로 처리합니다. 사람이 하는 일은 처음에 하네스 파일을 설정하고 스크립트를 실행하는 것뿐이며, 이후로는 루프가 자동으로 동작합니다.
위 코드에서 run_claude() 함수를 보면 claude -p를 호출하는데, 이건 Claude Code의 **파이프 모드(비대화형 모드)**입니다. 터미널에서 대화하는 게 아니라, 프로그래밍 방식으로 프롬프트를 넣고 결과를 받습니다. --allowedTools 플래그로 에이전트의 권한도 제어합니다.
# 이렇게 생긴 명령이 자동으로 실행됩니다 claude -p "TILE-001 기능을 구현하세요. ..." --allowedTools "Read,Write,Edit,Bash"
Claude Agent SDK를 쓰면 Python에서 더 세밀하게 제어할 수도 있습니다:
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions options = ClaudeAgentOptions( system_prompt="당신은 게임 개발자입니다...", allowed_tools=["Read", "Write", "Edit", "Bash"], max_turns=20, cwd="/path/to/pixel-quest" ) async with ClaudeSDKClient(options=options) as client: await client.query("TILE-001 기능을 구현하세요.") async for msg in client.receive_response(): # 실시간으로 진행 상황을 볼 수 있음 process(msg)
자동 루프의 전체 흐름을 그림으로 보면 이렇습니다.

feature_list.json에서 기능을 하나 꺼내고, Generator가 코드를 짜고, Evaluator가 검증하고, 통과하면 다음 기능으로 넘어갑니다. 실패하면 피드백을 전달하고 재시도합니다. 실제 터미널 출력은 이렇게 생겼습니다:
$ python .harness/runner.py === TILE-001: 타일 팔레트에서 타일 선택 === [Generator] claude -p 실행... (코드 작성 중) [Generator] git commit -m "feat: implement tile palette selection" [Verify] npm run lint... PASS [Evaluator] claude -p 실행... (Playwright로 앱 테스트 중) [Evaluator] VERDICT: OVERALL PASS ✓ → status: "done" === TILE-002: 사각형 영역 채우기 도구 === [Generator] claude -p 실행... (코드 작성 중) [Verify] npm run lint... FAIL (레이어 규칙 위반) [Fix] claude -p "린트 에러 수정"... 재시도 1/2 [Verify] npm run lint... PASS [Evaluator] claude -p 실행... [Evaluator] VERDICT: OVERALL FAIL - 드래그 중 미리보기 사각형 안 보임 [Generator] 피드백 전달 후 재실행... [Evaluator] 재검증... [Evaluator] VERDICT: OVERALL PASS ✓ → status: "done" === ENTITY-001: 엔티티 스폰 포인트 배치 및 삭제 === [Generator] claude -p 실행... [Evaluator] VERDICT: OVERALL FAIL - Delete 키 삭제 안 됨 [Generator] 피드백 전달 후 재실행... [Evaluator] VERDICT: OVERALL FAIL (여전히 안 됨) → 재시도 초과. status: "blocked" ⚠️ → 사람이 확인 필요 === 결과 요약 === done: 2개 (TILE-001, TILE-002) blocked: 1개 (ENTITY-001) — 수동 확인 필요 pending: 22개
Anthropic의 게임 메이커 실험이 정확히 이 방식이었습니다. 6시간 동안 10개 스프린트를 사람 개입 없이 진행했습니다.
자동 루프의 한계 — 솔직한 이야기
완전 자율은 아닙니다. 현실적으로 끊기는 지점이 있습니다.
MAX_RETRIES 초과: 위 예시의 ENTITY-001처럼, 2번 재시도해도 Evaluator를 통과하지 못하면 "blocked" 상태가 됩니다. 이 시점에서 사람이 progress.md를 확인하고 방향을 잡아줘야 합니다. 모든 기능이 한 번에 통과하지는 않습니다.
컨텍스트 불안(Context Anxiety): Anthropic이 발견한 현상으로, 에이전트가 컨텍스트 윈도우 한계에 가까워졌다고 인지하면 작업을 서둘러 마무리하려는 경향을 보입니다. "이 정도면 됐다"며 대충 끝내는 것입니다. Sonnet 4.5에서 특히 심했다고 합니다. 그래서 runner.py는 기능 단위로 세션을 끊고 progress.md를 통해 다음 세션으로 작업을 이어받는 구조입니다. 하나의 긴 세션으로 전부 처리하려고 하면 안 됩니다.
비용: Anthropic V1(Opus 4.5) 기준 6시간에 $200, V2(Opus 4.6) 기준 4시간에 $125 정도입니다. 기능 25개짜리 게임이면 대략 이 범위인데, 재시도가 많아지면 비용이 올라갑니다. 기능 하나당 Generator 1회 + Evaluator 1회가 기본이고, 실패 시 각각 1회 추가. API 비용을 feature_list.json의 기능 수로 미리 예측해두는 게 좋습니다.
브라우저 테스트의 불안정성: Playwright가 가끔 타이밍 이슈로 실패합니다. 게임 로딩이 지연되어 요소를 찾지 못하거나, 애니메이션 도중에 클릭하여 오작동하는 경우가 발생합니다. 이건 하네스 문제가 아니라 E2E 테스트의 본질적 문제인데, waitForSelector 타임아웃을 넉넉히 잡고 retry 로직을 넣으면 완화됩니다.
결국 자동 루프의 현실적인 모습은 이렇습니다:
- 전체 기능의 **60~70%**는 자동으로 통과
- **20~30%**는 1~2회 재시도 후 통과
- **5~10%**는 blocked 상태로 사람 개입 필요
사람이 완전히 손을 떼는 것이 아니라, "매번 확인"에서 "가끔 확인"으로 역할이 전환되는 것입니다. Anthropic의 표현을 빌리자면, 승인 기반 루프에서 반복 횟수가 많아지면 결국 제대로 확인하지 않고 승인 버튼만 누르게 되므로, 차라리 자동화하고 실제 문제 발생 시에만 사람에게 알리는 것이 더 효율적이라는 것입니다.
5단계: Playwright E2E 테스트 작성 — 에이전트에게 눈 달아주기
Evaluator가 게임을 "직접 클릭해볼" 수 있으려면 Playwright 테스트가 필요합니다. Anthropic 실험에서 Evaluator에 Playwright MCP를 연결한 이유가 여기에 있습니다. 코드만으로는 "사각형 채우기 도구가 드래그 시작/끝 지점에만 타일을 찍는다"와 같은 버그를 찾아내기 어렵습니다.
Pixel Quest의 E2E 테스트 예시. tests/e2e/level-editor.spec.ts:
import { test, expect } from '@playwright/test'; test.describe('레벨 에디터 — 타일 배치', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:5173/editor'); // 에디터가 로드될 때까지 대기 await page.waitForSelector('[data-testid="tile-palette"]'); }); test('팔레트에서 타일을 선택하고 캔버스에 배치한다', async ({ page }) => { // 팔레트의 3번째 타일 클릭 const tile = page.locator('[data-testid="palette-tile"]').nth(2); await tile.click(); // 선택 상태 확인 (하이라이트 테두리) await expect(tile).toHaveClass(/selected/); // 캔버스의 (5, 3) 위치 클릭 const canvas = page.locator('[data-testid="level-canvas"]'); const cellSize = 32; // 픽셀 await canvas.click({ position: { x: 5 * cellSize + 16, y: 3 * cellSize + 16 } }); // 해당 위치에 타일이 배치되었는지 확인 const placedTile = page.locator('[data-testid="placed-tile-5-3"]'); await expect(placedTile).toBeVisible(); }); test('사각형 영역 채우기 도구', async ({ page }) => { // 채우기 도구 선택 await page.click('[data-testid="tool-fill"]'); // 타일 선택 await page.locator('[data-testid="palette-tile"]').first().click(); // (2,2)에서 (5,5)까지 드래그 const canvas = page.locator('[data-testid="level-canvas"]'); const cellSize = 32; await canvas.dragTo(canvas, { sourcePosition: { x: 2 * cellSize + 16, y: 2 * cellSize + 16 }, targetPosition: { x: 5 * cellSize + 16, y: 5 * cellSize + 16 }, }); // 4x4 = 16개 타일이 배치됐는지 확인 for (let x = 2; x <= 5; x++) { for (let y = 2; y <= 5; y++) { const placed = page.locator(`[data-testid="placed-tile-${x}-${y}"]`); await expect(placed).toBeVisible(); } } }); }); test.describe('레벨 에디터 — 엔티티', () => { test('엔티티 배치 후 Delete로 삭제', async ({ page }) => { await page.goto('http://localhost:5173/editor'); // 엔티티 모드 전환 await page.click('[data-testid="mode-entity"]'); // 적-슬라임 선택 await page.click('[data-testid="entity-type-slime"]'); // 캔버스에 배치 const canvas = page.locator('[data-testid="level-canvas"]'); await canvas.click({ position: { x: 3 * 32 + 16, y: 4 * 32 + 16 } }); // 배치 확인 const entity = page.locator('[data-testid="entity-spawn-3-4"]'); await expect(entity).toBeVisible(); // 엔티티 클릭해서 선택 await entity.click(); await expect(entity).toHaveClass(/selected/); // Delete 키로 삭제 await page.keyboard.press('Delete'); await expect(entity).not.toBeVisible(); }); });
이 테스트들이 있으면 Evaluator가 npm run test:e2e를 실행하여 기능이 실제로 동작하는지 확인할 수 있습니다. 단순히 코드를 읽는 것을 넘어, 브라우저를 실행하여 클릭하고, 드래그하고, 키보드를 누르는 것입니다.
Anthropic의 게임 메이커 실험에서 Evaluator가 실제로 잡아낸 버그들이 전부 이런 식의 "직접 해봐야 보이는" 문제들이었습니다:
fillRectangle이mouseUp에서 호출 안 됨 → 드래그해봐야 발견- Delete 핸들러의
selection변수 누락 → 클릭하고 Delete 눌러봐야 발견 - FastAPI 라우트 순서 문제 → API를 실제로 호출해봐야 발견
6단계: 세션 간 상태 인수인계 — 교대 근무 프로토콜
게임 하나를 만드는 데 AI 세션 한 번으로 끝나지 않습니다. Anthropic의 V2 하네스(Opus 4.6 + DAW)에서도 3라운드의 빌드-QA 사이클이 필요했고, 총 3시간 50분 / $124.70이 들었습니다.
세션이 끊기면 에이전트는 이전 맥락을 잊습니다. 그래서 매 세션 시작 시 읽어야 할 "인수인계 문서"가 필요합니다. progress.md:
# Pixel Quest 진행 상황 ## 마지막 업데이트: 2026-04-09 14:30 ## 완료된 기능 - [x] TILE-001: 타일 팔레트에서 타일 선택 (commit: a3f2d1e) - [x] TILE-002: 사각형 영역 채우기 도구 (commit: b7c4e2a) - [x] TILE-003: 실행 취소/다시 실행 (commit: d9e1f3b) ## 진행 중 - [ ] ENTITY-001: 엔티티 스폰 포인트 배치 및 삭제 - 배치는 동작함. Delete 삭제에서 버그 발생. - Evaluator 피드백: "Delete 핸들러가 selection과 selectedEntityId를 동시에 확인하는데, 클릭 시 selectedEntityId만 설정됨" - 수정 필요: EntityEditor.tsx의 handleKeyDown에서 selectedEntityId만으로도 삭제 가능하도록 조건 변경 ## 블로커 - 없음 ## 다음 할 것 - ENTITY-001 버그 수정 → Evaluator 재실행 - ENTITY-002: 엔티티 행동 설정 패널
세션 시작 프로토콜을 init.sh로 만들어두면:
#!/bin/bash echo "=== Pixel Quest 세션 시작 ===" echo "" echo "--- 진행 상황 ---" cat progress.md echo "" echo "--- 최근 커밋 ---" git log --oneline -10 echo "" echo "--- 남은 기능 ---" python3 -c " import json with open('feature_list.json') as f: features = json.load(f) pending = [f for f in features if f['status'] == 'pending'] print(f'남은 기능: {len(pending)}개') for f in pending[:5]: deps = f.get('depends_on', []) dep_status = 'ready' if not deps else 'waiting' print(f' - {f[\"id\"]}: {f[\"title\"]} [{dep_status}]') " echo "" echo "--- 서버 시작 ---" (cd backend && uvicorn main:app --reload) & BACKEND_PID=$! (cd frontend && npm run dev) & FRONTEND_PID=$! echo "서버 시작 완료 (Backend PID: $BACKEND_PID, Frontend PID: $FRONTEND_PID)" echo "종료 시: kill $BACKEND_PID $FRONTEND_PID"
에이전트가 새 세션을 시작하면 init.sh를 실행하고, progress.md를 읽고, 중단된 지점부터 이어서 작업합니다. 마치 교대 근무하는 엔지니어가 인수인계 노트를 읽고 일을 이어받는 것과 같습니다.
7단계: 위험 차단 — Hook으로 안전장치 걸기
에이전트가 실수로(또는 의도치 않게) 위험한 작업을 수행하는 것을 방지해야 합니다.
앞에서 runner.py는 claude -p로 간단하게 구현했는데, Hook(에이전트가 도구를 사용하기 직전에 코드를 끼워넣어 차단하거나 수정하는 메커니즘)처럼 세밀한 제어가 필요하면 Claude Agent SDK를 씁니다. runner.py 전체를 SDK로 바꿀 수도 있고, Hook이 필요한 부분만 SDK로 구현할 수도 있습니다.
이 단계는 Python의 async/await 문법이 등장합니다. 아직 익숙하지 않다면, 4단계의 claude -p 방식을 사용하여 먼저 기본 루프를 구현해본 후 이 부분을 다시 살펴봐도 됩니다.
Claude Agent SDK 설치:
pip install claude-agent-sdk
Hook 구현 예시:
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher async def block_dangerous_commands(input_data, tool_use_id, context): """위험한 bash 명령어 차단""" command = input_data.get("tool_input", {}).get("command", "") blocked_patterns = [ "rm -rf", "DROP TABLE", "DELETE FROM", "git push --force", "chmod 777", "curl.*|.*sh", # 원격 스크립트 실행 차단 ] for pattern in blocked_patterns: if pattern in command: return { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": f"차단: '{pattern}' 패턴이 감지됨. " f"이 명령은 보안상 실행할 수 없습니다." } } return {} async def prevent_feature_list_edit(input_data, tool_use_id, context): """feature_list.json의 status 외 필드 수정 차단""" tool_name = input_data.get("tool_name", "") if tool_name in ("Write", "Edit"): file_path = input_data.get("tool_input", {}).get("file_path", "") if "feature_list.json" in file_path: # status 필드만 변경 가능 new_content = input_data.get("tool_input", {}).get("new_string", "") if "acceptance_criteria" in new_content or "test_steps" in new_content: return { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "feature_list.json의 acceptance_criteria와 " "test_steps는 수정할 수 없습니다. " "status 필드만 변경 가능합니다." } } return {} options = ClaudeAgentOptions( hooks={ "PreToolUse": [ HookMatcher(matcher="Bash", hooks=[block_dangerous_commands]), HookMatcher(matcher="Write", hooks=[prevent_feature_list_edit]), HookMatcher(matcher="Edit", hooks=[prevent_feature_list_edit]), ] } )
martinsson/harness-engineering-kata에서는 9단계에 걸쳐 하네스를 점진적으로 개선하는 실험을 진행했으며, 다음과 같은 핵심 사실을 발견했습니다: "기계적 하드 블로킹(Hook으로 exit 2 반환)만이 확실하게 동작한다. 문서로 안내하는 소프트 기법은 일관되게 무시된다."
8단계: 하네스 진화시키기 — 모델이 바뀌면 하네스도 바뀐다
Anthropic이 Opus 4.5에서 4.6으로 올라갔을 때 하네스 구조가 크게 바뀌었습니다. V1에서 핵심이었던 10개 스프린트 구조를 통째로 없앴습니다. Opus 4.6이 자체적으로 작업을 분할하는 능력이 향상되었기 때문입니다.
V1(Opus 4.5): 6시간, $200, 10 스프린트
V2(Opus 4.6): 3시간 50분, $124.70, 스프린트 없이 연속 세션
시간 36% 줄고, 비용 38% 줄었습니다.
이는 하네스의 모든 요소가 "모델이 이것을 단독으로 수행할 수 없다"는 가정을 담고 있다는 의미입니다. 모델이 나아지면 그 가정이 무효화되고, 해당 요소는 오히려 짐이 됩니다.
Pixel Quest에 적용하면:
지금 필요한 것 (현재 모델 기준):
- feature_list.json으로 기능 분할 → 에이전트가 혼자서 적절히 나누지 못할 때 필요
- Evaluator 분리 → 자기 평가 편향이 존재하는 한 필요
- 레이어 규칙 테스트 → 에이전트가 아키텍처를 무시하는 한 필요
- 플레이스홀더 자동 탐지 → 스텁을 만드는 경향이 있는 한 필요
모델이 개선되면 재검토할 것:
- 스프린트 단위 분할 → 모델이 장시간 일관된 작업을 유지하면 불필요
- 세션 간 인수인계 → 컨텍스트 윈도우가 충분히 커지면 단순화 가능
- 상세한 test_steps → 모델이 acceptance_criteria만으로 테스트를 작성할 수 있으면 불필요
Anthropic 블로그의 이 문장이 핵심을 찌릅니다:
"하네스 조합의 흥미로운 공간은 모델이 개선된다고 줄어들지 않는다. 대신 이동한다."
뭘 측정해야 재검토가 가능한가
느낌으로 "이제 이건 필요 없을 것 같다"고 하네스 요소를 빼면 안 됩니다. runner.py에 간단한 로그를 추가하면 됩니다:
import time # 기능별 실행 로그 run_log = [] for task in features: start = time.time() retries = 0 # ... Generator, Evaluator 실행 ... run_log.append({ "id": task["id"], "duration_sec": time.time() - start, "generator_calls": 1 + retries, "evaluator_result": "PASS" if "PASS" in verdict else "FAIL", "final_status": task["status"] }) # 실행 후 요약 with open("run_log.json", "w") as f: json.dump(run_log, f, indent=2) pass_rate = sum(1 for r in run_log if r["final_status"] == "done") / len(run_log) avg_retries = sum(r["generator_calls"] for r in run_log) / len(run_log) print(f"통과율: {pass_rate:.0%}, 평균 Generator 호출: {avg_retries:.1f}회")
이 로그가 쌓이면 판단할 수 있습니다:
- Evaluator 첫 패스 통과율이 90% 이상 → acceptance_criteria를 줄여도 됨
- 레이어 규칙 위반이 0건으로 유지 → 모델이 규칙을 학습한 것. 구조 테스트는 유지하되 린터 메시지를 단순화
- 특정 카테고리의 재시도가 계속 0회 → 해당 카테고리의 하네스 요소를 단순화 가능
- blocked 비율이 20% 이상 → 하네스가 부족한 것. acceptance_criteria를 더 상세하게, 또는 도구를 추가
Red Hat의 Eval-Driven Development 연구에 따르면, 이렇게 평가 기준을 먼저 만들고 측정하는 팀이 프로덕션 전에 이슈를 60% 더 잡아냈고, 반복 속도가 3배 빨랐습니다.
전체 디렉토리 구조 정리

최종적으로 Pixel Quest 프로젝트의 하네스 관련 파일을 한눈에 보면:
실제로 이 가이드대로 만든 Pixel Quest 프로젝트의 구조입니다:
pixel-quest/ ├── CLAUDE.md ← 에이전트가 처음 읽는 프로젝트 지도 ├── feature_list.json ← 기능 목록 + 수용 기준 (7개 기능) ├── evaluator_prompt.md ← Evaluator 에이전트 지시서 ├── progress.md ← 세션 간 인수인계 문서 ├── run_log.json ← 실행 결과 로그 │ ├── .harness/ │ └── runner.py ← Generator↔Evaluator 자동 루프 │ ├── frontend/ │ └── src/ │ ├── components/Navbar.tsx ← 네비게이션 바 │ ├── editors/ │ │ ├── LevelEditor.tsx ← 레벨 에디터 (타일+엔티티) │ │ ├── SpriteEditor.tsx ← 스프라이트 에디터 │ │ └── PlayMode.tsx ← 플레이 테스트 모드 │ ├── engine/ │ │ ├── GameEngine.ts ← 게임 엔진 (React 의존 없음) │ │ └── Physics.ts ← 물리/충돌 처리 │ ├── stores/ ← Zustand 상태 관리 (4개 스토어) │ ├── pages/Home.tsx ← 홈 페이지 │ └── types/index.ts ← 타입 정의 │ └── tests/ └── unit/ ← 142개 단위 테스트 (15개 파일)
체크리스트: 빠뜨린 거 없나 확인
| 단계 | 무엇 | 왜 |
|---|---|---|
| CLAUDE.md | 프로젝트 구조, 스택, 레이어 규칙, 금지 사항 | 에이전트가 볼 수 없으면 존재하지 않는다 |
| feature_list.json | 기능별 수용 기준 + 테스트 단계 (JSON) | Markdown보다 구조가 엄격해 에이전트가 임의로 편집하기 어려움 |
| architecture.test.ts | 레이어 의존성 규칙을 테스트 코드로 | 문서는 무시 가능, 빌드 실패는 무시 불가 |
| Evaluator 분리 | 별도 세션, 읽기 전용, "의심스러우면 FAIL" | 자기 평가 편향 차단 |
| Playwright E2E | 실제 브라우저에서 클릭/드래그/키 입력 | 코드 리뷰로는 못 잡는 버그를 잡음 |
| progress.md + init.sh | 세션 간 인수인계 | 세션 끊기면 맥락을 잊으니까 |
| Hook | 위험 명령 차단, feature_list 수정 차단 | 소프트 가이드는 무시됨, 하드 블로킹만 확실 |
이 7개가 갖춰지면 AI 에이전트가 게임을 만들 수 있는 하네스의 뼈대가 완성됩니다. 나머지는 프로젝트에 맞게 조정하면 됩니다.
실제로 돌려본 결과
위 가이드대로 하네스를 구성하고 python .harness/runner.py를 실행한 결과입니다. 사람이 한 건 하네스 파일 세팅과 스크립트 실행뿐이고, 나머지는 AI 에이전트가 자동으로 처리했습니다.
실행 결과: 7개 기능 전부 통과, 142개 테스트, 재시도 0회
홈 화면입니다. 레벨 에디터와 스프라이트 에디터로 이동할 수 있습니다.

레벨 에디터입니다. 왼쪽에 타일 팔레트(잔디, 흙, 돌, 벽돌, 나무, 물, 용암, 모래 등), 상단에 타일/엔티티 모드 토글, 저장/불러오기, 전체 초기화, 플레이 버튼이 있습니다.

스프라이트 에디터입니다. 32x32 픽셀 그리드에 16색 팔레트로 스프라이트를 그릴 수 있습니다.

소스 코드 전체는 GitHub에서 확인할 수 있습니다: github.com/urstory/pixel-quest
더 알아보기
공식 SDK & 문서:
- anthropics/claude-agent-sdk-python — Claude Agent SDK (Planner/Generator/Evaluator 구현 가능)
- Anthropic — Harness design for long-running application development — 게임 메이커 실험 원문
오픈소스 구현체:
- celesteanders/harness — runner.py + evaluator.py 전체 코드 포함
- martinsson/harness-engineering-kata — 9단계 점진적 학습 카타
배경 연구:
- Chroma Research — Context Rot — 컨텍스트 길이와 성능 저하 관계
- Martin Fowler — Harness engineering for coding agent users — 가이드/센서 프레임워크
- OpenAI — Harness engineering — 100만 줄 실험의 5가지 원칙




댓글
댓글을 작성하려면 이 필요합니다.