지난 글에서는 Career Radar가 경력과 공고를 비교해 설명할 때 어떤 근거를 어떤 형식으로 받을지부터 정했다. 그 글 끝에 적은 대로, 그 설명을 실제 모델 경로에 붙이는 M4-B2는 라이브 측정을 마친 뒤에 시작하기로 했다.

그 측정은 Milestone 3 때부터 미뤄 둔 숙제였다. 추천 배치의 90초 제한이 실제 모델에 맞는지, 재시도가 추출을 정말 건너뛰는지를 그때는 “측정하지 않았다”고만 적고 이슈 #4로 남겼다.

측정하려면 유료 호출을 해야 한다. 그 호출을 누가, 언제, 몇 번까지 허락하는가?

PR #15는 측정 결과가 아니라 이 질문에 먼저 답하는 도구다. 이번에는 구현을 Claude Code에 맡기고, 리뷰는 Codex에 맡겼다. 리뷰에서는 코드와 기존 테스트를 확인한 뒤, 테스트가 지나지 않은 경로를 별도로 실행했다. 이 글은 첫 커밋 eafd553과 리뷰 반영 2a4b745를 놓고 병합 전에 남기는 기록이다. 아래 실행 결과는 모두 합성 데이터와 가짜 응답으로 확인했고, 실제 모델은 아직 한 번도 호출하지 않았다.

측정하지 않은 숫자가 두 개 있었다

추천은 한 배치에 후보를 최대 다섯 개 받는다. 새 공고라면 추출 다섯 번과 판정 다섯 번, 최대 열 번의 모델 호출이 순차로 실행되고 전체 제한은 90초다. 리뷰에서 빠듯할 수 있다는 의견이 나왔지만, 실제 모델로 돌려 본 적이 없으니 늘릴 근거도 유지할 근거도 없었다.

재시도도 마찬가지였다. 추출이 끝난 공고를 저장해 두므로 실패한 배치를 다시 돌리면 재추출을 건너뛴다. 코드로 확인한 것은 여기까지다. 그것이 비용을 얼마나 줄이는지는 “절반”이라고 적었다가 정정한 적이 있다. 추출 한 번과 판정 한 번의 가격이 같다고 확인한 적이 없고, 무엇보다 전체 배치를 다시 요청하면 성공했던 공고도 재판정하기 때문이다.

이번에 만든 하네스가 재는 것은 다섯 가지다. 후보 다섯 개가 90초 안에 끝나는지, 추출과 판정 호출 각각이 얼마나 걸리는지, 제한 시간이 지났을 때 진행 중인 요청에 취소가 얼마나 빨리 전파되는지, 재시도가 어떤 호출을 건너뛰고 어떤 호출을 다시 하는지(정수 횟수), 그리고 API가 응답에 함께 보내는 토큰 사용량이다.

그런데 이 다섯 가지를 재려면 공개 채용 공고와 합성 프로필을 모델 API로 보내고, 그 비용을 누군가 내야 한다. 도구의 첫 기능은 측정이 아니라 거절이어야 했다.

기본은 유료 호출을 하지 않는 것

pnpm measure:live를 그냥 치면 유료 호출은 없다. 픽스처 검색 결과와 가짜 판정기로 전체 흐름을 끝까지 돌리고 보고서까지 만드는 드라이런이 기본이다. 승인 플래그에 따라 실행 모드는 아래처럼 나뉜다.

명령 하는 일 모델 호출
승인 플래그 없음 드라이런: 가짜 검색·가짜 판정기로 A 실행, 필요하면 B 재시도. C는 강제 중단 옵션 지정 시 추가 0
--approve-network 채용 보드에 공개 GET 한 번, 계획만 출력 0
--approve-network --approve-model-cost 계획을 출력한 뒤 표준 입력으로 상한 숫자를 받고 시작 상한 이하

라이브 모드에서도 바로 돌지 않는다. 검색 결과에서 고른 후보의 제목과 글자 수, 모델 설정 기준, 실행 순서, 호출 상한을 먼저 안내하고, 그 상한 숫자를 정확히 입력해야 시작한다. yes도, 다른 숫자도, 빈 줄도, 표준 입력이 닫힌 상태도 모두 거절이다.

