---
title: '사내 PR 20건으로 <em>SWE-bench 문항</em> 36개 만들기'
tags:
  - ai
  - testing
published: true
date: 2026-11-01 06:17:00
description: '사내 WebSocket 패키지의 병합된 PR을 코딩 에이전트 평가 문항으로 바꾸고 채점기를 검증한 뒤, Claude Code 실행 환경에서 개인 지침을 격리하기까지의 과정'
series: '코딩 에이전트 모델 교체 실험'
seriesOrder: 2
art:
  undraw: data-table
---

## Table of Contents

## 실행 기록에 내 호칭이 들어 있었다

첫 실행의 기록을 훑다가 Claude Code가 나를 부르는 호칭을 발견했다. 에이전트에게 준 것은 작업 디렉터리와 문제 설명뿐이었는데, 평소 Claude Code에 쓰던 전역 지침 파일이 함께 들어가 있었다. `HOME`과 `CLAUDE_CONFIG_DIR`을 실행마다 빈 디렉터리로 바꿔 두었기 때문에 개인 설정은 들어가지 않을 것이라고 생각했다.

원인은 Claude Code가 지침 파일을 찾는 범위였다. [문서](https://code.claude.com/docs/en/memory)에 따르면 프로젝트 지침은 `./CLAUDE.md` 또는 `./.claude/CLAUDE.md`에 둘 수 있다. 상위 디렉터리에서 찾은 지침도 파일 시스템 루트부터 작업 디렉터리 순서로 이어 붙인다. 작업 디렉터리가 `~/works/...`라면 그 위에 있는 `~/.claude/CLAUDE.md`도 탐색 범위에 들어간다. 실제 작업 경로를 따라 찾으므로 `HOME`만 바꿔서는 이를 막을 수 없다.

문서에서는 이 탐색 과정에서 사용자 지침 파일인 `~/.claude/CLAUDE.md`를 따로 취급한다고 설명한다. 그런데 `HOME`과 `CLAUDE_CONFIG_DIR`을 빈 디렉터리로 바꾸면 사용자 지침 경로도 그 디렉터리를 가리킨다. 원래 홈에 있던 파일은 사용자 지침이 아닌, 상위 디렉터리의 `.claude/CLAUDE.md`로 읽힌 것으로 보인다.

홈 아래 작업 디렉터리에서 에이전트에게 "어떤 지침을 받았는지 첫 줄을 인용하라"고 하자 전역 지침 파일의 첫 줄을 그대로 답했다. `/Users/Shared/` 아래에서는 그 지침이 없다고 답했다. 재실행은 `/Users/Shared/` 아래에서 했고, 출력에 호칭이 0건인 것을 확인했다. 문서에 있는 `claudeMdExcludes` 설정으로 상위 디렉터리의 특정 지침 파일을 제외할 수도 있다.

[1편](/2026/10/swe-bench-before-swapping-models)에서는 공개 SWE-bench의 TypeScript 문항이 MUI와 Vue 두 저장소에 편중되어 있다는 점을 살펴봤다. 그 점수만으로 Claude Code의 모델 교체 여부를 판단하기는 어려워서, 사내 저장소에서 같은 형식의 문항을 만들기로 했다. 병합된 PR 20건으로 문항 36개를 만들고 채점기를 검증하는 데 나흘, 첫 실행에 사흘, 개인 지침을 격리한 뒤 다시 실행하는 데 하루가 들었다. 이 글에서는 문항과 실행 환경을 만드는 과정을 다룬다. 모델별 결과와 교체 여부는 3편에서 살펴본다.

> 문항과 채점기는 사내 코드와 정답 패치를 포함하고 있어 공개하지 않는다. 실행별 집계(모델, 회차, 문항 번호를 가린 식별자, 판정, 턴, 토큰, 품질 지표)는 [blog-experiments의 swe-bench-inhouse](https://github.com/yceffort/blog-experiments/tree/main/swe-bench-inhouse)에 CSV로 올렸다. 실행은 2026년 10월 23일부터 26일 사이에 macOS에서 했고, Claude Code는 `claude -p`로 실행했다. Claude Code는 자동 업데이트되는 상태로 사용했고, 결과 데이터에 버전을 남기지 않아 npm 배포 시각으로 역산했다. 격리 재실행이 있던 10월 26일 기준 최신 배포본은 2.1.310이고, 첫 실행 사흘 동안은 2.1.308에서 2.1.310 사이였다.

## 병합된 PR이 20건 있는 패키지

대상은 사내 모노레포의 WebSocket 패키지다. 서버 로그인, 데이터 구독과 해제, 메시지 직렬화, 연결이 끊겼을 때의 재연결과 회복을 담당한다. 접속 주소와 인증 정보는 외부에서 주입받으므로 특정 앱에 묶여 있지 않다.

이 패키지에는 테스트와 구현을 함께 바꾼 병합 PR이 20건 있었다. SWE-bench 방식에서는 PR이 추가한 테스트로 채점하므로, 구현만 고친 PR은 사용할 수 없다. 2026년 5월 말부터 9월 중순까지 병합된 PR 가운데 테스트 파일 변경이 포함된 것을 고르니 20건이 남았다.

화면이나 브라우저 없이 vitest만으로 동작을 검증할 수 있다는 점도 중요했다. 패키지 안에 모의 WebSocket 서버와 PUSH 루프를 흉내 내는 테스트 도우미가 있어서 연결과 재연결 시나리오를 단위 테스트로 재현할 수 있다. 연결, 로그인, 구독, 재연결의 순서와 타이밍을 다루다 보니 한 줄만 고쳐서 끝나는 문제도 적었다. 모델 사이의 차이를 살펴보기에 적당하다고 생각했다.

공개 TypeScript 문항에서는 정답 패치가 `.ts` 구현 파일을 고치지 않는 경우가 전체의 3분의 1이었다. 이번에 고른 사내 문항은 모두 실제 구현 파일을 고친다. 다만 패키지 하나에서 나온 36문항이므로 평가 결과도 이 패키지에 한정해 해석해야 한다.

## PR 20건이 문항 36개가 되기까지

문항은 1편에서 정한 절차에 따라 만들었다. PR의 출발 커밋을 `base`로 삼고, 변경 사항을 테스트 파일 변경(테스트 패치)과 나머지(정답 패치)로 나눈다. 패치를 적용하지 않은 상태, 테스트 패치만 적용한 상태, 두 패치를 모두 적용한 상태에서 각각 테스트를 돌린다. 테스트 패치만 적용하면 실패하고 정답 패치까지 적용하면 통과하는 테스트가 f2p(fail-to-pass), 세 상태에서 모두 통과하는 테스트가 p2p(pass-to-pass)다. 문항 하나에는 base 커밋, 문제 설명, 테스트 패치, f2p와 p2p 목록이 들어간다. 정답 패치는 채점에 쓰지 않고 검증용으로만 보관한다.

20건 중 2건은 문항이 되지 못했다. 하나는 패키지 구조를 크게 바꾸면서 테스트 18개를 고친 개선 PR이었고, 다른 하나는 도구가 자동으로 만든 취약점 수정 PR이었다. 둘 다 "이슈를 읽고 버그를 고친다"는 문항의 형식에 맞지 않았다.

남은 18건 가운데 7건은 리뷰 지적 여러 개를 한꺼번에 고치거나 릴리스 변경을 모은 PR이었다. 이를 문항 하나로 만들면 문제 설명에 서로 무관한 버그 대여섯 개가 들어가고, 하나만 못 고쳐도 미해결로 처리된다. 그래서 7건을 주제별로 쪼갰다.

쪼갠 문항은 같은 base, 같은 테스트 패치, 같은 정답 패치를 쓰되 f2p만 해당 주제의 테스트로 좁혔다. 테스트 이름에 들어 있는 문자열로 주제를 나누고, 원본 f2p 각각이 정확히 한 주제에만 속하는지 스크립트로 확인했다. p2p는 원본과 같다. 다른 주제의 테스트는 base에서 원래 실패하던 것이므로 p2p에 포함되지 않고, 해당 문항의 판정에도 영향을 주지 않는다.

예를 들어 리뷰 지적을 모아 고친 PR 하나는 "버전 문자열의 구성 요소가 2자리로 잘리는 문제"와 "로그 마스킹 함수가 일반 객체가 아닌 클래스 인스턴스를 망가뜨리는 문제" 두 문항이 됐다. 릴리스 PR 하나는 대기 중인 요청까지 버리는 종료 처리, 오류 객체의 로그 직렬화, 토큰을 기다리는 동안 죽은 소켓으로 로그인을 시도하는 문제, 놓친 종료 이벤트 처리, 모듈이 두 번 로드될 때 상태가 갈라지는 문제의 다섯 문항이 됐다. 각각이 독립된 이슈로 보고될 법한 내용이다.

<LiveDemo src="/demos/swe-bench/split-items.html?present=1" title="묶음 PR 하나를 주제별 문항 3개로 쪼개는 구조. 세 문항은 base, 테스트 패치, 정답 패치를 공유하고 f2p 만 주제별로 좁히며, 정답 패치에서 주제 파일만 추린 부분 정답으로 채점기를 검증한다" height={640} />

도식의 뷰어 메뉴는 영어이고, 라벨은 글을 위해 일반화한 것이다.

쪼갠 문항이 해당 주제만 고쳐서 풀리는지도 확인했다. 정답 패치에서 그 주제의 파일만 모은 부분 정답을 만들어 채점하니 25문항 모두 해결로 나왔고, 빈 패치로는 모두 미해결이었다. 다만 4문항은 다른 주제에서 새로 만든 이름을 import하고 있어서 부분 정답에 다른 주제의 파일도 포함했다. 이 4문항은 다른 주제의 테스트를 바꾸지 않아도 풀린다는 것까지 확인했다. 해당 주제의 코드만 고쳐서 풀리는지는 검증하지 못했다.

그중 한 문항은 로그인 성공 경로에서 다른 주제가 추가한 메서드를 호출해야 했다. 이 의존 관계는 문제 설명과 인터페이스 명세에 적었다. 쪼개서 나온 문항 하나는 제외했다. 테스트가 새 이름을 import해서 실패할 뿐, 요구하는 동작은 base에 이미 있었다. 사실상 이름만 바꾸면 되는 문제였다.

최종적으로 쪼개지 않은 원본 11건과 쪼갠 문항 25건, 모두 36문항을 주 세트로 삼았다. 쪼개기 전 원본 7건도 보관했지만, 같은 테스트를 중복해서 세지 않도록 주 세트에서는 뺐다. 같은 PR에서 나온 문항은 코드 영역을 공유하므로 독립 표본으로 볼 수 없다. 36문항의 f2p는 모두 167개다. 문항당 1개인 것이 12문항, 10개 이상인 것이 7문항이고, p2p는 문항에 따라 142개에서 366개 사이다.

36문항으로 해결률의 작은 차이까지 구분하기는 어렵다. 1편에서는 문항을 독립 표본으로 가정하고 해결률 50%에서 95% 신뢰구간의 반폭을 $1.96\sqrt{\frac{p(1-p)}{n}}$으로 계산했다. 같은 식에 36을 넣으면 ±16.3%p인데, 이번 문항들은 그 독립성 가정도 만족하지 못한다. 따라서 해결률의 소수점 차이보다는 각 모델이 어떤 문항을 풀고 어디서 실패했는지에 주목했다. 3편에서도 문항별 결과를 함께 제시한다.

## 36문항은 무엇을 고치라고 하는가

36문항을 수정 대상에 따라 나눠 봤다. 문항 식별자는 3편과 같은 익명 기호이며, 같은 글자는 같은 PR에서 나온 문항을 가리킨다. 영역 구분은 글을 위해 직접 정했다.

| 영역                        | 문항 수 | 문항                                          | 예                                                                  |
| --------------------------- | ------: | --------------------------------------------- | ------------------------------------------------------------------- |
| 연결, 재연결, 회복, 요청 큐 |      12 | A1, A3, E1, E3, K1, K4, K5, L1, L3, L4, P3, Q | 재연결을 전부 실패한 뒤 외부 신호 없이는 영구히 끊긴 채 남음        |
| 로그인                      |       5 | E2, K3, L5, M, P2                             | 백그라운드에서 로그인에 실패하면 재연결이 반복되어 소켓이 계속 바뀜 |
| 구독과 전달                 |       6 | A2, C, F, G, I, R                             | 같은 대상을 구독하는 화면끼리 전달 주기가 서로 영향을 줌            |
| 메시지 해석과 직렬화        |       4 | H, J, N, P1                                   | 정수 필드가 숫자가 아닌 객체로 디코딩됨                             |
| 설정과 로그                 |       4 | D1, D2, K2, L2                                | 버전 문자열의 구성 요소가 2자리로 잘림                              |
| 테스트 도우미와 디버그 도구 |       5 | B1, B2, B3, B4, O                             | 디버그 도구를 늦게 마운트하면 시작 직후의 이벤트가 보이지 않음      |

연결과 로그인, 회복에 관한 문항이 절반 가까이 된다. 이 패키지에서는 연결 상태가 바뀌는 경계에서 버그가 많이 나왔고, 문항에도 그 특성이 반영됐다. 테스트 도우미 문항 4개는 한 PR에서 나왔다. 패키지가 제공하는 모의 소켓과 PUSH 루프 도우미를 고치는 문제여서 서비스 코드의 버그와는 성격이 다르다. 채점이 성립하는 PR은 전부 포함했으므로, 36문항의 구성은 "이 패키지에서 실제로 고친 것"에 가깝다. 이를 "프론트엔드 업무" 전반의 분포로 보기는 어렵다.

## 문제 설명만 주는 문항과 명세를 함께 주는 문항

문항을 만들다 보니 테스트의 성격이 둘로 나뉘었다. 어떤 테스트는 호출 횟수나 반환값처럼 외부에서 관찰할 수 있는 동작을 확인한다. 다른 테스트는 새 API의 이름이나 모듈 내부의 상태를 직접 확인한다. 후자는 문제 설명만으로 풀기 어렵다. 내부 함수를 이름으로 지정해 spy를 붙이거나 새 설정 필드의 이름까지 일치하는지 검사한다면, 동작을 올바르게 고쳐도 그 이름을 쓰지 않는 한 테스트에 실패한다.

이 문제를 공개 벤치마크는 두 가지 방식으로 다뤘다. SWE-bench Verified는 검수 과정에서 이슈에 없는 이름을 요구하는 문항을 버렸고, SWE-Bench Pro는 이름과 시그니처를 알려 주는 interface를 문항에 포함해 다른 문항과 하나의 해결률로 합산했다. 여기서는 Pro와 같은 방식을 택하되 점수를 따로 셌다. 문제 설명만 주는 문항을 기본 트랙(basic, 22문항), 인터페이스 명세를 함께 주는 문항을 명세 트랙(spec, 14문항)으로 나눴다. 트랙 이름과 구분은 이 실험에서 정한 것이고 공개 벤치마크의 표준 등급은 아니다.

인터페이스 명세에는 테스트가 의존하는 항목마다 모듈 경로, 시그니처, 의미를 적었다. 항목 하나는 대략 이런 모양이다.

```text
## `SubscribeOptions.windowTime`

- 모듈: `src/types.ts` (`SubscribeOptions` 타입)
- 시그니처: `options?: { windowTime?: number; legacyWindowTime?: number }`
- 의미: 이 구독에만 적용되는 묶음 윈도우(ms). 옛 옵션은 deprecated 별칭이며,
  둘을 같이 주면 새 옵션이 우선한다. 아무것도 없으면 앱 기본값을 따른다.
  옛 옵션은 base 에도 있었지만 의미가 연결 단위에서 구독 단위로 바뀐다.
```

식별자는 글을 위해 바꿨지만 구조는 그대로다. 명세를 주면 고칠 위치도 상당 부분 알려 주게 되므로, 명세 트랙과 기본 트랙의 해결률은 따로 집계하기로 했다. 3편에서도 이 구분을 유지한다. 그렇다고 명세 트랙이 항상 쉬운 것은 아니었다. 가장 어려운 문항도 이 트랙에 있었는데, 옵션의 의미가 연결 단위에서 구독 단위로 바뀌면서 13개 파일을 고쳐야 했다.

## 문제 설명은 정답을 본 사람이 쓴다

문제 설명에는 증상, 재현 절차, 기대 동작을 적고 해법은 적지 않았다. 1편에서 인용한 SWE-Bench+의 분석처럼, 이슈 본문에 해법이 있으면 답을 알려 준 채 평가하게 되기 때문이다. 실제 PR 본문에는 대개 해법이 적혀 있어 참고만 하고 그대로 옮기지는 않았다. 정답을 찾을 단서가 될 수 있는 PR 번호와 링크도 제외했다.

기본 트랙의 한 문항은 이런 내용이다. 식별자와 도메인 용어는 바꿨다.

```text
# 묶음 전달 옵션을 켜면 즉시 전달해야 하는 알림 일부가 사라집니다

## 현상
연결할 때 묶음 윈도우를 지정하면, 최신값만 있으면 되는 메시지뿐 아니라 건별 알림까지
윈도우 안에서 합쳐져 마지막 1건만 전달됩니다. 건별 알림은 사용자 단위로 구독하기 때문에
한 사용자의 모든 건이 같은 구독 키로 묶이고, 짧은 시간에 여러 건이 오면
중간 건이 영구히 유실됩니다.

## 재현
1. 윈도우 100ms 로 연결하고 로그인까지 마칩니다.
2. 건별 알림 코드를 사용자 키로 구독합니다.
3. 같은 사용자의 알림 3건(seq 1, 2, 3)을 연달아 받습니다.
4. 콜백은 즉시 호출되지 않고, 100ms 뒤 seq 3 으로 1번만 호출됩니다.

## 기대 동작
- 건별 알림 세 종류는 윈도우와 관계없이 합치지 않고, 들어오는 즉시 한 건도
  빠짐없이 순서대로 콜백에 전달해야 합니다.
- 위 재현 절차에서는 타이머가 흐르기 전에 콜백이 3번 호출되어야 합니다.
- 그 밖의 메시지는 지금처럼 윈도우 단위로 합쳐 최신값만 전달합니다.
```

채점에서는 PR이 추가한 테스트 4개가 통과해야 하고 기존 테스트 253개가 그대로 통과해야 한다. 어디를 고쳐야 하는지는 적지 않았지만, 무엇이 잘못됐고 무엇이 맞는 동작인지는 테스트가 확인하는 수준까지 적었다.

테스트가 요구하는 수치는 문제 설명에도 남겨야 했다. 어떤 문항의 테스트는 묶음 윈도우의 시점과 호출 횟수가 정해진 값과 일치하는지 검사한다. 이때 "몇 ms 안에 몇 번"이라는 조건을 빼면, 설명에는 어긋나지 않지만 테스트에는 실패하는 해법이 나올 수 있다. 테스트가 확인하지 않는 설계 선택은 에이전트에게 맡겼다. 정답 패치와 다른 방법을 택해도 테스트를 통과하면 해결로 인정한다.

정답 패치를 보고 문제 설명을 쓰면 해법의 구조를 암시하기 쉽다. 그래서 검토 단계를 따로 뒀다. 기본 트랙은 직접 작성했고, 그중 4건은 base에 테스트 패치만 적용했을 때의 실패 메시지와 대조했다. 명세 트랙은 에이전트가 쓴 초안을 전부 검토한 뒤 7건을 같은 방법으로 대조했다. 테스트의 요구 사항이 설명에 빠졌는지, 설명이 테스트에서 확인하지 않는 것까지 요구하는지를 살폈다. 테스트가 의존하는 새 이름과 새 모듈이 인터페이스 명세에 빠짐없이 들어 있는지는 스크립트로 확인했다.

검토 후에는 해법의 구조를 암시하던 문장을 지우고, 의존 패키지의 타입 정의를 수정하면 해당 패키지도 다시 빌드해야 한다는 안내를 넣었다. base에 이미 있는 동작에는 "현재 코드와 같음, 유지"라고 표시했다. 마지막 안내는 뒤에서 설명할 테스트의 연쇄 실패 때문에 필요했다. 쪼갠 문항의 설명과 명세는 검토한 원본에서 해당 주제만 떼어 단독 이슈로 다듬었다.

## 모델보다 채점기를 먼저 시험했다

모델을 실행하기 전에 채점기부터 시험했다. 실제로 모델을 비교할 때보다 채점기를 검증하는 과정에서 해결해야 할 문제가 더 많았다.

채점기는 에이전트의 패치를 base에 적용하고, 그 위에 PR의 테스트 패치를 얹어 테스트를 돌린다. f2p가 모두 통과하고 p2p가 하나도 깨지지 않으면 해결이다. 에이전트가 테스트를 고쳐 통과시키지 못하도록, 패치를 적용할 때 테스트 파일은 제외한다. `__tests__/` 아래, `.test.`와 `.spec.`이 들어간 파일, 스냅숏과 픽스처 디렉터리가 대상이며 제외한 경로는 결과에 기록한다.

실패한 테스트가 있으면 그 파일만 최대 3회 다시 돌린다. 재실행에서 모두 통과하면 `resolved_after_rerun`으로 따로 표기한다. 불안정한 테스트 때문에 미해결로 처리되는 경우를 구분하려고 넣었는데, 실제로 이 판정은 한 건도 나오지 않았다. 판정은 일곱 가지로 나눴다.

| 판정                   | 뜻                                                               |
| ---------------------- | ---------------------------------------------------------------- |
| `resolved`             | f2p 와 p2p 전부 통과                                             |
| `resolved_after_rerun` | 처음엔 실패했지만 실패한 파일만 다시 돌려 통과. 본 해결률과 분리 |
| `unresolved`           | 다시 돌려도 실패. 실패한 테스트 목록을 남김                      |
| `patch_apply_failed`   | 모델 패치가 base 에 적용되지 않음                                |
| `test_patch_conflict`  | 모델 패치 위에 테스트 패치가 적용되지 않음                       |
| `no_report`            | 테스트 리포트가 생기지 않음(설정 파손 등)                        |
| `env_setup_failed`     | 채점 환경 문제. 모델 탓이 아님                                   |

모델 비교에서 실제로 나온 판정은 `resolved`와 `unresolved`뿐이었다. 그래도 나머지 네 가지 실패를 미리 구분해 두니, 결과를 받은 뒤 모델의 실패인지 환경 문제인지 다시 분류할 필요가 없었다. "시간 초과, API 오류, 결과 기록 누락은 구분해서 남긴다"는 1편의 원칙을 채점기에도 적용한 것이다.

채점기를 검증하는 방법은 단순하다. 정답 패치를 넣으면 해결로, 빈 패치를 넣으면 미해결로 나와야 한다. 쪼개지 않은 원본 18건은 정답 패치를 3회 채점해 모두 해결, 빈 패치는 모두 미해결이었다. 쪼갠 문항 25건은 앞에서 말한 부분 정답으로 같은 결과를 확인했다. 판정에서 뺀 테스트도 하나 있다. 직렬화 함수가 특정 인자로 호출됐는지 확인하는 테스트인데, 같은 동작을 다른 인자 구성으로 구현하면 깨지는 테스트라 제외 목록에 넣었다.

f2p가 10개인 문항에서는 실제 동작 변경을 확인하는 테스트가 1개뿐이었다. 나머지 9개는 연쇄 실패였다. 같은 파일에서 spy를 복원하는 `mockRestore()`가 테스트 본문 안에 있어, 첫 검증에서 실패하면 복원 코드까지 실행되지 않았다. base에 테스트 패치만 적용해 보니 첫 실패 이후 파일 끝까지 테스트가 연속으로 실패했다.

다른 문항에서도 f2p 17개 중 최소 4개가 같은 이유로 실패했다. 이 4개가 확인하는 동작은 base에 이미 있었고, 정답 패치도 해당 로직을 바꾸지 않았다. 그래서 명세에 "유지"라고 표시했다. 문항의 해결 여부에는 영향이 없지만, "f2p 10개짜리 어려운 문항"이라고 판단할 근거는 되지 않는다. 공개 데이터셋에 PASS-FAIL-PASS 기록이 섞여 있어 "f2p 수를 난이도로 읽으면 안 된다"고 했던 1편의 주의 사항이 사내 문항에도 적용됐다.

채점용 워크트리를 `/tmp` 아래에 두면 경로를 처리할 때도 주의해야 했다. macOS의 `/tmp`는 `/private/tmp`의 심볼릭 링크여서, vitest가 보고하는 파일 경로와 채점기가 계산하는 경로가 어긋났다. 이 상태에서는 빈 패치의 p2p가 전부 실패해, 그대로 모델을 평가했다면 모두 0점으로 처리됐을 것이다.

의존 패키지의 타입 정의를 수정하는 패치는 해당 패키지를 다시 빌드해야 테스트에 반영됐다. 채점기가 패치 경로를 확인해 필요한 빌드를 실행하도록 했다. 하나의 채점 워크트리를 동시에 쓸 수는 없으므로, 여러 모델을 돌릴 때는 모델마다 워크트리를 따로 만들었다.

## 테스트 통과 말고도 잰 것

테스트를 통과한 패치 중에도 타입 검사를 깨뜨리거나 팀의 린트 규칙을 어기고, 정답 패치보다 몇 배 크게 수정한 것이 있었다. 해결 여부만으로는 이런 차이를 알 수 없어 실행마다 품질 지표도 기록했다.

기록한 지표는 tsc 오류 수, eslint 오류 수, 포맷 위반 파일 수, 테스트를 제외한 코드 변경 줄 수, 정답 패치 대비 변경 크기, 추가된 `any`, 린트 억제 주석, 일반 주석 줄 수다. 측정 대상도 채점할 때와 같은 코드여야 한다. base에 모델 패치만 적용하면 채점과 다른 상태를 검사하게 되므로, PR의 테스트 패치까지 얹은 뒤 측정했다.

"모델이 새로 만든 오류"를 세기 위한 기준선은 정답 상태(정답 패치 + 테스트 패치)로 잡았다. 쪼갠 문항에는 다른 주제의 테스트도 포함되어 있어, 해당 테스트의 tsc 오류가 모든 모델에서 똑같이 잡혔다. 따라서 비테스트 파일의 오류만 집계했다. 변경 크기를 비교할 때도 전체 정답 패치 대신 해당 주제의 부분 정답을 분모로 썼다.

"변경 없이 끝난 실행"의 기준도 정해야 했다. 코드 파일 변경이 0줄인 실행으로 세면 테스트나 문서만 바꾼 실행까지 섞인다. 그래서 패치 파일 자체가 비었는지로 판정하고, 이런 실행의 품질은 재지 않고 건수만 따로 셌다. 3편에서 한 모델의 실행 70회 중 15회가 여기에 해당한다.

비용은 Claude Code가 보고한 `usage`의 입력, 캐시 읽기, 캐시 쓰기, 출력 토큰에 단가를 곱해 계산했다. `total_cost_usd`는 클라이언트 가격표로 계산한 추정치라 쓰지 않았다. 시간 한도로 강제 종료된 실행은 결과 이벤트가 없어 토큰 사용량도 기록되지 않는데, 격리 실행에서는 이런 경우가 없었다.

## Claude Code를 돌린 방법

에이전트의 작업 디렉터리는 base 커밋의 파일만 담은 새 git 저장소다. `git archive`로 파일을 풀고 커밋 하나를 만든 뒤 `base` 태그를 붙였다. Git 이력이나 원격 PR에서 정답을 찾지 못하도록 이후 커밋과 원격은 포함하지 않았다. 의존성 설치와 의존 패키지 빌드는 미리 끝내 두어 에이전트가 바로 테스트를 돌릴 수 있게 했다. 정답 패치를 포함한 문항 저장소는 작업 디렉터리에서 보이지 않는 곳에 두었다. 36문항의 작업본을 한 번 만들어 두고 실행마다 복사해 썼다.

프롬프트는 "이 저장소의 해당 패키지에 아래 이슈가 보고됐습니다. 이슈를 해결하도록 코드를 수정하세요. 테스트를 추가하거나 고쳐도 되지만 채점에서는 반영되지 않습니다." 뒤에 문제 설명을 붙인 것이다. 명세 트랙이면 "아래 인터페이스 명세를 지켜야 합니다."와 명세를 이어 붙였다. 실행 명령은 다음 형태다.

```sh
claude -p "<프롬프트>" \
  --output-format stream-json --verbose \
  --max-turns 60 \
  --allowedTools Read Edit Write Grep Glob \
    "Bash(pnpm:*)" "Bash(npx:*)" "Bash(node:*)" "Bash(ls:*)" "Bash(cd:*)" \
    "Bash(git diff:*)" "Bash(git status:*)" "Bash(git log:*)"
```

`ANTHROPIC_BASE_URL`에는 사내 게이트웨이를, `ANTHROPIC_MODEL`에는 비교할 모델 ID를 지정했다. 보조 모델 변수인 `ANTHROPIC_SMALL_FAST_MODEL`과 `ANTHROPIC_DEFAULT_HAIKU_MODEL`도 같은 모델 ID로 맞췄다. 이를 따로 지정하지 않으면 제목 생성 같은 보조 작업에서 기본 모델을 호출해, 비교 대상이 아닌 모델의 토큰이 섞일 수 있다. `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`도 켜서 평가와 무관한 요청을 줄였다.

한도는 문항당 25분과 60턴으로 정했다. Claude Code [문서](https://code.claude.com/docs/en/cli-reference)는 `--max-turns`를 에이전트 턴(agentic turn)의 수를 제한하는 옵션으로 설명한다. 한도에 이르면 오류로 종료한다. 사람이 문제를 한 번 주면 Claude Code는 작업이 끝날 때까지 모델 호출을 반복한다. 모델이 파일 읽기나 수정, 테스트 실행 같은 도구 호출을 지시하고 Claude Code가 그 결과를 돌려주는 왕복 한 번이 한 턴이다.

60턴은 첫 시험 실행에서 정한 값이다. 공개 벤치마크에서는 이보다 훨씬 많은 예산을 준다. [mini-swe-agent의 SWE-bench 기본 설정](https://mini-swe-agent.com/latest/usage/swebench/)은 250스텝과 \$3이고, GLM 카드의 Terminal Bench 3.0 평가는 600턴이다. 그만큼 주면 결국 풀리는 문항이 많아서, 이번에는 정해진 턴 안에서 얼마나 푸는지 보고 싶었다. 턴마다 모델이 컨텍스트 전체를 다시 읽으므로 턴이 늘면 토큰 사용량과 비용도 늘어난다.

25분 한도는 60턴을 처리하는 데 지나치게 오래 걸릴 때 중단하도록 둔 것이다. 첫 실행에서는 시간 한도에 걸린 경우가 있었지만, 개인 지침을 격리하고 다시 실행했을 때는 없었다. 이때 결과를 제한한 것은 턴 한도였다. 3편에서는 60턴이라는 선택이 결과에 어떤 영향을 줬는지도 살펴본다.

`--allowedTools`에는 위 목록을 지정했다. 문서에 따르면 이 옵션은 권한 확인 없이 실행할 도구를 정한다. 도구 자체를 제한하는 옵션은 `--tools`다. 목록 밖의 호출은 `-p` 모드에서 권한 요청에 답할 사람이 없어 거부된다. 이 횟수는 결과의 `permission_denials`에 남으며, 모델에 따라 2배 이상 차이가 났다. 거부된 호출에도 턴이 쓰이므로 허용되지 않은 명령을 자주 시도할수록 문제를 푸는 데 쓸 턴이 줄어든다.

실행 결과는 stream-json 이벤트를 전부 파일로 받은 뒤 요약했다. assistant 이벤트에서 도구 호출의 종류와 횟수를 세고, 마지막 result 이벤트에서 `subtype`, `is_error`, `num_turns`, `usage`를 꺼냈다. 1편에서 "`subtype` 이 `success` 라는 이유만으로 성공으로 분류하지 않는다"고 적었는데, 반대 경우도 있었다. 60턴 한도에 걸리면 `subtype`은 `error_max_turns`, `is_error`는 true로 끝나지만 그 시점의 코드가 테스트를 통과할 수 있다. 그래서 성공 여부는 Claude Code의 결과가 아니라 채점기의 판정으로만 정했고, Claude Code의 결과는 "어떻게 끝났는가"를 기록하는 데만 썼다.

채점과 집계에 쓰는 자료에서는 모델명을 라벨로 바꿨다. 라벨과 모델 ID의 대응은 별도 파일 하나에만 두고, 결과 폴더와 로그에는 라벨만 남겼다. 모델명이 들어 있는 에이전트 원본 출력은 따로 숨긴 폴더에 보관했다. 다만 집계를 돕는 세션이 이전 회차의 표를 읽으면 라벨이 어떤 모델인지 알 수 있었다. 모델명을 가리려면 이전 결과에 대한 접근도 관리해야 했다.

패치 리뷰에서도 작성 모델을 가렸다. 문항마다 두 패치에 P와 Q 라벨을 무작위로 붙여, 문항이 바뀌면 같은 라벨이라도 작성 모델이 달라질 수 있게 했다. 리뷰어에게는 문제 설명, 인터페이스 명세, 정답 패치, 두 패치, 채점 결과를 담은 폴더와 base 코드만 보여 줬다. 정확성, 변경 범위, 기존 코드와의 일관성, 단순성, 코드 정리 상태, 종합 평가를 1~5점으로 매기게 했다.

리뷰어는 Claude였다. 근거 없는 지적을 줄이기 위해 3점 이하를 준 항목에는 반드시 패치 원문을 인용하도록 했다. 실제 버그로 분류한 지적은 내가 패치와 base 코드를 열어 다시 확인했다.

모델마다 36문항을 순서대로 처리하는 실행 큐(lane)를 하나씩 두고, 모델 수만큼 큐를 동시에 실행했다. 문항 순서는 기본 트랙 22문항, 명세 트랙 14문항 순이다. 문항 하나가 끝나면 `git add -A && git diff --cached --binary base`로 패치를 수집해 채점하고, 품질 지표와 함께 `meta.json`에 기록했다. `base` 태그를 기준으로 비교하므로 에이전트가 실행 중에 커밋했더라도 변경 사항은 모두 패치에 포함된다.

API 오류나 연결 실패로 보이는 결과는 인프라 실패로 분류해 한 번 재시도했다. 이미 결과 파일이 있는 문항은 건너뛰기 때문에, 중간에 끊긴 큐를 다시 실행하면 남은 문항부터 이어서 처리한다. 회차와 조건별로 결과 폴더도 나눴다. 한도를 바꾼 실행이나 격리 재실행이 기존 결과와 섞이지 않도록 한 구조가 첫 실행을 버리고 다시 돌릴 때도 유용했다.

## 첫 실행을 비교에서 뺀 이유

첫 실행은 4개 모델로 36문항을 3회씩 돌린 432회와 한도를 늘려 명세 14문항을 다시 돌린 56회(그중 3회는 게이트웨이 오류로 결과가 없다), 모두 488회였다. 그중 297회의 출력에 호칭이 들어 있었고, 일부는 "계획을 먼저 보여 주고 멈출 것"이라는 지침을 따라 계획만 적고 끝나 미해결로 채점됐다. 지침 없이 다시 돌린 모델 하나는 108회 중 105회 해결로 같았지만, 지침이 섞인 결과를 격리한 결과와 나란히 놓을 수는 없었다. 488회는 모델 비교에서 빼고, 한도를 늘렸을 때의 변화를 보는 참고 자료로만 3편에 남겼다.

호출 경로도 이때 다시 확인했다. 1편에 "사내 코드를 호스팅 API로 보내기 전에 회사에서 어디까지 허용하는지 확인해야 한다"고 썼지만, 실제로는 게이트웨이에 등록된 모델 ID만 보고 실행을 시작했다. 일부 모델이 사내 코드를 넣기에 적절하지 않은 경로로 호출된다는 사실은 실행이 끝난 뒤에 알았다. 그 결과는 비교에서 제외하고, 이후에는 사내에서 쓰기로 정한 경로의 모델로 대상을 좁혔다. 3편의 주 비교가 Claude Sonnet 5.5와 GLM 5.3 두 모델이 된 이유다.

실행 환경에도 1편의 계획과 달라진 부분이 있었다. 컨테이너 대신 호스트에서 실행하고 허용 도구 목록만 제한했다. 작업 디렉터리에 원격이 없고 문항 저장소도 보이지 않아 정답에 접근할 경로는 없지만, Bash로 네트워크 요청을 보내는 것까지 막지는 않았다. 결과 요약에 Claude Code 버전을 남기지 않아 npm 배포 시각으로 역산했다.

자체 호스팅한 오픈웨이트 모델 2종은 Claude Code가 보내는 요청의 최대 출력 토큰과 시스템 메시지 위치를 서버가 받지 못해 실행하지 못했다. curl로 보낸 최소 요청은 통과했으므로, 그것만으로는 호환 여부를 알 수 없었다. Claude Code로 문항 하나를 끝까지 실행해 봐야 했다.

턴 수는 결과의 `num_turns`를 썼다. 이 값은 `--max-turns 60`인 실행에서 61 이상이 되거나 도구를 145회 호출한 실행이 1로 보고되는 경우가 있어, 한도 도달 여부는 `subtype`으로 판단했다. 여러 모델을 한 머신에서 동시에 돌렸으므로(1회차 큐 4개, 2회차와 3회차 큐 8개) 모델 간 소요 시간은 참고값이다.

## 다시 돌린 결과는 3편에서

문항 36개와 검증된 채점기, 실행 환경은 그대로 쓸 수 있어서 개인 지침을 격리한 재실행은 하루 안에 끝났다. 문항과 한도도 결과를 보기 전에 정해 둔 것을 유지했다. 기본 한도인 60턴은 첫 시험 실행 이후 바꾸지 않았고, 한도를 늘린 실험은 별도로 남겨 결과가 어떻게 달라지는지 비교했다.

3편에서는 Claude Sonnet 5.5와 GLM 5.3이 같은 36문항을 3회씩 푼 216회의 결과를 살펴본다. 해결률과 턴, 시간, 비용을 비교하고, 테스트를 통과한 패치를 사람과 모델이 리뷰한 결과도 함께 다룬다. 공개 벤치마크와의 조건 차이, 60턴 한도의 영향, 테스트 통과만으로는 확인할 수 없었던 결함이 주요 내용이다.
