스펙 문서 하나만 읽은 코딩 에이전트는 오목을 얼마나 잘 두는가 — 코드배틀에서 해 본 프롬프트 한 줄 실험#
2026-10-11

사람 말고 에이전트가 읽을 문서를 먼저 만들었다#
필자는 코드배틀(codebattle.online)이라는 사이드 프로젝트를 만들고 있습니다. 오목, 가위바위보 같은 게임의 전략을 JavaScript 코드로 짜서 대결하는 서비스입니다. 플레이어가 쓴 코드는 내장 AI와 싸울 수도 있고, 아레나에서 다른 사람의 코드와 싸울 수도 있습니다.
이 서비스를 설명하는 문장을 올해 한 줄로 다시 정했습니다.
전략을 코드로 짜서 대결한다. 직접 짜도 되고, 코딩 에이전트에게 스펙을 먹여도 된다.
뒷부분이 새로 붙은 말입니다. 사람은 게임 화면에서 배웁니다. 에디터에 처음부터 샘플 코드가 올라와 있고, 전략 해설을 따라가며 코드를 고치다 보면 어느새 내장 AI를 이기는 코드에 도달합니다. 그런데 코딩 에이전트는 화면을 돌아다니며 배우지 않습니다. 에이전트에게 필요한 것은 한 문서에 적힌 계약입니다. 어떤 훅(게임 엔진이 코드를 부르는 함수)이 언제 불리는지, 인자로 무엇이 들어오는지, 무엇을 반환해야 하는지, 한 수에 쓸 수 있는 시간은 얼마인지, 자주 빠지는 함정은 무엇인지. 그래서 게임마다 markdown 형식의 스펙 문서를 짧은 고정 주소에 두었습니다. 오목은 codebattle.online/specs/omok입니다.
문서를 만들고 나니 당연한 질문이 남았습니다. 이 문서가 정말 제 역할을 하는가. 스펙 하나만 받은 에이전트가 계약을 어기지 않는 코드를 내놓는가. 그리고 그 코드가, 사람이 화면에서 배워 도달하는 전략을 이기는가. 앞의 질문은 문서의 품질을 묻고, 뒤의 질문은 “에이전트에게 먹여도 된다"는 말이 빈말이 아닌지를 묻습니다. 둘 다 재 보기 전에는 답할 수 없는 질문이라, 재 봤습니다.
프롬프트 한 줄, 수정 없음#
실험은 누구나 똑같이 반복할 수 있도록 단순하게 설계했습니다.
에이전트는 Claude Code에서 돌아가는 Claude(모델은 Claude Fable 5)입니다. 2026년 9월 6일에 코드를 받았습니다.
에이전트에게 준 것은 위 스펙 문서의 주소 하나뿐입니다. 다른 자료, 저장소 접근, 웹 검색은 주지 않았습니다.
프롬프트는 다음 한 줄이 전부입니다.
이 스펙만 읽고, 코드배틀 오목에서 이길 전략 코드를 작성해줘.
에이전트가 낸 코드(7,239자)를 한 글자도 고치지 않고 측정했습니다.
측정은 두 경로로 했습니다. 실제 서비스에 두 가지 실행 환경이 있기 때문입니다.
| 경로 | 조건 | 서비스에서 대응하는 것 |
|---|---|---|
| 아레나 경로 | 코드 대 코드. 재현을 위해 시계가 결정론적으로 흐르고, 판마다 seed(난수 씨앗)를 바꿈 | 아레나 도전 |
| 게임 화면 경로 | 코드 대 내장 AI. 실제 시계 | 게임 화면의 AI 대전 |
비교 상대는 모두 사이트에 이미 있던 것입니다.
- 기본 샘플: 게임 화면에 처음 들어오면 에디터에 올라와 있는 코드입니다. 상대 돌 근처의 빈칸을 무작위로 고릅니다.
- 열린 줄 전략: 게임 화면의 전략 해설이 결론으로 제시하는 코드입니다. 양쪽이 트인 줄과 한쪽이 막힌 줄을 구분해 두는 전략으로, 내장 AI LEVEL 1을 상대로 전승하는 수준입니다. 사람이 화면에서 배워 도달하는 전략의 기준점으로 삼았습니다.
- 내장 AI: RANDOM, LEVEL 1, LEVEL 2, LEVEL 3의 네 단계입니다.
에이전트가 낸 코드: 점수표 하나, 규칙 네 줄, 그리고 시간예산#
코드를 읽어 보니 접근 방식은 세 층이었습니다.
첫째 층은 패턴 점수표입니다. 한 줄에 놓인 돌의 배치를 문자열로 보고, 모양마다 점수를 매깁니다. o는 평가 대상의 돌, _는 빈칸이고, 상대 돌과 판 밖은 x로 표시합니다. 점수표에 x가 들어간 패턴은 하나도 없으므로, 빈칸이 와야 할 자리에 x가 오면 그 패턴은 성립하지 않습니다. 그렇게 “막힌” 쪽이 자연스럽게 표현됩니다. 코드에서 이 표만 그대로 옮기면 다음과 같습니다.
// 패턴 점수표: 'o' = 평가 대상 플레이어의 돌, '_' = 빈칸
var PATTERNS = [
["ooooo", 10000000],
["_oooo_", 1000000],
["oooo_", 120000], ["_oooo", 120000],
["ooo_o", 120000], ["o_ooo", 120000], ["oo_oo", 120000],
["_ooo_", 25000],
["_o_oo_", 20000], ["_oo_o_", 20000],
["_ooo", 3000], ["ooo_", 3000],
["o_oo", 1500], ["oo_o", 1500],
["_oo_", 3000],
["_o_o_", 1200],
["_o_", 40]
];열일곱 개 패턴입니다. 다섯 개가 이어지면 1,000만 점, 양쪽이 트인 넷은 100만 점, 한쪽이 막힌 넷과 가운데가 빈 넷은 12만 점입니다. 흥미로운 점은 양쪽이 트인 셋(_ooo_)이 2만 5천 점으로, 한쪽이 막힌 넷의 5분의 1 정도라는 것입니다. 사람이 “열린 줄과 막힌 줄을 구분하라"고 말로 배우는 내용을, 에이전트는 숫자 비율로 적어 둔 셈입니다.
빈칸 하나의 가치는 그 자리에 돌을 놓기 전과 후의 점수 차이로 계산합니다. 가로, 세로, 두 대각선의 네 방향에 대해 그 칸을 가운데 둔 아홉 칸짜리 문자열을 만들고, 가운데를 o로 둔 것의 점수표 합에서 _로 둔 것의 합을 빼면 “이 자리에 두면 얼마가 오르는가"가 나옵니다. 같은 계산을 상대 입장에서도 해서 수비 가치를 얻고, 공격 가치에 수비 가치의 0.9배와 약간의 중앙 가중치를 더해 후보 수를 정렬합니다. 후보는 이미 놓인 돌에서 두 칸 안의 빈칸으로 제한합니다.
둘째 층은 규칙 사슬입니다. 후보 수가 정렬되면 탐색에 들어가기 전에 네 가지를 순서대로 확인합니다.
- 내가 지금 오목을 완성할 수 있으면 그 수를 둔다.
- 상대가 다음 수로 오목을 완성할 자리가 있으면 반드시 막는다. 여러 곳이면 내 공격 가치가 큰 곳을 막는다.
- 내가 양쪽이 트인 넷을 만들 수 있으면 만든다.
- 상대가 양쪽이 트인 넷을 만들 자리가 있으면 막는다.
셋째 층이 시간예산을 둔 탐색입니다. 규칙 사슬에 걸리는 수가 없을 때만 탐색을 돌립니다. 깊이 2에서 시작해 3, 4로 늘려 가는 반복 심화 방식이고, 한 수마다 Date.now() 기준으로 1.35초의 예산을 두어 예산 안에 끝난 깊이의 결과만 채택합니다. 상대 수의 가치는 0.95를 곱해 할인하고, 승리 수순이 보이면 더 깊이 가지 않고 멈춥니다.
코드에서 확인한 사실도 몇 가지 적어 둡니다. 스펙이 정한 훅 다섯 개를 모두 올바른 이름으로 구현했고, 오목에는 없는 훅을 만들지 않았습니다. 상대의 직전 수가 들어오는 인자의 이름과 형식, 좌표 반환 형식도 스펙대로입니다. Math.random은 한 번도 쓰지 않아 같은 국면에서는 항상 같은 수를 둡니다.
결과 1 — 아레나 경로: 코드 대 코드#
먼저 아레나 조건에서 코드끼리 붙였습니다. 판마다 seed를 바꿨고, 실격은 한 번도 없었습니다.
| 대결 | 판수 | 결과 |
|---|---|---|
| 에이전트 코드(도전) vs 기본 샘플(방어) | 12 | 12전 12승 |
| 에이전트 코드(도전) vs 열린 줄 전략(방어) | 3 | 3전 3승 |
| 열린 줄 전략(도전) vs 에이전트 코드(방어) | 3 | 에이전트 코드 3전 3승 |
열린 줄 전략과 에이전트 코드는 둘 다 무작위가 없어서, 판수를 늘려도 같은 결과가 반복됩니다. 그래서 세 판씩만 돌리되 도전과 방어를 바꿔 양방향으로 돌려 선후공 편향을 제거했습니다. 사람이 화면에서 배워 도달하는 전략을, 스펙만 읽은 에이전트의 코드가 양방향 6전 6승으로 이긴 셈입니다.
결과 2 — 게임 화면 경로: 내장 AI 상대#
다음은 게임 화면 조건입니다. 실제 시계로 돌아가고, 상대는 내장 AI입니다. 아레나 경로와는 조건이 다르므로 두 표의 숫자를 서로 더하거나 비교하지 않습니다.
| 상대 | 판수 | 결과 |
|---|---|---|
| RANDOM | 8 | 8전 8승 |
| LEVEL 1 | 8 | 8전 8승 |
| LEVEL 2 | 8 | 8전 8무 (8판 모두 라운드 3:3) |
| LEVEL 3 | 8 | 8전 6승 2패 |
LEVEL 2를 상대로 8판 전부가 3:3 무승부인 것이 눈에 띕니다. 한 판은 여러 라운드로 이루어지고 라운드마다 선공이 바뀌는데, 양쪽 다 자기가 선공인 라운드는 지키고 후공인 라운드는 내주는 구도가 8판 내내 반복되었다는 뜻입니다.
LEVEL 3을 상대로는 8전 6승 2패입니다. 이런 흔들림은 실제 시계 조건에서 생깁니다. 한 수 1.35초의 예산 안에서 탐색을 얼마나 깊이 하느냐가 그 순간 기기 부하에 따라 달라지고, 그래서 같은 대결을 반복해도 결과가 흔들립니다. 결정론 시계인 아레나 경로에서는 보이지 않던 현상입니다.
측정한 것은 여기까지입니다. 에이전트 코드가 오목을 잘 두는 것은 맞지만, LEVEL 3에 두 판을 졌고 LEVEL 2와는 비겼습니다. “최강"이라 부를 근거는 없습니다.
뜻밖의 발견: 1.35초가 1,350번이 되었다#
결과표보다 필자에게 더 오래 남은 것은 측정 중에 발견한 상호작용 하나입니다.
아레나는 같은 판을 누가 언제 돌려도 똑같이 재현되어야 합니다. 그래서 아레나에서는 Date.now()가 실제 시각을 돌려주지 않고 호출할 때마다 1밀리초씩 전진하는 가짜 시계로 바뀝니다. 그런데 에이전트 코드는 Date.now()로 시간예산을 관리합니다. 두 설계가 만나면 “1.35초 예산"은 “Date.now() 호출 1,350회 예산"으로 바뀝니다. 호출 한 번이 탐색 노드 하나이니, 이 코드는 아레나에서 시간이 아니라 노드 수로 탐색을 끊고 있었습니다.
문제는 1,350번의 탐색이 실제로 얼마나 걸리느냐입니다. 이번 측정에서 아레나 경로의 한 수당 실시간은 평균 약 1.8초였습니다. 아레나의 한 수 제한은 2초입니다. 필자의 기기에서는 통과했지만, 조금만 느린 기기였다면 실격될 수 있었습니다. 스펙 문서의 함정 목록에는 이 내용이 없었습니다. 에이전트는 스펙을 정확히 지켰습니다. 정확하지 않았던 것은 필자의 스펙입니다. 아레나 대전에서는 시계가 결정론적으로 흐른다는 사실을 스펙에 적는 것이 다음 할 일 목록에 올라갔습니다.
이 발견은 아레나 출전판에도 반영했습니다. 아레나 대결은 도전자의 기기에서 실행되므로, 방어 코드가 2초에 가까운 시간을 쓰면 도전자의 기기가 느리다는 이유만으로 실격될 수 있습니다. 그래서 실제로 아레나에 올린 판은 측정한 원본과 한 줄만 다릅니다. 시간예산 1350을 400으로 줄였습니다.
deadline = Date.now() + 400; // 원본은 1350
호출 횟수로 치면 탐색량이 약 3분의 1로 줄어든 셈이라, 출전판은 원본의 결과에 기대지 않고 같은 두 경로로 다시 쟀습니다.
아레나 경로에서는 기본 샘플에 4전 4승, 열린 줄 전략에 도전과 방어 양방향으로 각 3전 3승, 실격 0회였습니다. 수당 실행 시간은 0.1초 안팎으로, 2초 제한까지 열 배 넘는 여유가 생겼습니다. 반면 실제 시계로 도는 게임 화면 경로에서는 RANDOM과 LEVEL 1에 각 6전 6승이지만, LEVEL 2에 6전 1승 1무 4패, LEVEL 3에 6전 6패로 원본보다 뚜렷이 약해졌습니다.
| 경로 | 상대 | 출전판 결과 | 원본 결과 |
|---|---|---|---|
| 아레나 | 기본 샘플 | 4전 4승 | 12전 12승 |
| 아레나 | 열린 줄 전략 (양방향) | 6전 6승 | 6전 6승 |
| 화면 | RANDOM / LEVEL 1 | 각 6전 6승 | 각 8전 8승 |
| 화면 | LEVEL 2 | 6전 1승 1무 4패 | 8전 8무 |
| 화면 | LEVEL 3 | 6전 6패 | 8전 6승 2패 |
같은 한 줄이 아레나에서는 “호출 400번"이고 화면에서는 “0.4초"입니다. 화면에서는 줄인 예산만큼 탐색이 얕아져 LEVEL 2와 LEVEL 3에 밀렸고, 아레나에서는 호출 400번으로 줄어든 탐색으로도 기본 샘플과 열린 줄 전략을 모두 이겼습니다. 지금 아레나에 올라가 있는 것은 원본이 아니라 이 출전판이므로, 도전하면 이 판을 상대하게 됩니다.
배운 것#
세 가지로 정리됩니다.
스펙 문서는 에이전트용 인터페이스로 성립합니다. 훅 다섯 개, 인자 형식, 반환 형식을 문서 하나만 보고 맞췄고 실격이 한 번도 없었습니다. 사람이 화면에서 배우는 경로와 별도로, 문서 하나로 들어오는 경로가 실제로 열린 것입니다.
숫자로 적은 점수표에 탐색을 얹은 코드가, 말로 배운 규칙을 옮긴 코드보다 강했습니다. 열린 줄 전략은 사람이 해설을 읽고 “열린 줄과 막힌 줄을 구분하라"는 규칙을 코드로 옮긴 것입니다. 에이전트 코드는 같은 구분을 점수 비율로 적고, 그 위에 몇 수 앞을 내다보는 탐색을 얹었습니다. 규칙은 사람이 읽기 좋고, 점수표는 기계가 쓰기 좋습니다.
시계는 계약의 일부입니다. 에이전트는 “한 수에 2초"라는 제한을 보고 당연하게 시간예산 코드를 썼습니다. 그 당연한 코드가 결정론 시계와 만나 제한에 아슬아슬하게 닿았습니다. 실행 환경이 시간을 어떻게 다루는지는 스펙에 명시해야 하는 계약이었습니다.
독자에게 넘기는 질문#
이 실험은 에이전트 하나, 게임 하나로 한 것입니다. 열린 질문이 둘 남았습니다.
첫째, 다른 에이전트는 다른 접근을 내놓는가. 같은 스펙 주소와 같은 프롬프트를 다른 코딩 에이전트에게 주면 점수표가 아닌 다른 설계가 나올지 궁금합니다. 스펙 주소는 codebattle.online/specs/omok이고, 프롬프트는 위에 적은 한 줄 그대로입니다. 독자가 쓰는 에이전트에 그대로 넘겨 보셔도 됩니다.
둘째, 사람이 이 코드를 이기는 코드를 짤 수 있는가. 지금 아레나 오목의 방어 자리에는 이 코드의 출전판이 올라가 있습니다. 직접 짜든, 에이전트에게 시키든, 아레나에서 도전해 보실 수 있습니다. 아레나가 낯설다면 게임 화면에서 내장 AI를 상대로 먼저 연습하는 쪽이 편합니다.
누가 이기든 결과는 다음 글의 재료가 됩니다.