환경 변수는 거절만 할 수 있고 허락은 못 한다. CI 환경, 테스트 러너, 애플리케이션 DB 경로가 설정된 상태에서는 승인 플래그가 있어도 멈춘다. 반대로 어떤 변수를 설정해도 드라이런이 라이브로 바뀌지는 않는다. API 키가 든 .env.local은 상한 숫자를 입력한 뒤에야 읽는다.

호출 횟수에는 두 겹의 상한을 뒀다. 실행 하나에 40회라는 고정 상한이 있고, 그 안에서 계획된 횟수(후보 수 × 2 × 배치 수, 선택적 프로필 추출은 1회 추가)가 이번 실행의 상한이 된다. 판정기를 감싼 래퍼가 그 횟수를 세다가 초과분은 SDK에 넘기기 전에 거부한다.

보고서에는 횟수와 시간, 해시, 식별자만 남긴다. 공고 본문, 이력서, 추출된 요건 문장, 판정 설명, 키는 저장 전에 검사해서 하나라도 섞이면 파일을 쓰지 않는다. 합성 입력에 표식 문자열을 넣어 두고 보고서 세 파일 어디에도 나오지 않는지를 테스트로 고정했다. 금액과 퍼센트도 쓰지 않는다. 측정하지 않은 비율이 보고서에 들어갈 길을 테스트가 막도록 했다.

드라이런은 실패 경로도 재현한다. 세 번째 후보의 판정이 한 번 실패하는 시나리오에서는 첫 배치가 6회 호출로 끝나고(완료 2, 실패 1, 미시도 2), 재시도 배치는 실패한 후보의 추출을 건너뛰어 5회로 끝났다. 두 번째 후보의 추출이 영원히 멈추는 시나리오에서는 제한 시간이 취소를 보내고, 진행 중이던 호출이 취소 뒤 몇 밀리초 만에 정리됐는지가 기록된다.

한 번의 실행은 근거이지 결정이 아니다. 보고서의 결정 항목은 비어 있고, 승인된 실행 세 번 뒤에 사람이 채우도록 했다.

리뷰는 내가 감싼 층의 바깥을 봤다

첫 커밋은 타입 검사, 린트, 테스트, 정책 평가, 빌드를 모두 통과했다. Codex의 리뷰는 그 결과를 다시 실행한 뒤, 코드에서 의심한 경로 가운데 통과한 검증이 지나지 않은 네 곳을 재현으로 찾아냈다.

문서에 적은 명령이 실행되지 않았다

루트 스크립트 끝에 인자를 넘기려고 붙인 --가 문제였다. pnpm은 그 --를 스크립트에 그대로 전달해서, 문서에 적은 pnpm measure:live --no-save는 알 수 없는 옵션으로 거절됐다. 테스트는 CLI 파일을 직접 실행했기 때문에 이 경로를 한 번도 지나지 않았다.

--를 지우고, 사용자가 직접 친 --도 무시하도록 했다. 회귀 테스트는 이제 pnpm 자체를 자식 프로세스로 띄워 문서의 명령으로 실행한다.

승인 화면과 실제 목적지가 달랐다

승인 화면은 공고와 프로필을 api.openai.com으로 보낸다고 안내한다. 그런데 SDK는 환경 변수 OPENAI_BASE_URL을 읽어 목적지를 바꾸고, OPENAI_LOG=debug면 요청 본문을 디버그 로그에 출력한다. 리뷰는 실제 SDK에서 통신 함수만 가짜 응답으로 바꿔, 다른 주소로 인증 헤더가 붙은 요청이 만들어지는 것과 합성 입력 표식이 로그에 찍히는 것을 보였다.

보고서를 검사하는 코드는 SDK 자신의 로그를 보호하지 못했다. 하네스가 만드는 판정기에는 SDK 로그 수준을 끄고 재시도를 0으로 고정하는 옵션을 넘기도록 했다. OPENAI_BASE_URL은 설정돼 있으면 라이브 실행을 거절한다. 이 검사는 두 번 한다. 셸 환경을 처음 스냅샷할 때와 .env.local을 읽은 뒤다. Node의 process.loadEnvFile은 이미 있는 변수를 덮어쓰지 않지만 없던 변수는 새로 추가하므로, 파일을 읽기 전 스냅샷만으로는 부족했다.

상한은 래퍼가 세는 만큼만 지켜졌다

배치 안의 추출과 판정 호출은 취소 신호와 함께 재시도 0으로 요청한다. 그런데 선택 기능인 프로필 추출은 신호 없이 호출하는 경로라 SDK의 기본 재시도 2회가 살아 있었다. 리뷰는 500 응답을 돌려주는 가짜 통신으로 상한 1회 설정에서 래퍼 집계 1회, 실제 요청 시도 3회를 재현했다.

