같은 기능을 Claude Code와 Codex에게 각자의 worktree·브랜치에서 동시에 맡기고, git log·diff A...B·range-diff·같은 테스트로 두 결과를 비교한 뒤 좋은 부분만 골라 합치는 법을 실제 출력으로 따라갑니다. 통째로 채택(squash), 커밋만 가져오기(cherry-pick -x), 파일 하나 가져오기(restore --source), 세 번째 에이전트에게 합치기를 맡기는 법과 뒷정리, 그리고 이 방법이 오히려 손해인 경우까지 다룹니다.
카페 주문 웹앱 cafe-menu에 메뉴가 늘면서 사장님이 부탁을 하나 했습니다. "손님이 메뉴를 검색할 수 있게 해 주세요. 휴대폰으로 '라테'만 치면 라테 종류가 쭉 나오게요."
민지는 요즘 Claude Code와 Codex를 둘 다 씁니다. 평소라면 둘 중 하나에게 맡겼겠지만, 이번에는 도윤이 이런 제안을 했습니다.
도윤: "검색은 생각보다 고를 게 많아요. 공백을 어떻게 처리할지, 빈 검색어면 뭘 보여 줄지, 초성 검색을 넣을지. 한 에이전트한테만 시키면 그 에이전트가 고른 답만 보게 되니까, 두 에이전트한테 똑같이 시켜 보고 비교해 봐요. 어차피 worktree 쓰면 서로 안 부딪히잖아요."
이번 편은 이 제안을 끝까지 따라가는 편입니다. 같은 일을 두 에이전트에게 각자의 worktree·브랜치에서 맡기고, 결과를 git으로 비교해 좋은 것만 합칩니다. 흔히 "경쟁 구현"이나 "best-of-N(여러 번 시켜서 가장 좋은 것 고르기)"이라고 부르는 방식입니다. 2편에서 worktree를, 3편에서 에이전트에게 worktree를 맡기는 법을 배웠다면, 이번 편은 그 도구로 두 결과를 나란히 놓고 고르는 법입니다.
이 글의 기준 — 터미널 출력은 모두 git 2.55, Node.js 24, Worktrunk 0.79.0에서 직접 실행해 얻은 것입니다. 단, 두 에이전트의 작업은 실제 Claude Code·Codex 세션을 띄운 것이 아니라, 설명을 위해 두 에이전트가 했을 법한 코드와 커밋을 사람이 직접 만들어 재현한 것입니다. 그래서 "Claude가 이렇게 짠다, Codex가 저렇게 짠다"는 성향 비교로 읽으면 안 됩니다. 두 결과가 이런 식으로 갈릴 수 있다는 예시일 뿐이고, 실제로는 같은 에이전트를 두 번 돌려도 결과가 다릅니다. GitHub 대신 내 컴퓨터에 만든 가짜 원격 저장소(git init --bare)를 썼고, 출력 속 경로는 /Users/minji/work/…와 https://github.com/minji/cafe-menu.git으로 바꿔 적었습니다(해시와 구조는 그대로). Claude Code·Codex 옵션은 2026년 9월 기준 공식 문서와 각 도구의 --help로 확인했습니다.
1. 왜 같은 일을 두 번 시키나: 시안을 두 장 받는 이유
인테리어를 맡길 때 업체 한 곳에서만 시안을 받으면, 그 시안이 좋은지 나쁜지 판단할 기준이 없습니다. 두 곳에서 받으면 달라집니다. "A안은 조명이 좋고 B안은 수납이 좋네, 조명은 A로 가고 수납은 B 걸 빌려 오자" 같은 판단이 가능해집니다. 비교 대상이 생기는 순간 내가 무엇을 원하는지도 선명해집니다.
에이전트도 똑같습니다. 에이전트에게 일을 시키면 대개 "그럴듯하게 동작하는" 결과가 나옵니다. 문제는 그럴듯함이 한 가지가 아니라는 점입니다. 검색 기능 하나에도 이런 선택이 숨어 있습니다.
검색어 앞뒤 공백은 지울까? "카페 라테"처럼 가운데 공백은?
빈 검색어면 전체 메뉴를 보여 줄까, 아무것도 안 보여 줄까?
"ㄹㅌ"처럼 초성만 치면 찾아 줄까?
결과가 없을 때 화면에 안내 문구를 띄울까?
한 에이전트는 이 중 몇 가지를 알아서 고르고, 나머지는 조용히 넘어갑니다. 결과만 보면 무엇을 넘어갔는지 알기 어렵습니다. 두 결과를 나란히 놓으면 서로 다르게 고른 지점이 diff로 드러나고, 그 지점이 곧 사람이 결정해야 할 곳입니다.
같은 출발점 main의 커밋 98a455e 같은 요구사항 · 같은 인수 검사
↓ worktree 두 개, 브랜치 두 개
agent/search-claude ../cafe-menu-claude Claude Code 세션
agent/search-codex ../cafe-menu-codex Codex 세션
↓ log · diff · range-diff · 같은 테스트로 비교
좋은 것만 골라 main에 통째로 채택 / 커밋만 가져오기 / 파일만 가져오기 / 세 번째 에이전트
이 방법이 성립하는 이유는 시리즈 첫 원칙 덕분입니다. 작업 1개 = worktree 1개 = 브랜치 1개 = 에이전트 세션 1개. 두 에이전트가 같은 폴더에서 일하면 서로의 파일을 덮어쓰지만, worktree를 나누면 각자 자기 폴더와 자기 브랜치만 만집니다. 결과는 브랜치 두 개로 남고, 브랜치끼리 비교하고 합치는 일은 git이 가장 잘하는 일입니다.
💡
도구들도 이 방식을 지원하기 시작했습니다 — 2026년 9월 기준 Codex CLI 0.157.1의 codex cloud exec --help에는 --attempts(설명: "Number of assistant attempts (best-of-N)")라는 옵션이 있어, 클라우드 작업을 한 번에 여러 번 시도하게 할 수 있습니다. 이번 편은 그런 기능 없이도, 서로 다른 에이전트 둘이든 같은 에이전트 두 세션이든 git만으로 같은 일을 할 수 있다는 것을 보여 줍니다.
2. 출발선 맞추기: 같은 main 커밋에서 worktree 두 개
경주에서 가장 중요한 건 같은 출발선입니다. 한 에이전트는 어제 main에서, 다른 에이전트는 오늘 main에서 출발하면 두 결과의 차이에 "main이 그사이 바뀐 것"까지 섞여 비교가 흐려집니다. 그래서 두 worktree를 같은 커밋에서 만듭니다.
먼저 main이 최신인지 확인하고 지금 커밋을 봅니다.
bash
cd ~/work/cafe-menu
git switch main
git pull
git log --oneline
text
98a455e 주문 합계 계산 orderTotal() 추가
df86c7f 메뉴판 첫 버전
지금 main 끝은 98a455e입니다. 여기서 두 에이전트용 worktree를 하나씩 만듭니다. 2편에서 본 git worktree add -b <새 브랜치> <폴더> <출발점> 형태입니다. 출발점을 main이라고 명시해 두면 지금 어느 브랜치에 있든 같은 곳에서 출발합니다.
bash
git worktree add -b agent/search-claude ../cafe-menu-claude main
git worktree add -b agent/search-codex ../cafe-menu-codex main
text
Preparing worktree (new branch 'agent/search-claude')
HEAD is now at 98a455e 주문 합계 계산 orderTotal() 추가
text
Preparing worktree (new branch 'agent/search-codex')
HEAD is now at 98a455e 주문 합계 계산 orderTotal() 추가
두 줄 모두 HEAD is now at 98a455e입니다. 출발선이 같다는 증거입니다. 한 번 더 확인합니다.
+ agent/search-claude 98a455e 주문 합계 계산 orderTotal() 추가
+ agent/search-codex 98a455e 주문 합계 계산 orderTotal() 추가
* main 98a455e 주문 합계 계산 orderTotal() 추가
git branch -v의 맨 앞 +는 "이 브랜치는 다른 worktree에 체크아웃되어 있다"는 표시이고, *는 지금 폴더의 브랜치입니다. 세 브랜치 모두 같은 98a455e를 가리킵니다.
브랜치 이름: agent/<작업>-<에이전트>
이름은 agent/search-claude, agent/search-codex처럼 지었습니다. 규칙은 단순합니다.
agent/ 로 시작 — 사람이 만든 브랜치와 한눈에 구분되고, 나중에 git branch --list 'agent/*'로 모아 보기 쉽습니다.
작업 이름이 같다 — search가 같으면 같은 경주에 나간 브랜치라는 뜻입니다.
끝에 에이전트 이름 — 결과를 비교할 때 누가 만든 건지 이름만 보고 압니다. 같은 에이전트를 두 번 돌린다면 agent/search-claude-1, -2처럼 번호를 붙입니다.
4편에서 이야기한 브랜치 이름 규칙과도 잘 맞습니다. 여러 Mac을 오가며 이어서 볼 때도 이름만으로 무슨 브랜치인지 알 수 있어야 합니다.
Worktrunk로 만들면
3편에서 소개한 Worktrunk(wt)를 쓰면 한 줄로 worktree와 브랜치를 만들고 바로 에이전트를 띄울 수 있습니다. 실제로는 -x claude, -x codex로 에이전트를 띄우지만, 여기서는 동작만 보여 주려고 -x pwd(현재 폴더 출력)로 대신했습니다.
bash
wt switch -c agent/search-claude -x pwd
text
✓ Created branch agent/search-claude from main and worktree @ /Users/minji/work/cafe-menu.agent-search-claude
↳ To customize worktree locations, run wt config create
▲ Cannot change directory — shell integration not installed
↳ To enable automatic cd, run wt config shell install
◎ Executing (--execute) @ /Users/minji/work/cafe-menu.agent-search-claude:
pwd
/Users/minji/work/cafe-menu.agent-search-claude
from main이라고 출발점이 찍히고, 폴더는 기본 규칙에 따라 cafe-menu.agent-search-claude(저장소 이름 + 점 + 브랜치 이름에서 /를 -로 바꾼 것)에 만들어졌습니다. wt switch --help에 따르면 -c로 만들 때 출발점은 기본 브랜치이고, -b <base>로 바꿀 수 있습니다. 가운데 경고 두 줄은 셸 연동(wt config shell install)을 하지 않아서 터미널이 그 폴더로 자동 이동하지 못했다는 안내입니다. -x로 실행한 프로그램은 그래도 새 worktree 안에서 돕니다. 실제로는 이 자리에서 Claude Code가 뜹니다.
에이전트 자체의 worktree 기능을 써도 됩니다. Claude Code는 claude --worktree search-claude로 .claude/worktrees/search-claude/에 worktree-search-claude 브랜치를 만들어 시작하고(3편), Codex CLI도 --help에 --worktree("Run the session in a new managed Git worktree")가 있습니다. 다만 이번 편처럼 두 결과를 git으로 비교하는 것이 목적이라면, 출발점과 브랜치 이름을 내가 정하는 git worktree add나 wt switch -c가 편합니다. 에이전트마다 브랜치 이름 규칙과 폴더 위치가 달라서, 나중에 비교 명령을 칠 때 헷갈리기 쉽기 때문입니다. Claude Code 문서도 "특정 브랜치에서 시작하거나 저장소 밖에 두려면 git으로 직접 만들라"고 안내합니다.
두 에이전트에게 일을 시키기 전에 해 둘 일이 두 가지 있습니다. 경주가 끝난 뒤에 기준을 정하면, 먼저 본 결과에 끌려가기 쉽기 때문입니다.
약속: 함수 이름과 모양을 정해 준다
"메뉴 검색을 만들어 줘"라고만 하면 한 에이전트는 search(q)를, 다른 에이전트는 filterMenu(list, keyword)를 만들지도 모릅니다. 그러면 같은 테스트를 돌릴 수도 없고, 나중에 한쪽 코드를 다른 쪽에 끼워 넣기도 어렵습니다. 그래서 민지는 바깥에서 보이는 모양만큼은 못 박았습니다.
민지가 정한 약속 (두 에이전트에게 똑같이 전달)
파일과 함수
src/search.js에서 searchMenu(query, menu = MENU)를 export. 반환값은 메뉴 항목 배열.
꼭 되어야 하는 것
이름 일부로 찾기("라테" → 카페라테·바닐라라테) · 앞뒤·가운데 공백과 대소문자 무시 · 빈 검색어면 전체 메뉴 · 없으면 빈 배열 · index.html 검색창 연결
있으면 좋은 것
초성 검색("ㄹㅌ" → 카페라테·바닐라라테)
안쪽 구현(정규화 함수를 따로 둘지, 초성 표를 어떻게 만들지)은 에이전트에게 맡깁니다. 거기서 달라지는 것이 비교할 가치가 있는 부분입니다.
인수 검사: 저장소 밖에 두는 채점표
두 번째는 두 결과에 똑같이 돌릴 검사 스크립트입니다. 각 에이전트는 자기 테스트를 쓰겠지만, 자기가 쓴 테스트는 대개 자기 코드를 통과합니다. 시험 문제를 수험생이 직접 내는 셈이죠. 그래서 채점표는 민지가 따로 씁니다.
민지는 이 파일을 저장소 밖(~/work/checks-search.mjs)에 두었습니다. 이유는 두 가지입니다.
두 브랜치에 똑같이 적용된다 — 저장소 안에 두면 에이전트가 "테스트를 고쳐서 통과시키는" 일이 생길 수 있고, 그러면 두 쪽의 채점표가 달라집니다. 밖에 두면 한 파일을 두 worktree에서 똑같이 돌립니다.
에이전트가 답안을 거기에 맞추지 않는다 — 프롬프트에서 요구사항은 다 알려 주되, 채점 스크립트 자체는 보여 주지 않습니다. 요구사항을 제대로 이해했는지 보려는 것이지, 채점표를 외우게 하려는 게 아닙니다.
현재 폴더의 src/search.js를 불러와 여섯 가지 경우를 확인합니다. worktree마다 그 폴더에서 node ../checks-search.mjs를 치면 같은 문제로 두 답안을 채점할 수 있습니다. 여섯 번째 초성 검사는 "있으면 좋은 것"이라 가산점으로 표시해 두었습니다.
✅
기준을 먼저 적는 습관 — 채점표가 거창할 필요는 없습니다. "꼭 되어야 하는 것" 서너 줄과 이를 확인하는 짧은 스크립트면 충분합니다. 이것만 있어도 비교가 "느낌상 이게 나아 보여"에서 "이쪽은 여섯 개 중 다섯 개, 저쪽은 네 개"로 바뀝니다.
4. 프롬프트 템플릿: 두 에이전트에게 거의 같은 말을
이제 두 에이전트에게 일을 시킬 차례입니다. 핵심은 두 프롬프트가 브랜치 이름과 폴더만 빼고 똑같아야 한다는 점입니다. 한쪽에만 힌트를 더 주면 비교가 공정하지 않습니다.
민지가 Claude Code 세션(~/work/cafe-menu-claude에서 시작)에 붙여 넣은 프롬프트는 이렇습니다.
markdown
너는 cafe-menu 저장소의 worktree ~/work/cafe-menu-claude 에서
브랜치 agent/search-claude 로 일한다.
## 할 일
메뉴 검색 기능을 만든다.
- src/search.js 에서 searchMenu(query, menu = MENU) 를 export
- 이름 일부로 찾기, 앞뒤·가운데 공백과 대소문자 무시
- 빈 검색어면 전체 메뉴, 없으면 빈 배열
- index.html 의 검색창에 연결
- 있으면 좋은 것: 초성 검색 ("ㄹㅌ" → 카페라테, 바닐라라테)
## 지킬 것1. 이 폴더와 agent/search-claude 브랜치에서만 일한다.
다른 브랜치로 switch 하지 말고, 다른 worktree 폴더도 열지 않는다.
2. 다른 agent/* 브랜치는 읽지도 참고하지도 않는다.
3. 요청과 상관없는 파일(예: src/order.js)은 고치지 않는다.
4. 커밋은 작은 단위로 나눈다(기능 / 테스트 / 화면). 메시지는 한국어로.
5. 커밋하기 전에 npm test 를 돌려 모두 통과시킨다.
6. push, merge, rebase, main 수정은 하지 않는다.
7. 끝나면 마지막에 요약을 남긴다:
무엇을 했는지, 무엇을 일부러 안 했는지, 테스트 결과, 남은 걱정.
Codex 세션(~/work/cafe-menu-codex)에는 첫 두 줄의 폴더와 브랜치 이름, 그리고 2번 규칙의 브랜치 이름만 바꿔 똑같이 넣었습니다.
규칙 하나하나에 이유가 있습니다.
1번 "내 브랜치에서만" — 3편에서 본 것처럼 Claude Code는 격리된 worktree 세션에서 main 체크아웃을 건드리는 편집·명령을 막아 줍니다. 다만 공식 문서가 이 차단을 설명하는 대상은 --worktree로 시작했거나 세션 중에 worktree로 들어간(EnterWorktree) 경우입니다. 직접 만든 폴더에 들어가 그냥 claude를 띄운 경우에도 같은 보호가 걸리는지는 문서에서 확인하지 못했으니, 말로라도 경계를 그어 둡니다.
2번 "다른 브랜치를 읽지 말 것" — 같은 저장소의 worktree는 커밋 저장소(.git)를 공유하기 때문에, 에이전트가 마음만 먹으면 git log agent/search-codex로 상대 답안을 볼 수 있습니다. 서로 베끼면 두 번 시킨 의미가 사라집니다. 경주 목적이 "다른 답 두 개"이므로 명시합니다.
3번 "범위 밖 파일 금지" — 에이전트는 친절하게도 보이는 김에 다른 파일을 "정리"하곤 합니다. 그 변경은 리뷰 부담만 늘리고, 두 결과 비교를 흐립니다.
4번 "작은 커밋" — 나중에 상대 브랜치에서 커밋 하나만 가져오려면(cherry-pick) 커밋이 기능별로 나뉘어 있어야 합니다. 뭉친 커밋 하나에서는 좋은 부분만 떼어 올 수 없습니다.
5번 "테스트 통과 후 커밋" — 적어도 자기 테스트는 통과한 상태로 결과를 받습니다.
6번 "push·merge 금지" — 합치는 결정은 사람이 합니다.
7번 "요약" — 두 결과를 비교할 때 가장 먼저 읽는 것이 이 요약입니다. 특히 "일부러 안 한 것"이 중요합니다. 에이전트가 초성 검색을 건너뛰었다면, 몰라서인지 판단해서인지 여기서 드러납니다.
아래 위젯에 할 일과 제약을 적으면, 두 에이전트에게 줄 프롬프트 한 쌍을 만들어 줍니다. 브랜치 이름과 폴더만 다르고 나머지는 글자 하나까지 같게 나옵니다.
⚠️
worktree마다 준비물 확인 — worktree는 새로 꺼낸 체크아웃이라, git이 추적하지 않는 파일(.env, node_modules)은 따라오지 않습니다(2편). 이 예제는 의존성이 없어 바로 npm test가 되지만, 실제 프로젝트라면 각 worktree에서 npm install을 먼저 하거나 .worktreeinclude(3편)로 .env를 복사해 두세요. 두 에이전트가 같은 포트로 개발 서버를 띄우면 한쪽이 실패하니, 포트도 나눠 주는 편이 좋습니다.
5. 두 에이전트가 일하는 동안: 각자의 브랜치에 쌓인 커밋
두 에이전트가 끝났다는 요약을 남겼습니다. 앞서 밝혔듯 여기서 보여 주는 코드와 커밋은 설명을 위해 직접 만든 것입니다. 결과를 보는 첫 명령은 "main에는 없고 이 브랜치에만 있는 커밋" 입니다. 민지는 원래 폴더(~/work/cafe-menu, main)에 그대로 있으면서 두 브랜치를 봅니다. worktree들이 커밋 저장소를 공유하므로, 어느 폴더에서든 모든 브랜치가 보입니다.
d00b862 화면: 검색창을 메뉴 목록에 연결
2225ba2 테스트: searchMenu 다섯 가지 경우
b6495e3 검색: 빈 검색어면 전체 메뉴 반환
c220c7d 검색: searchMenu() 추가 — 이름 부분 일치
text
1cfd766 정리: order.js 화살표 함수로 스타일 통일
0413c64 화면: 검색창과 '찾는 메뉴가 없어요' 안내
c8b3f22 검색: searchMenu() — 이름·초성 검색
1d727fe 유틸: 한글 초성 추출 getChosung() 추가
main..agent/search-claude(점 두 개)는 "agent/search-claude에서 닿을 수 있는 커밋 중 main에서 닿을 수 있는 것은 뺀다", 즉 이 브랜치가 새로 만든 커밋만이라는 뜻입니다. 둘 다 커밋 네 개씩입니다. 메시지만 읽어도 성격이 보입니다.
Claude 쪽 — 기능을 만들고, 빈 검색어 처리를 따로 한 커밋으로 더하고, 테스트와 화면을 나눴습니다. 초성 검색 커밋은 없습니다.
Codex 쪽 — 초성 추출 유틸을 먼저 따로 만들었고(좋은 신호입니다, 떼어 오기 쉽습니다), 화면에 "찾는 메뉴가 없어요" 안내를 넣었습니다. 그런데 마지막 커밋이 order.js 스타일 정리입니다. 프롬프트 3번에서 금지한 범위 밖 변경입니다.
커밋 메시지는 에이전트의 요약만큼이나 중요한 첫 단서입니다. 이 단계에서 벌써 "Codex 쪽 마지막 커밋은 빼야겠다"는 판단이 하나 생겼습니다.
Claude 쪽은 파일 3개, Codex 쪽은 파일 6개입니다. Codex 쪽에는 초성 유틸(src/hangul.js)과 그 테스트가 더 있고, 건드리지 말라던 src/order.js가 끼어 있습니다. 변경 범위(scope) 점수를 매길 때 이 목록이 근거가 됩니다. 테스트 코드 줄 수도 보입니다. Claude 쪽 테스트가 27줄, Codex 쪽 검색 테스트가 15줄입니다.
점 두 개와 점 세 개, 무엇이 다른가
여기서는 main...agent/search-claude처럼 점 세 개를 썼습니다. git diff에서 점 개수는 뜻이 다릅니다.
쓰는 법
뜻
이럴 때
git diff A B (= A..B)
A의 끝과 B의 끝을 그대로 비교
두 결과물 자체를 맞대어 볼 때 (예: 두 에이전트 브랜치끼리)
git diff A...B
A와 B가 갈라진 지점(공통 조상)부터 B까지 바뀐 것
"이 브랜치가 한 일"만 볼 때. 그사이 main이 앞서 나가도 main 쪽 변경이 섞이지 않음
git log A..B
B에만 있는 커밋 목록
브랜치가 만든 커밋을 셀 때
지금은 두 브랜치가 모두 main 끝(98a455e)에서 출발했고 main이 그대로라서, 점 두 개든 세 개든 결과가 같습니다. 하지만 경주 중에 도윤이 main에 커밋을 올리면 달라집니다. 점 두 개로 비교하면 도윤의 변경까지 "지워진 것처럼" 섞여 나옵니다. "브랜치가 한 일"을 볼 때는 점 세 개라고 기억해 두면 안전합니다. 기존 시리즈를 읽었다면 GitHub PR의 Files changed 탭이 보여 주는 것이 바로 이 점 세 개 비교입니다(6편).
두 결과를 맞대기: git diff A B -- 경로
이번에는 두 에이전트 브랜치를 직접 비교합니다. 어차피 둘 다 같은 기능이라, 같은 파일을 어떻게 다르게 짰는지가 궁금합니다. -- 뒤에 경로를 주면 그 파일만 봅니다.
diff --git a/src/search.js b/src/search.js
index 80a8148..5209f0f 100644
--- a/src/search.js
+++ b/src/search.js
@@ -1,12 +1,11 @@
import { MENU } from "./menu.js";
-
-// 검색어와 메뉴 이름을 같은 모양으로 맞춘다: 앞뒤·가운데 공백 제거, 소문자로
-function normalize(text) {
- return text.trim().toLowerCase().replace(/\s+/g, "");
-}
+import { getChosung, isChosungOnly } from "./hangul.js";
export function searchMenu(query, menu = MENU) {
- const q = normalize(query ?? "");
- if (q === "") return menu; // 빈 검색어면 전체 메뉴
- return menu.filter((item) => normalize(item.name).includes(q));
+ const q = query.trim().toLowerCase();
+ return menu.filter((item) => {
+ const name = item.name.toLowerCase();
+ if (isChosungOnly(q)) return getChosung(name).includes(q);
+ return q.length > 0 && name.includes(q);
+ });
}
-가 Claude 쪽(앞에 적은 브랜치), +가 Codex 쪽(뒤에 적은 브랜치)입니다. 이 diff는 "무엇이 바뀌었나"가 아니라 "두 답안이 어디서 갈렸나" 로 읽어야 합니다. 읽어 보면 결정 지점 세 개가 보입니다.
결정 지점
Claude 쪽
Codex 쪽
공백 처리
normalize()로 앞뒤·가운데 공백 모두 제거
trim()만, 가운데 공백은 남음
빈 검색어
전체 메뉴 반환
q.length > 0 조건 때문에 빈 배열
초성 검색
없음
hangul.js로 지원
벌써 결론의 윤곽이 보입니다. "기본기는 Claude 쪽, 초성은 Codex 쪽." 하지만 눈으로 읽은 것은 추측입니다. 테스트로 확인하기 전에 도구를 하나 더 보겠습니다.
커밋끼리 짝 맞춰 보기: git range-diff
git range-diff는 커밋 묶음 두 개를 커밋 단위로 짝지어 비교합니다. git range-diff main A B처럼 쓰면 main..A의 커밋들과 main..B의 커밋들을 놓고, 내용이 비슷한 커밋끼리 짝을 지어 줍니다. 공식 문서의 설명은 "두 버전의 패치 묶음, 또는 더 일반적으로 두 커밋 범위의 차이를 보여 준다"입니다.
bash
git range-diff main agent/search-claude agent/search-codex
text
1: c220c7d < -: ------- 검색: searchMenu() 추가 — 이름 부분 일치
2: b6495e3 < -: ------- 검색: 빈 검색어면 전체 메뉴 반환
3: 2225ba2 < -: ------- 테스트: searchMenu 다섯 가지 경우
4: d00b862 < -: ------- 화면: 검색창을 메뉴 목록에 연결
-: ------- > 1: 1d727fe 유틸: 한글 초성 추출 getChosung() 추가
-: ------- > 2: c8b3f22 검색: searchMenu() — 이름·초성 검색
-: ------- > 3: 0413c64 화면: 검색창과 '찾는 메뉴가 없어요' 안내
-: ------- > 4: 1cfd766 정리: order.js 화살표 함수로 스타일 통일
한 줄이 커밋 하나입니다. 가운데 기호만 읽으면 됩니다.
기호
뜻
=
양쪽에 똑같은 커밋이 있다 (메시지·내용 동일)
!
짝은 맞는데 내용이나 메시지가 조금 다르다 (아래에 차이가 붙어 나옴)
<
앞쪽 범위(A)에만 있다
>
뒤쪽 범위(B)에만 있다
결과는 전부 <와 >, 짝이 하나도 없습니다. 당연합니다. 두 에이전트가 따로 짰으니 비슷한 커밋이 있을 리 없습니다. 이 결과 자체가 정보입니다. "둘은 정말 독립적으로 풀었다, 서로 베끼지 않았다"는 확인이니까요.
그럼 range-diff는 언제 진가를 발휘할까요? 같은 브랜치의 전과 후를 비교할 때입니다. 에이전트에게 "리뷰 반영해서 고쳐 줘"라고 했더니 에이전트가 커밋을 다시 쓰고(rebase·amend) 새로 올렸다면, 전과 후를 range-diff로 보면 "커밋 1·2는 그대로(=), 3은 고쳐졌고(!), 5가 새로 생겼다(>)"가 한눈에 보입니다. 공식 문서에 나오는 예가 바로 rebase 직후 git range-diff my-topic@{u} my-topic@{1} my-topic입니다. 이번 편 9장에서 합친 결과를 Claude 원본과 비교할 때 이 쓰임새를 실제로 보게 됩니다.
7. 비교 2 — 정말 되나: 같은 테스트를 각 worktree에서
눈으로 읽은 결론을 테스트로 확인합니다. worktree의 장점이 여기서 드러납니다. 브랜치를 오가며 switch할 필요 없이, 폴더만 옮겨 다니며 같은 명령을 칩니다.
각자 쓴 테스트: 둘 다 통과
먼저 각 에이전트가 쓴 테스트(npm test)입니다. Claude 쪽 worktree에서:
bash
cd ~/work/cafe-menu-claude
npm test
text
> test
> node --test --test-reporter=spec
✔ 주문 합계 (0.389916ms)
✔ 없는 메뉴는 에러 (0.123958ms)
✔ 이름 일부로 찾는다 (0.815375ms)
✔ 앞뒤 공백과 대소문자를 무시한다 (0.1385ms)
✔ 빈 검색어면 전체 메뉴 (0.081792ms)
✔ 없는 메뉴면 빈 배열 (0.551708ms)
✔ 원본 메뉴 배열을 바꾸지 않는다 (0.072916ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63.145708
Codex 쪽 worktree에서:
bash
cd ~/work/cafe-menu-codex
npm test
text
> test
> node --test --test-reporter=spec
✔ 초성 추출 (0.440583ms)
✔ 초성만으로 된 검색어 판별 (0.121625ms)
✔ 주문 합계 (0.402584ms)
✔ 없는 메뉴는 에러 (0.132709ms)
✔ 부분 일치 (0.51775ms)
✔ 초성 검색 (0.428875ms)
✔ 대소문자 무시 (0.081917ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 58.342458
둘 다 pass 7 · fail 0입니다. 원래 있던 주문 테스트 두 개도 통과하니, 적어도 기존 기능은 안 망가뜨렸습니다. 하지만 이것만 보면 "둘 다 잘했네"로 끝납니다. 자기가 낸 문제를 자기가 푼 결과이기 때문입니다. Codex 쪽 테스트에는 빈 검색어 경우가 아예 없습니다.
같은 채점표: 여기서 갈린다
이제 3장에서 저장소 밖에 둔 채점표를 두 worktree에서 똑같이 돌립니다.
bash
cd ~/work/cafe-menu-claude
node ../checks-search.mjs
text
PASS 부분 일치 '라테'
PASS 공백·대소문자 ' EARL grey '
PASS 가운데 공백 '카페 라테'
PASS 빈 검색어 → 전체
PASS 없는 메뉴 '녹차'
FAIL 초성 'ㄹㅌ' (있으면 가산점) (받음: "")
5/6 통과
bash
cd ~/work/cafe-menu-codex
node ../checks-search.mjs
text
PASS 부분 일치 '라테'
PASS 공백·대소문자 ' EARL grey '
FAIL 가운데 공백 '카페 라테' (받음: "")
FAIL 빈 검색어 → 전체 (받음: "")
PASS 없는 메뉴 '녹차'
PASS 초성 'ㄹㅌ' (있으면 가산점)
4/6 통과
6장에서 diff로 읽은 추측이 그대로 확인됐습니다.
Claude 쪽 5/6 — 꼭 되어야 하는 다섯 가지를 모두 통과. 가산점인 초성만 없음.
Codex 쪽 4/6 — 가산점 초성은 통과했지만, 꼭 되어야 하는 가운데 공백과 빈 검색어에서 실패.
흥미로운 점은 Codex 쪽이 화면에서는 빈 검색어 문제를 숨겼다는 것입니다. index.html에서 검색어가 비면 searchMenu를 부르지 않고 전체 메뉴를 직접 보여 주도록 짜 두었습니다. 화면만 눌러 봤다면 문제를 못 봤을 겁니다. 함수의 약속을 직접 검사해야 보이는 문제입니다. 이것이 채점표를 따로 두는 이유입니다.
💡
자기 테스트 통과는 출발선일 뿐 — 에이전트가 "테스트 전부 통과했습니다"라고 하면 거짓말은 아닙니다. 다만 그 테스트를 누가 썼는지 봐야 합니다. 에이전트가 쓴 테스트는 에이전트가 이해한 요구사항을 검사하고, 사람이 쓴 채점표는 사람이 원한 요구사항을 검사합니다. 둘이 어긋나는 곳이 바로 버그가 숨는 곳입니다.
테스트 결과만으로 고르면 "통과 개수가 많은 쪽"이 이깁니다. 하지만 main에 들어갈 코드는 앞으로 사람이 계속 읽고 고칠 코드이기도 합니다. 민지는 네 가지 기준으로 5점 만점 점수를 매겼습니다.
기준
무엇을 보나
agent/search-claude
agent/search-codex
정확성
채점표 통과 (필수 5 + 가산 1)
4 — 필수 5/5, 초성 없음
3 — 필수 3/5, 초성 있음
테스트
자기 테스트가 요구사항을 얼마나 덮나
5 — 빈 검색어·없는 메뉴·원본 불변까지 5개
3 — 3개, 빈 검색어 없음. 초성 유틸 테스트는 좋음
변경 범위
요청한 파일만 고쳤나
5 — 3파일, 모두 요청 범위
2 — 요청 밖 order.js 수정
읽기 쉬움
커밋 단위·이름·주석
4 — 작은 커밋, normalize 주석
4 — 초성 유틸을 따로 뗀 구조가 좋음
합계
18 / 20
12 / 20
합계만 보면 Claude 쪽이 크게 이겼습니다. 그렇다고 Codex 쪽을 통째로 버리기는 아깝습니다. 표에서 Codex 쪽이 이긴 칸이 두 군데 있습니다. 초성 검색(hangul.js, 따로 뗀 커밋이라 가져오기도 쉽습니다)과 화면의 "찾는 메뉴가 없어요" 안내입니다. 이렇게 "총점은 한쪽이 이겼지만, 진 쪽에도 확실히 더 나은 조각이 있다" 일 때가 섞어 합치기의 전형적인 상황입니다.
✅
결정 규칙을 미리 정해 두면 편합니다 — 민지 팀은 이렇게 정했습니다. ① 필수 요구사항을 다 통과한 쪽이 하나뿐이면 그쪽이 기반. ② 진 쪽에 기반보다 확실히 나은 조각이 있고, 그 조각이 커밋이나 파일 단위로 떨어져 있으면 가져온다. ③ 둘 다 필수를 못 채우면 채택하지 않고, 둘의 좋은 점을 적어 세 번째 시도에 넘긴다. 아래 위젯이 바로 이 규칙으로 추천을 계산합니다. 점수를 바꿔 가며 어느 순간 "통째로 채택"이 "섞어 합치기"로 바뀌는지 보세요.
9. 합치는 네 가지 방법
결과를 골랐다면 main에 넣을 차례입니다. 상황에 따라 네 가지 방법이 있습니다. 먼저 한 장으로 보고, 민지가 실제로 쓴 조합을 따라가겠습니다.
방법
명령
이럴 때
주의
① 한쪽 통째로 채택
git merge --squash 브랜치 또는 PR의 Squash and merge
한쪽이 모든 면에서 낫다
커밋이 하나로 합쳐짐. 브랜치 삭제는 -D
② 커밋만 골라 오기
git cherry-pick -x 해시
진 쪽의 좋은 부분이 커밋 하나로 떨어져 있다
새 해시로 복사됨. 기반과 같은 파일이면 충돌 가능
③ 파일 하나 가져오기
git restore --source=브랜치 -- 파일
진 쪽의 특정 파일이 통째로 더 낫다
그 파일의 기반 쪽 변경은 덮어써짐. 커밋 기록은 안 따라옴
④ 세 번째 에이전트에게 합치기
새 worktree + 두 브랜치를 읽게 하는 프롬프트
두 쪽 장점이 코드 곳곳에 뒤섞여 있다
또 한 번의 비용과 리뷰. 원본 두 브랜치는 보존
아래 위젯에서 상황을 고르면 추천 방법과 명령, 합친 뒤의 커밋 그래프 모양을 보여 줍니다.
민지의 상황은 "Claude 쪽이 기반, Codex 쪽에서 초성 커밋 하나와 index.html 하나"입니다. 그래서 ②와 ③을 쓴 뒤 ①의 squash로 main에 넣었습니다. 순서대로 보겠습니다.
합칠 브랜치를 따로 만든다: search/final
에이전트가 만든 브랜치를 바로 고치지 않고, Claude 쪽 브랜치 끝에서 새 브랜치를 하나 뗍니다. 원본 두 브랜치는 비교용으로 그대로 남겨 두기 위해서입니다. 합치다가 꼬이면 새 브랜치만 버리고 다시 시작하면 됩니다.
bash
cd ~/work/cafe-menu
git switch -c search/final agent/search-claude
text
Switched to a new branch 'search/final'
agent/search-claude는 이미 ../cafe-menu-claude에 체크아웃되어 있어서 이 폴더에서 git switch agent/search-claude는 거절됩니다(같은 브랜치를 두 worktree에 체크아웃할 수 없다, 2편). 하지만 그 브랜치에서 출발하는 새 브랜치를 만드는 것은 문제없습니다.
방법 ②: 초성 커밋만 cherry-pick
Codex 쪽 1d727fe 유틸: 한글 초성 추출 getChosung() 추가는 src/hangul.js와 그 테스트를 새로 만들기만 한 커밋입니다. 기존 파일을 건드리지 않으니 충돌 없이 가져올 수 있습니다. 커밋 하나를 골라 지금 브랜치에 똑같이 한 번 더 적용하는 cherry-pick입니다(자세한 개념은 「Git, 이것만 알면 협업한다」 8편). 어디서 왔는지 남기려고 -x를 붙입니다.
bash
git cherry-pick -x 1d727fe
text
[search/final e7574f6] 유틸: 한글 초성 추출 getChosung() 추가
Date: Sun Oct 11 09:07:00 2026 +0900
2 files changed, 29 insertions(+)
create mode 100644 src/hangul.js
create mode 100644 test/hangul.test.js
bash
git log -1 --format='%H%n%s%n%n%b'
text
e7574f64dd1ba380c1ed5c31ae9aa1a93d17020e
유틸: 한글 초성 추출 getChosung() 추가
(cherry picked from commit 1d727fe56cb5a0a66bb28250105b1b191cd4fe89)
원래 해시는 1d727fe였지만 search/final에는 e7574f6이라는 새 해시로 들어왔습니다. 부모 커밋이 다르면 다른 커밋입니다. 메시지 끝의 (cherry picked from commit …)이 -x가 적어 준 출처입니다. 경쟁 구현에서는 이 한 줄이 특히 쓸모 있습니다. 나중에 "이 초성 코드는 어느 에이전트 답안에서 왔지?"를 커밋만 보고 알 수 있기 때문입니다. 원본 커밋을 계속 찾을 수 있게 9장 끝에서 Codex 브랜치에 태그를 남겨 둘 겁니다.
그렇다면 Codex 쪽의 c8b3f22 검색: searchMenu() — 이름·초성 검색은 왜 안 가져올까요? 그 커밋은 src/search.js를 처음부터 새로 쓴 커밋이라, Claude 쪽 src/search.js와 정면으로 부딪힙니다. 게다가 가운데 공백·빈 검색어 문제도 같이 딸려 옵니다. 좋은 부분(초성 판단 한 줄)만 필요하니, 그건 직접 연결하는 편이 낫습니다.
연결은 짧게 직접: 초성 분기 한 줄
hangul.js는 가져왔지만 아직 searchMenu가 쓰지 않습니다. Claude 쪽 searchMenu에 초성 분기 한 줄과 테스트 하나를 더했습니다(실제로는 이 브랜치에서 Claude Code에게 "hangul.js의 isChosungOnly로 초성 검색을 연결하고 테스트를 추가해 줘"라고 짧게 시켜도 됩니다).
bash
git diff
text
diff --git a/src/search.js b/src/search.js
index 80a8148..8a87a47 100644
--- a/src/search.js
+++ b/src/search.js
@@ -1,4 +1,5 @@
import { MENU } from "./menu.js";
+import { getChosung, isChosungOnly } from "./hangul.js";
// 검색어와 메뉴 이름을 같은 모양으로 맞춘다: 앞뒤·가운데 공백 제거, 소문자로
function normalize(text) {
@@ -8,5 +9,6 @@ function normalize(text) {
export function searchMenu(query, menu = MENU) {
const q = normalize(query ?? "");
if (q === "") return menu; // 빈 검색어면 전체 메뉴
+ if (isChosungOnly(q)) return menu.filter((item) => getChosung(item.name).includes(q));
return menu.filter((item) => normalize(item.name).includes(q));
}
diff --git a/test/search.test.js b/test/search.test.js
index 2b2a904..b8007d1 100644
--- a/test/search.test.js
+++ b/test/search.test.js
@@ -25,3 +25,7 @@ test("원본 메뉴 배열을 바꾸지 않는다", () => {
searchMenu("아메", menu);
assert.equal(menu.length, 1);
});
+
+test("초성으로 찾는다", () => {
+ assert.deepEqual(names(searchMenu("ㄹㅌ")), ["카페라테", "바닐라라테"]);
+});
bash
git add -A
git commit -m "검색: 초성만 입력하면 초성으로 찾기"
초성 분기를 빈 검색어 확인 뒤, 일반 검색 앞에 넣었습니다. Claude 쪽의 normalize가 공백을 이미 지웠으니 "ㄹ ㅌ"처럼 띄어 쳐도 초성 검색이 됩니다. 두 답안의 장점이 한 함수 안에서 맞물린 셈입니다.
방법 ③: index.html은 파일째 Codex 것으로
화면은 Codex 쪽이 낫습니다. 검색창에 <label>이 있어 화면 낭독기로도 읽히고, "찾는 메뉴가 없어요" 안내가 있습니다. 이 파일은 커밋 하나에 깔끔히 담겨 있긴 하지만, 두 쪽이 같은 파일을 고쳤으니 cherry-pick하면 충돌이 납니다. 파일 통째로가 낫다고 판단했다면 다른 브랜치의 파일 하나를 지금 작업 폴더로 꺼내 오는git restore --source가 가장 간단합니다(8편에서 옛 커밋의 파일을 되살릴 때 쓴 바로 그 명령입니다. 출처가 옛 커밋 대신 다른 브랜치일 뿐입니다).
파일을 통째로 가져오면 좋은 것과 함께 그 파일의 나머지도 따라옵니다. 여기서는 Codex 쪽이 빈 검색어 문제를 피하려고 넣은 우회 코드(q.trim() === "" ? MENU : …)도 함께 왔습니다. 이제 searchMenu가 빈 검색어를 제대로 처리하니 필요 없는 우회지만, 동작에는 문제가 없어서 민지는 일단 그대로 두고 나중에 정리하기로 했습니다. 파일째 가져올 때는 꼭 diff를 한 번 읽고 이런 딸려 온 부분을 확인하세요.
예전 방식은 git checkout 브랜치 -- 파일 — 같은 일을 하는 옛 명령입니다. 인터넷 답변에서 자주 보이는데, 동작은 같지만 파일을 바로 스테이징까지 해 버린다는 점이 다릅니다. git 2.23부터 파일 되돌리기는 restore, 브랜치 바꾸기는 switch로 나뉘었으니 새로 배운다면 restore를 쓰세요. --staged --worktree를 함께 주면 checkout처럼 스테이징까지 한 번에 합니다.
합친 결과도 같은 채점표로
합친 결과가 정말 좋아졌는지는 같은 테스트로 확인합니다.
bash
git log --oneline main..search/final
text
1bd745e 화면: 검색창 마크업은 Codex안 채택 (라벨·결과 없음 안내)
f4c5a0c 검색: 초성만 입력하면 초성으로 찾기
e7574f6 유틸: 한글 초성 추출 getChosung() 추가
d00b862 화면: 검색창을 메뉴 목록에 연결
2225ba2 테스트: searchMenu 다섯 가지 경우
b6495e3 검색: 빈 검색어면 전체 메뉴 반환
c220c7d 검색: searchMenu() 추가 — 이름 부분 일치
bash
npm test
text
> test
> node --test --test-reporter=spec
✔ 초성 추출 (0.439583ms)
✔ 초성만으로 된 검색어 판별 (0.146292ms)
✔ 주문 합계 (0.428417ms)
✔ 없는 메뉴는 에러 (0.138833ms)
✔ 이름 일부로 찾는다 (0.858834ms)
✔ 앞뒤 공백과 대소문자를 무시한다 (0.138834ms)
✔ 빈 검색어면 전체 메뉴 (0.834792ms)
✔ 없는 메뉴면 빈 배열 (0.188667ms)
✔ 원본 메뉴 배열을 바꾸지 않는다 (0.088166ms)
✔ 초성으로 찾는다 (0.128416ms)
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63.063625
bash
node ../checks-search.mjs
text
PASS 부분 일치 '라테'
PASS 공백·대소문자 ' EARL grey '
PASS 가운데 공백 '카페 라테'
PASS 빈 검색어 → 전체
PASS 없는 메뉴 '녹차'
PASS 초성 'ㄹㅌ' (있으면 가산점)
6/6 통과
테스트 10개 모두 통과, 채점표 6/6. 두 답안 어느 쪽도 혼자서는 못 받은 점수입니다. Claude 쪽의 테스트 5개, Codex 쪽의 초성 유틸 테스트 2개, 새로 연결하며 더한 1개가 함께 돕니다.
무엇이 더해졌나: range-diff의 진짜 쓰임새
6장에서 약속한 range-diff의 제 쓰임새입니다. Claude 원본 브랜치와 합친 브랜치를 커밋 단위로 비교합니다.
bash
git range-diff main agent/search-claude search/final
text
1: c220c7d = 1: c220c7d 검색: searchMenu() 추가 — 이름 부분 일치
2: b6495e3 = 2: b6495e3 검색: 빈 검색어면 전체 메뉴 반환
3: 2225ba2 = 3: 2225ba2 테스트: searchMenu 다섯 가지 경우
4: d00b862 = 4: d00b862 화면: 검색창을 메뉴 목록에 연결
-: ------- > 5: e7574f6 유틸: 한글 초성 추출 getChosung() 추가
-: ------- > 6: f4c5a0c 검색: 초성만 입력하면 초성으로 찾기
-: ------- > 7: 1bd745e 화면: 검색창 마크업은 Codex안 채택 (라벨·결과 없음 안내)
이번에는 =가 네 줄입니다. "Claude 쪽 커밋 네 개는 하나도 안 바뀌고 그대로 있다, 그 위에 세 개가 더해졌다." 리뷰어(도윤)에게 PR을 보낼 때 이 출력을 붙여 두면, 도윤은 이미 본 커밋 네 개를 건너뛰고 새 커밋 세 개만 보면 됩니다. 만약 합치는 과정에서 Claude 쪽 커밋 하나를 고쳤다면 그 줄은 !로 나오고 아래에 무엇이 달라졌는지 붙어 나옵니다.
진 쪽에서 무엇이 아직 안 들어왔나: git cherry
반대 방향도 확인해 둡니다. git cherry -v <기준> <브랜치>는 브랜치의 커밋마다 같은 내용이 기준에 이미 들어갔는지를 표시합니다. -는 이미 들어감(해시가 달라도 내용이 같으면), +는 아직 안 들어감입니다.
bash
git cherry -v search/final agent/search-codex
text
- 1d727fe56cb5a0a66bb28250105b1b191cd4fe89 유틸: 한글 초성 추출 getChosung() 추가
+ c8b3f22ce4c36a7438ff092d290b9b6579187b87 검색: searchMenu() — 이름·초성 검색
+ 0413c6489fd75cc01a7bed61ad7ac05901aa0c9e 화면: 검색창과 '찾는 메뉴가 없어요' 안내
+ 1cfd766ca1ed4747e9703529c1800842ffc779ee 정리: order.js 화살표 함수로 스타일 통일
cherry-pick한 초성 커밋은 해시가 e7574f6으로 달라졌는데도 -로 나옵니다. git이 커밋 내용(패치)을 비교하기 때문입니다. 나머지 세 개는 +입니다. index.html은 파일째 가져왔지만 커밋 단위로 같지 않으니(Claude 쪽 변경 위에 덮었으니) +로 남습니다. 이 목록을 보며 "c8b3f22는 일부러 버림, 0413c64는 파일로 가져옴, 1cfd766은 범위 밖이라 버림"을 하나씩 확인하면, 빠뜨린 좋은 조각이 없는지 점검할 수 있습니다.
방법 ①: squash로 main에 한 커밋
마지막으로 main에 넣습니다. search/final에는 커밋이 일곱 개 있지만 main에서 보면 "메뉴 검색 추가"라는 한 가지 일입니다. 그래서 여러 커밋을 하나로 뭉쳐 넣는 squash merge를 씁니다.
bash
git switch main
git merge --squash search/final
text
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
Fast-forward라는 말이 보여 헷갈릴 수 있는데, 다음 줄 Squash commit -- not updating HEAD가 핵심입니다. git이 "그냥 앞으로 당겨도(fast-forward) 되는 상황"임을 알아챘지만, --squash라서 main을 옮기지는 않고 결과만 스테이징해 두었다는 뜻입니다. 변경 파일 목록에 src/order.js가 없다는 것도 확인하세요. Codex의 범위 밖 변경은 한 줄도 들어오지 않았습니다.
bash
git status --short
text
M index.html
A src/hangul.js
A src/search.js
A test/hangul.test.js
A test/search.test.js
모두 스테이징된 상태(M·A가 왼쪽 칸)입니다. 이제 커밋 메시지에 경주의 결과를 남깁니다. squash 커밋은 원래 커밋들을 main 역사에 남기지 않으니, 어디서 무엇을 가져왔는지를 메시지에 적어 두는 것이 나중에 도움이 됩니다.
bash
git commit -F - <<'MSG'
메뉴 검색 추가 (이름·공백 무시·초성)
두 에이전트 경쟁 구현 결과를 합침.
- 기반: agent/search-claude (searchMenu, 테스트 5개)
- 가져옴: agent/search-codex의 초성 유틸(cherry-pick), index.html
- 버림: order.js 스타일 변경(범위 밖)
MSG
git log --oneline --graph --all -12
text
* 5f55876 메뉴 검색 추가 (이름·공백 무시·초성)
| * 1bd745e 화면: 검색창 마크업은 Codex안 채택 (라벨·결과 없음 안내)
| * f4c5a0c 검색: 초성만 입력하면 초성으로 찾기
| * e7574f6 유틸: 한글 초성 추출 getChosung() 추가
| * d00b862 화면: 검색창을 메뉴 목록에 연결
| * 2225ba2 테스트: searchMenu 다섯 가지 경우
| * b6495e3 검색: 빈 검색어면 전체 메뉴 반환
| * c220c7d 검색: searchMenu() 추가 — 이름 부분 일치
|/
| * 1cfd766 정리: order.js 화살표 함수로 스타일 통일
| * 0413c64 화면: 검색창과 '찾는 메뉴가 없어요' 안내
| * c8b3f22 검색: searchMenu() — 이름·초성 검색
| * 1d727fe 유틸: 한글 초성 추출 getChosung() 추가
|/
그래프를 읽어 봅시다. 맨 위 5f55876이 main에 새로 생긴 커밋 하나입니다. 옆의 두 갈래(search/final의 일곱 커밋, Codex의 네 커밋)는 main과 선으로 이어지지 않았습니다. squash merge는 내용만 가져오고 브랜치와의 연결(머지 커밋의 두 번째 부모)은 만들지 않기 때문입니다. 이 사실이 뒷정리에서 중요해집니다.
bash
git push origin main
text
To https://github.com/minji/cafe-menu.git
98a455e..5f55876 main -> main
🔀
팀이 PR로 일한다면 — 로컬 squash 대신 search/final을 push하고 PR을 연 뒤, GitHub의 Squash and merge 버튼을 누르면 같은 결과가 됩니다(기존 시리즈 6편). 명령으로는 git push -u origin search/final → gh pr create --base main --head search/final → 리뷰 후 gh pr merge --squash 순서입니다. PR 설명란에 두 에이전트의 요약, 채점표 결과, range-diff 출력을 붙여 두면 리뷰어가 "왜 이렇게 합쳤는지"를 바로 압니다. 6편의 원칙대로 main 보호가 켜진 저장소라면 이 경로가 기본입니다.
방법 ④: 세 번째 에이전트에게 합치기를 맡긴다면
민지는 좋은 조각이 커밋·파일 단위로 깔끔히 떨어져 있어서 손으로 합쳤습니다. 하지만 두 답안의 장점이 한 파일 안에 뒤섞여 있다면(예: A의 정규화 방식 + B의 캐시 구조 + A의 에러 처리) cherry-pick도 restore도 딱 맞지 않습니다. 그럴 때는 세 번째 세션에게 "두 브랜치를 읽고 합친 버전을 새로 만들라"고 맡길 수 있습니다. 이번에는 읽기를 허용한다는 점이 경주 때와 반대입니다.
새 worktree는 main에서 새로 출발시킵니다.
bash
git worktree add -b agent/search-merge ../cafe-menu-merge main
text
Preparing worktree (new branch 'agent/search-merge')
HEAD is now at 98a455e 주문 합계 계산 orderTotal() 추가
그리고 이런 프롬프트를 줍니다.
markdown
너는 worktree ~/work/cafe-menu-merge 에서 브랜치 agent/search-merge 로 일한다.
같은 요구사항(메뉴 검색)을 두 브랜치가 따로 구현했다.
- agent/search-claude : 공백·빈 검색어 처리가 정확하고 테스트가 충실함
- agent/search-codex : 초성 검색(src/hangul.js)과 '결과 없음' 화면이 좋음
단, src/order.js 변경은 범위 밖이므로 가져오지 않는다.
두 브랜치를 git show / git diff 로 읽기만 하고(switch 하지 말 것),
둘의 장점을 합친 구현을 이 브랜치에 새로 커밋하라.
- 약속: src/search.js 의 searchMenu(query, menu = MENU)
- 두 브랜치의 테스트를 모두 가져와 전부 통과시킬 것
- 가져온 부분마다 커밋 메시지에 출처 브랜치를 적을 것
- 끝나면 무엇을 어느 쪽에서 가져왔는지 표로 요약
결과는 다시 같은 채점표로 확인하고, 괜찮으면 squash로 넣습니다. 이 방법은 편하지만 한 번 더 비용이 들고, 리뷰할 diff가 하나 더 생깁니다. 합치는 에이전트가 두 답안의 버그까지 합칠 수도 있습니다. 그래서 원본 두 브랜치는 끝까지 지우지 않고 비교 기준으로 둡니다. 이번 예제에서는 쓰지 않았으니 바로 치웠습니다.
worktree remove는 성공하면 아무 말도 하지 않습니다. 목록에 main 폴더만 남았습니다. 폴더를 지워도 브랜치와 커밋은 그대로라는 점이 중요합니다. worktree는 작업대일 뿐이고, 커밋은 공유 저장소(.git)에 있습니다. 커밋하지 않은 수정이 남아 있으면 git이 제거를 거절하는데, 그때는 정말 필요 없는지 확인한 뒤에만 --force를 씁니다(2편).
-d가 거절하는 이유: squash의 뒷면
이제 브랜치를 지웁니다. 먼저 합쳐진 search/final부터.
bash
git branch -d search/final
text
error: the branch 'search/final' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D search/final'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
방금 main에 합쳤는데 "완전히 합쳐지지 않았다"고 합니다. 9장의 그래프를 떠올리면 이해됩니다. squash merge는 search/final의 내용만 새 커밋으로 가져왔고, search/final의 커밋들 자체는 main의 역사에 들어가지 않았습니다. git branch -d는 "이 브랜치의 커밋이 모두 main의 역사에 들어 있나"를 보는데, 들어 있지 않으니 지우면 커밋을 잃는다고 경고하는 것입니다.
내용이 정말 다 들어갔는지는 main과 브랜치의 파일 차이로 확인합니다.
bash
git diff main search/final --stat
아무것도 출력되지 않습니다. 두 쪽의 파일이 완전히 같다는 뜻입니다. 이제 안심하고 -D(--delete --force의 줄임, "합쳐졌는지 확인하지 말고 지워라")를 씁니다.
진 쪽 브랜치는 태그로 남기고 지운다
Codex 쪽 브랜치는 초성 커밋 하나만 가져왔고 나머지는 버렸습니다. 그래도 나중에 "그때 Codex는 어떻게 짰더라?"가 궁금할 수 있고, cherry-pick 메시지에 적힌 원본 해시 1d727fe를 찾아가려면 그 커밋이 저장소에 남아 있어야 합니다. 브랜치를 지우면 그 커밋은 이름표가 없어져 언젠가 git의 청소(gc)에 쓸려 갈 수 있습니다. 그래서 지우기 전에 태그를 하나 붙여 둡니다. 태그는 움직이지 않는 이름표라서, 브랜치 목록을 어지럽히지 않으면서 커밋을 붙잡아 둡니다.
bash
git tag archive/search-codex agent/search-codex
git branch -D search/final agent/search-claude agent/search-codex
git branch
git tag
git log --oneline -1 archive/search-codex
text
* main
text
archive/search-codex
text
1cfd766 정리: order.js 화살표 함수로 스타일 통일
브랜치는 main 하나만 남았고, Codex 답안은 archive/search-codex 태그로 언제든 다시 볼 수 있습니다(git show archive/search-codex:src/search.js). -D 출력의 (was 1bd745e)도 기억해 두면 좋습니다. 실수로 지웠다면 git branch search/final 1bd745e로 바로 되살릴 수 있습니다.
agent/search-claude는 태그 없이 지웠습니다. 내용은 squash 커밋에 다 들어갔고, 커밋 하나하나를 다시 볼 일은 드뭅니다. 필요하다고 생각되면 똑같이 태그를 붙이면 됩니다. 이 태그는 기록용이라 원격에 올릴지는 팀이 정합니다. 올린다면 git push origin archive/search-codex입니다. 태그는 따로 push하지 않으면 올라가지 않습니다(8편).
⚠️
에이전트에게 뒷정리를 맡길 때 — git branch -D와 git worktree remove --force는 되돌리기 어려운 명령입니다(기존 시리즈 7편의 위험 명령 목록). 에이전트에게 맡긴다면 "지우기 전에 git diff main 브랜치 --stat이 비었는지 확인하고, 진 브랜치는 archive/ 태그를 먼저 붙여라"를 규칙으로 적어 두세요. 원격에 push해 둔 브랜치라면 git push origin --delete agent/search-codex로 원격 쪽도 따로 지워야 합니다.
11. 비용과 "하지 말아야 할 때"
지금까지 보면 매번 두 에이전트를 경주시키고 싶어질지 모릅니다. 하지만 이 방법에는 분명한 비용이 있습니다.
💸
토큰과 요금이 대략 두 배
같은 일을 두 번 시키니 에이전트 사용량도 두 번입니다. 세 번째 에이전트로 합치면 그 이상입니다. 요금제 한도가 있는 경우 경주 한 번이 하루 사용량의 큰 몫을 먹을 수 있습니다.
👀
리뷰가 두 배 이상
에이전트 시간은 싸지만 사람의 리뷰 시간은 비쌉니다. diff 두 개를 읽고, 서로 비교하고, 합친 결과를 또 읽어야 합니다. 리뷰어가 지치면 가장 중요한 "합친 결과"를 대충 보게 됩니다.
🧩
섞으면 새 버그가 생길 수 있다
각자 따로는 맞던 코드가 합치면 어긋날 수 있습니다. 이번 예제의 index.html처럼 필요 없는 우회 코드가 딸려 오기도 합니다. 합친 뒤 채점표를 다시 돌리는 단계를 건너뛰면 안 되는 이유입니다.
그래서 민지 팀은 이런 기준을 씁니다.
경주시킬 만한 일
한 에이전트로 충분한 일
설계 선택지가 여러 개인 기능 (검색, 캐시, 상태 관리)
답이 사실상 하나인 일 (오타, 의존성 버전 올리기, 이름 바꾸기)
어떤 접근이 나을지 사람도 확신이 없을 때
이미 설계가 정해져 있고 손만 필요한 일
채점표(테스트)를 미리 쓸 수 있는 일
좋고 나쁨을 사람이 직접 봐야만 알 수 있는 일 (디자인 취향 등, 이 경우 시안 비교는 되지만 git 비교의 이점이 적음)
틀리면 비용이 큰 핵심 코드
30분이면 사람이 고칠 작은 일
변경이 수십~수백 줄로 읽을 만한 크기
수천 줄짜리 대규모 변경 (두 개를 비교해 읽을 수 없음)
작은 일에 경주를 붙이면, 에이전트 둘이 30초씩 일하고 사람이 10분 동안 비교하는 이상한 일이 벌어집니다. 반대로 핵심 기능에서 한 에이전트의 첫 답을 그대로 받아들이면, 그 에이전트가 조용히 건너뛴 선택지를 영영 모른 채 지나갑니다. "비교할 가치가 있는 선택지가 있는가" 를 먼저 묻는 것이 요령입니다.
✅
비용을 줄이는 요령 — ① 경주는 설계가 갈리는 핵심 부분만 시키고, 화면 연결 같은 뒷부분은 이긴 쪽에 이어서 맡긴다. ② 에이전트 요약에 "일부러 안 한 것"을 쓰게 해서, diff를 읽기 전에 차이를 먼저 파악한다. ③ 채점표를 스크립트로 만들어 사람 눈 대신 기계가 먼저 거른다. ④ 같은 에이전트 두 번보다 서로 다른 에이전트 둘이 답이 더 다양하게 갈리는 경우가 많지만, 이는 경험칙이지 보장이 아니다.
자주 하는 질문(FAQ)
Q1. 서로 다른 에이전트(Claude·Codex) 대신 같은 에이전트를 두 번 돌려도 되나요?
됩니다. 브랜치 이름만 agent/search-claude-1, agent/search-claude-2처럼 번호로 나누면 이번 편의 모든 명령이 그대로 통합니다. 같은 에이전트라도 매번 결과가 조금씩 다르고, 프롬프트를 살짝 바꿔(예: "성능 우선" vs "읽기 쉬움 우선") 두 방향을 비교하는 것도 좋은 쓰임새입니다.
Q2. 두 에이전트를 같은 Mac에서 동시에 돌려도 괜찮나요?
worktree를 나눴다면 파일은 부딪히지 않습니다. 다만 두 에이전트가 같은 포트로 개발 서버를 띄우거나, 같은 데이터베이스·캐시 폴더를 쓰면 충돌합니다. 포트를 나누고(3편의 Worktrunk hash_port), 공유 자원은 worktree별로 분리하세요. 한쪽을 다른 Mac에서 돌리고 싶다면 4편처럼 브랜치를 push해 두고 그 Mac에서 fetch하면 됩니다.
Q3. 에이전트가 경주 중에 상대 브랜치를 몰래 보면 어떻게 알 수 있나요?
완벽히 막을 방법은 없습니다. worktree들은 .git을 공유하므로 git log --all 한 번이면 다 보입니다. 에이전트 세션 기록에서 상대 브랜치 이름이 나오는 명령이 있었는지 보거나, 6장처럼 range-diff에서 짝이 맞는 커밋(=·!)이 나오는지로 짐작할 수는 있습니다. 확실히 떼어 놓고 싶다면 한쪽을 별도로 clone한 폴더에서 돌리는 방법이 있습니다. 대신 합칠 때 그 clone에서 push·fetch로 브랜치를 가져와야 합니다.
Q4. squash 말고 일반 merge로 넣으면 안 되나요?
됩니다. 일반 merge(git merge --no-ff search/final)를 쓰면 에이전트의 작은 커밋들이 main 역사에 그대로 남고, 브랜치를 -d로 지울 수 있습니다. 대신 cherry-pick한 커밋, "WIP"성 커밋까지 main에 남아 역사가 길어집니다. 경쟁 구현은 "여러 시도 끝에 나온 결과 하나"라서 squash가 잘 맞는 편이지만, 팀 방침이 우선입니다(기존 시리즈 6편·8편).
Q5. cherry-pick하다 충돌이 나면요?
기존 시리즈 8편과 똑같이 처리합니다. 충돌 표시를 풀고 git add → git cherry-pick --continue, 그만두려면 git cherry-pick --abort. 다만 경쟁 구현에서는 두 답안이 같은 파일을 서로 다르게 새로 썼을 때 충돌이 크게 나기 쉽습니다. 충돌이 크면 억지로 풀지 말고 파일째 가져오기(restore --source)나 직접 연결, 또는 세 번째 에이전트로 방향을 바꾸는 편이 빠릅니다.
Q6. 두 결과가 다 별로면요?
둘 다 채택하지 않습니다. 대신 이번 경주에서 배운 것을 남깁니다. "빈 검색어 처리를 둘 다 놓쳤다"처럼 두 답안이 공통으로 놓친 점을 채점표와 프롬프트에 추가하고, 다음 시도(세 번째 세션 또는 사람)에 넘기세요. 브랜치는 archive/ 태그로 남겨 두면 참고가 됩니다. 두 에이전트가 똑같이 헤맸다면 요구사항 자체가 모호했을 가능성이 큽니다.
이번 편 요약(치트시트)
하고 싶은 일
명령
기억할 점
같은 출발선에서 worktree 두 개
git worktree add -b agent/X-claude ../repo-claude main
출발점 main 명시. 두 출력의 HEAD is now at 해시가 같은지 확인
Worktrunk로 한 줄에
wt switch -c agent/X-claude -x claude
기본 출발점은 기본 브랜치, -b로 변경
브랜치가 만든 커밋
git log --oneline main..브랜치
점 두 개 = 이 브랜치에만 있는 커밋
브랜치가 한 일(변경 범위)
git diff --stat main...브랜치
점 세 개 = 갈라진 지점부터. main이 앞서가도 안전
두 답안 맞대기
git diff A B -- src/파일
-는 A, +는 B. "어디서 갈렸나"로 읽기
커밋 단위로 짝 맞추기
git range-diff main A B
= 같음 · ! 조금 다름 · < A만 · > B만
같은 채점표로 비교
각 worktree에서 npm test + node ../check.mjs
채점표는 저장소 밖에. 자기 테스트 통과는 출발선
합칠 브랜치 따로 만들기
git switch -c search/final agent/X-claude
원본 두 브랜치는 비교용으로 보존
커밋 하나 가져오기
git cherry-pick -x 해시
새 해시로 복사. -x로 출처 기록
파일 하나 가져오기
git restore --source=브랜치 -- 파일
그 파일 전체가 바뀜. diff로 딸려 온 부분 확인. 옛 방식 git checkout 브랜치 -- 파일
아직 안 가져온 커밋 점검
git cherry -v 기준 브랜치
- 이미 들어감(내용 기준) · + 아직
main에 한 커밋으로
git merge --squash 브랜치 → git commit
메시지에 기반·가져온 것·버린 것 기록. PR이면 Squash and merge
worktree 치우기
git worktree remove ../폴더
브랜치·커밋은 남음
squash한 브랜치 지우기
git diff main 브랜치 --stat(빈 출력 확인) → git branch -D 브랜치
squash 후엔 -d가 거절하는 게 정상
진 답안 보관
git tag archive/X-codex agent/X-codex
브랜치 목록은 깔끔하게, 커밋은 붙잡아 두기
다음 편 예고: 에이전트가 길을 잃지 않는 저장소
이번 편에서 민지는 프롬프트마다 "npm test를 돌려라", "order.js는 건드리지 마라", "worktree마다 .env를 챙겨라"를 반복해서 적었습니다. 에이전트가 둘, 셋으로 늘면 이 반복이 금세 지겨워지고, 한 번 빠뜨리면 한쪽 에이전트만 다른 규칙으로 일하게 됩니다.
6편 「에이전트가 길을 잃지 않는 저장소」에서는 이런 규칙을 저장소 안의 파일로 옮깁니다. 어떤 에이전트든 처음에 읽는 AGENTS.md와 CLAUDE.md, 도구 버전과 명령을 고정하는 mise.toml, 비밀을 안전하게 다루는 .env.example, 커밋 전에 테스트를 강제하는 훅과 CI까지. 프롬프트에 매번 쓰던 것들이 저장소에 들어가면, 경주에 나가는 모든 에이전트가 같은 규칙으로 출발합니다.