상한을 세는 층과 요청을 보내는 층이 달랐다. 재시도를 요청마다 끄는 대신 SDK 클라이언트 자체에 0으로 고정하니 신호 없는 경로도 같이 잡혔다. 같은 방식의 테스트에서 서버가 쓰는 기본 판정기는 3회, 하네스 판정기는 1회 시도로 확인했다. openai-node의 재시도 기본값은 그대로 두었다. 이것은 요청 시도 횟수의 재현이지 과금 횟수의 증명은 아니다.

중단 보고서가 끝난 호출까지 버렸다

Ctrl-C로 중단하면 부분 보고서를 남기도록 했는데, 그 보고서는 끝난 배치만 담고 있었다. 첫 배치 도중에 중단하면 이미 끝난 호출이 있어도 실행 목록이 비었다. 리뷰는 자식 프로세스로 띄운 뒤 첫 배치 시작 150ms 뒤에 중단 신호를 보내, 임시 DB에는 완료된 판정이 한 건 있는데 보고서에는 호출이 0건인 상태를 보였다.

“진행 중이던 요청의 사용량은 빠질 수 있다”는 안내는 이미 끝난 호출이 빠지는 이유를 설명하지 못했다. 배치를 시작할 때 알 수 있는 정보를 따로 들고 있다가, 중단 시점에는 그때까지 기록된 호출로 배치를 요약하도록 바꿨다. 같은 방식의 회귀 테스트에서는 중단 코드 130과 함께 호출 3건(완료 2건, 진행 중 1건)이 보고서에 남는 것을 확인했다.

안전장치마다 덮지 못한 층이 있었다

네 건을 나란히 놓고 보니 같은 모양이었다. 래퍼는 호출을 셌지만 그 아래에서 SDK가 다시 요청했다. 보고서 검사는 파일을 지켰지만 SDK의 로그는 다른 출구였다. 환경 스냅샷은 셸을 봤지만 그 뒤에 읽는 파일이 변수를 더했다. 테스트는 CLI를 검증했지만 문서에 적힌 명령은 그 앞에 한 층이 더 있었다.

안전장치를 만들 때는 그것이 어느 층에서 동작하는지를 같이 적어야 했다. “호출을 상한 안에서 센다”는 문장에는 어느 층의 호출인지가 빠져 있었다.

리뷰의 방식도 기억해 둘 만하다. 코드를 읽고 의심한 경로를 실행으로 확인했고, 수정 뒤에는 같은 재현을 다시 돌렸다. 실제 SDK를 그대로 두고 통신 함수 하나만 바꾸고, 실제 자식 프로세스에 실제 중단 신호를 보내는 식이었다.

실제 모델의 지연은 아직 측정하지 않았다

리뷰 반영 뒤 테스트 269개(shared 7 + server 262), 정책 평가 28개, 타입 검사·린트·빌드를 통과했다. 새로 추가한 테스트 가운데 실제 SDK를 쓰는 것은 재시도 횟수, 디버그 로그, 목적지 변경 세 가지이고, 나머지는 가짜 판정기 위에서 돈다. 드라이런의 시간 값은 가짜 지연이라 모델 지연에 대해 아무것도 말해 주지 않는다.

이 PR이 증명하는 것은 파이프라인과 집계, 그리고 무엇이 보고서에 들어가지 않는지다. 90초가 맞는지는 여전히 모른다. 그 답은 API 키를 넣고 두 플래그를 켠 뒤 상한 숫자를 입력하고, 그렇게 승인된 실행 세 번이 쌓인 다음에야 적을 수 있다. 그때 바꿀 곳은 코드의 상수 하나만이 아니라 README와 결정 기록, 이전 마일스톤 문서까지라서, 보고서에 그 목록을 같이 남기게 했다.

돈이 나가는 코드를 만들면서 배운 것은 단순했다. 측정 도구의 첫 번째 기능은 측정이 아니라, 돌면 안 되는 조건에서 확실히 멈추는 것이었다. 그리고 그 “확실히”는 내가 감싼 층에서만 성립했다. 그 바깥을 본 것은 통과한 테스트가 아니라, 코드를 읽고 의심한 경로를 실제로 돌려 본 리뷰였다.