앞 글에서는 명세를 먼저 쓰고 Codex로 MCP 서버와 React 위젯을 ChatGPT에 연결한 과정을 정리했다. 그때 만든 것은 상태 카드 하나였다. 이력서를 분석하거나 공고를 판단하는 기능은 없었다.
이번에는 다음 단계인 Milestone 1을 구현했다. 이력서와 채용 공고를 입력받아 REALISTIC / STRETCH / PASS를 반환하는 흐름이다. 초기 코드는 main에 넣었지만, 이번부터는 PR #1에서 구현과 리뷰를 이어갔다.
PR을 열고 보니 글의 중심도 달라졌다. “명세를 주니 기능이 만들어졌다”에서 끝나지 않았다. 명세에 적어둔 안전장치가 실제로 안전한지 확인하는 작업이 생각보다 중요했다.
먼저 범위를 분명히 해두면, 이 글은 03452bf 기준의 구현과 로컬 검증 기록이다. 실제 Responses API를 거치는 전체 흐름은 크레딧 부족으로 검증을 마치지 못했다. 앞 글에서 확인한 ChatGPT 상태 카드와 이번에 추가한 실제 공고 판정은 검증 범위가 다르다.
명세의 두 번째 작업을 PR로 옮겼다
Career Radar의 목적은 이력서를 공고에 맞춰 어떻게든 그럴듯하게 고치는 것이 아니다. 지금 경력으로 지원할 가치가 있는 공고인지 먼저 판단하는 것이다.
그래서 이번에도 범위를 좁혔다. 공고 검색, URL 수집, SQLite 저장, 지원 현황 관리는 붙이지 않았다. 세 도구가 이어지는 흐름만 만들었다.
profile_upsert 이력서 텍스트 → 구조화된 후보자 프로필
job_ingest 붙여 넣은 JD → 구조화된 채용 공고
job_assess 프로필 + 공고 → 근거와 gap이 포함된 판정
서버는 Responses API의 구조화된 출력을 사용하고, 반환된 데이터를 공유 Zod 스키마로 검증한다. React 카드는 판정, confidence, resume contortion, 근거, gap과 hard blocker를 표시한다.
여기서 구조화된 출력이 보장하는 범위를 혼동하면 안 된다. 필요한 필드와 형식이 맞는 것과, 그 안의 경력 설명이 사실인 것은 다른 문제다. evidence라는 문자열이 있다는 이유로 그 문자열을 믿을 수는 없다.
그래서 모델의 판정 뒤에 결정론적 후처리 함수를 뒀다. 후보자 프로필에서 찾을 수 없는 긍정 근거는 제거하고, hard blocker가 있으면 판정에 반영하는 코드다. 이 부분은 모델에게 한 번 더 “정직하게 판단해줘”라고 요청하는 대신 서버가 확인하게 했다.
이름도 역할도 그럴듯했다. 문제는 그 함수에 잘못된 입력을 넣었을 때 드러났다.
“Kubernetes를 쓴 적 없다”가 Kubernetes 경험의 근거가 됐다
첫 리뷰에서 재현한 입력 중 하나다. 공개 테스트에 사용하는 합성 문장이며 실제 이력서는 아니다.
후보자 프로필:
Never used Kubernetes in production at any point
모델이 내놓은 긍정 근거:
Kubernetes
이 입력은 긍정 근거로 인정하면 안 된다. 그런데 초기 정책은 긴 프로필 문장 안에 짧은 모델 주장이 포함되는지를 확인했다. 문자열에는 실제로 Kubernetes가 들어 있으므로 통과했다.
조건의 핵심을 줄이면 이렇다.
item.includes(claim) || claim.includes(item)
문장을 구성하는 일부 단어가 같다는 사실을, 같은 경력을 말한다는 근거로 사용한 셈이다. 이 경우에는 부정 표현을 떼어내고 기술명만 남겨도 긍정 근거가 됐다.
다른 오류도 있었다.
| 입력 | 초기 정책의 문제 |
|---|---|
| ID가 없거나 매칭되지 않는 명시적 hard blocker gap | 요구사항 조회에 실패하면서 무시했다 |
| 필수 학력 요구사항의 minor gap | minor인데도 hard blocker로 올렸다 |
| ID 없는 blocker와 같은 문구의 ID 있는 gap | 서로 다른 키로 취급해 중복이 남았다 |
이런 오류는 TypeScript나 Zod가 잡아줄 문제가 아니다. 입력 형태는 유효했고 함수도 예외 없이 끝났다. 결과의 의미가 틀렸을 뿐이다.
첫 리뷰의 의미는 코멘트 수보다 여기에 있었다. “이 로직이 불안해 보인다”를 넘어서, 어떤 입력에서 어떤 잘못된 판정이 나오는지가 구체적으로 남았다.
리뷰의 제안도 그대로 적용하면 끝이 아니었다
첫 수정에서는 부분 문자열 비교의 방향을 제한했다. 짧은 주장만 프로필 안에서 찾는 방식은 버리고, 후보자의 긴 근거 문장 전체가 주장에 들어 있을 때만 인정하도록 바꿨다.
그러자 원래 부정문 사례는 막혔다. 하지만 다음 입력은 여전히 통과했다.
후보자 프로필:
Led a React platform team for three years
모델이 내놓은 근거:
Led a React platform team for three years
and owned company-wide architecture
첫 문장은 프로필에 있다. 뒤에 붙은 성과는 없다. 그런데 주장 안에 원래 문장이 통째로 들어 있으므로, 전체 주장을 근거로 인정했다.
고쳤다는 커밋과 테스트 통과만 보고 멈췄다면 놓쳤을 문제다. 앞선 실패는 해결했지만 다른 방향의 과장을 허용하고 있었다.
결국 부분 문자열 비교를 없애고, 정규화한 프로필 근거 목록에 모델의 근거 문장과 같은 항목이 있는지만 확인하도록 바꿨다. 회사나 역할 같은 출처 정보는 별도 source 필드를 사용한다. 근거 문자열에 설명을 덧붙여 일치 조건을 느슨하게 만들지 않기로 했다.
이후 리뷰에서는 반대 방향의 불편도 짚었다. 같은 문장 뒤에 마침표 하나가 붙었다고 근거를 버리면 지나치게 엄격하다.
최종 수정에서는 대소문자와 공백뿐 아니라 문장 끝 구두점과 양쪽 따옴표도 정규화한다. 다음 세 가지를 함께 테스트한다.
- 문장 끝에 마침표가 붙어도 인정한다.
- 따옴표로 감싼 같은 문장도 인정한다.
- 원래 문장 뒤에 없는 성과를 덧붙이면 거부한다.
이것으로 자연어의 의미를 완벽하게 검증한다고 말할 수는 없다. 질문이나 불확실한 표현의 의미까지 문자열 정규화로 판별하는 것은 아니다. 현재 정책은 구조화된 프로필에서 근거를 복사했는지 확인하는 제한된 장치다. 프로필 추출 단계부터 잘못됐다면 그 문제는 별도로 검증해야 한다.
우대 조건은 제외했는데 마지막 단계에서 다시 들어왔다
두 번째로 흥미로웠던 것은 preferred qualification 처리였다.
모델이 “AWS 자격증 우대”를 hard blocker라고 잘못 분류하더라도, 서버는 우대 조건이라는 정보를 보고 제외해야 한다. 처음에는 요구사항 ID로만 확인했다. 모델이 ID를 생략하거나 잘못 반환하면 우대 조건인지 알아보지 못했다.
이를 보완하면서 gaps와 hardBlockers 두 배열을 모두 따라갔다. 그러자 ID가 올바른 경우에도 문제가 있었다. 앞의 순회에서는 preferred 항목을 건너뛰었는데, 마지막에 severity === "hard_blocker"인 gap을 다시 수집하면서 같은 항목이 되살아났다.
하나의 continue가 함수 전체에서의 제외를 의미하지 않았던 것이다.
최종 정책은 유효한 요구사항 ID를 우선한다. ID가 없거나 알 수 없는 값이면 정규화한 문구로 preferred 목록과 비교한다. preferred인데 hard blocker로 표시된 gap은 material로 낮춰 마지막 수집 단계에서도 blocker가 되지 않도록 했다.
반대로 유효한 required ID가 있는 항목은 문구가 preferred와 충돌한다고 무조건 제외하지 않는다. 잘못된 문구 하나 때문에 실제 필수 조건을 지워버리는 반대쪽 오류도 막아야 하기 때문이다.
이 부분은 회귀 테스트로 고정했다. 과장된 근거와 preferred 처리에 대한 네 테스트가 수정 전에는 실패하고, 수정 후에는 통과하는 것을 확인했다. 코드를 바꾸고 테스트를 추가했다는 기록보다, 그 테스트가 실제로 이전 오류를 잡았다는 기록이 더 쓸모 있었다.
eval 100%는 모델 정확도 100%가 아니었다
처음 eval은 손으로 만든 draftAssessment를 정책 함수에 넣고 결과를 확인하는 구조였다.
합성 프로필 + 공고 + 미리 작성한 모델 초안
↓
applyAssessmentPolicy
↓
기대 결과와 비교
이 실행 경로에는 실제 모델 호출이 없다. 따라서 verdict agreement가 1.0이라고 나와도 모델이 모든 공고를 정확하게 판단했다는 뜻은 아니다. 정해둔 입력에 대한 후처리 정책의 결과가 기대와 일치했다는 뜻이다.
금지 주장 검사에도 비슷한 문제가 있었다. 초기 fixture에는 금지한 문장이 모델 초안에 애초에 들어 있지 않았다. 정책이 근거를 제거만 하는 구조에서, 처음부터 없는 문장이 결과에도 없다는 사실은 검증 가치가 작았다.
그래서 잘못된 주장이 실제로 들어 있는 입력을 추가했다. 예를 들어 부정문에서 잘라낸 Kubernetes 주장, 원래 근거 뒤에 덧붙인 성과를 넣고 정책이 제거하는지 확인한다. 중복 blocker는 존재 여부뿐 아니라 개수까지 검사한다.
문서에도 이 수치를 정책 회귀 테스트 결과라고 적었다. 모델 평가라는 이름으로 더 넓은 신뢰를 빌려오지 않기로 했다.
최종 검증 시점의 숫자는 다음과 같다.
| 검증 | 03452bf 기준 결과 |
|---|---|
| lint · typecheck · build | 통과 |
| 단위·통합 테스트 | 29개 통과 |
| 정책 eval fixture | 16개 통과 |
| 실제 Responses API 전체 흐름 | 검증 미완료 |
| 이번 판정 기능의 최신 ChatGPT 화면 | 수동 검증 미완료 |
이 숫자를 작게 보이게 만드는 마지막 두 줄도 같은 표에 두는 편이 맞다. 테스트가 무엇을 하지 않았는지 알아야 앞의 통과 결과를 제대로 해석할 수 있다.
타입 검사와 메모리 보관도 PR에서 정리했다
기능 밖이라고 생각했던 부분도 리뷰에서 드러났다.
먼저 기존 typecheck는 서버의 src만 확인했고 테스트와 eval fixture는 빠져 있었다. Vitest와 tsx로 실행하는 테스트가 통과해도, 해당 파일들이 TypeScript 타입 검사를 통과했다는 의미는 아니었다.
테스트와 eval을 검사 대상에 넣자 기존 HTTP 테스트에서 바로 타입 오류가 발견됐다. MCP 리소스 응답이 텍스트인지 다른 형태인지 구분하지 않고 text 필드를 읽고 있었다. 타입을 좁힌 뒤 접근하도록 수정했다.
이후 shared 패키지는 자기 테스트를 자기 설정으로 검사하도록 나눴다. 서버 테스트가 루트 eval fixture를 가져오던 부분도 별도 합성 fixture로 정리했다.
저장소의 수명도 명확히 했다. Milestone 1은 SQLite를 사용하지 않지만 구조화된 프로필과 공고를 프로세스 메모리에 보관한다. “원본 이력서를 저장하지 않는다”는 문장만으로는 충분하지 않았다. 구조화된 경력 데이터도 개인정보를 포함할 수 있고, 상한이 없는 Map은 계속 커질 수 있다.
현재는 프로필과 공고를 각각 최대 100건 보관하고, 마지막 저장 후 30분이 지나면 제거한다. 요청이 없어도 타이머로 만료 데이터를 지우며, 상한을 넘으면 오래된 저장부터 내보낸다. 읽는다고 보관 시간이 늘어나지는 않는다.
HTTP 앱이 저장소와 analyzer의 수명을 소유하도록 정리한 것도 같은 맥락이다. 요청마다 만드는 MCP 서버는 필요한 의존성을 반드시 전달받고, analyzer는 HTTP 앱 안에서 한 번 만들어 재사용한다.
이 중에는 실제 판정 오류를 고친 것도 있고, 앞으로 다른 호출 경로를 붙일 때 실수를 줄이기 위한 구조 정리도 있다. 모두를 같은 심각도의 버그로 세지는 않았다.
AI 리뷰는 승인 도장이 아니라 검증할 가설이었다
이번에는 Codex로 구현과 검증을 진행하고, Claude Code의 리뷰·수정 내용을 다시 확인하면서 같은 PR을 이어갔다. 누가 더 많은 문제를 찾았는지보다, 서로의 제안이 실제 코드에서 어떤 결과를 내는지가 중요했다.
중복 제거에 대한 첫 리뷰의 키 제안은 그대로는 중복을 해결하지 못했고, 답글에 정정이 남았다. 부분 문자열 비교를 좁히는 수정도 추가 재현에서 다시 보완됐다. 반대로 모든 외부 API 호출을 open-world로 봐야 한다는 의견은 현재 입력만 분석하는 도구 범위와 공식 문서를 대조한 뒤 채택하지 않았다. 판단 근거는 ADR에 남겼다.
리뷰 의견이 있다는 사실도, 그 의견에 답했다는 사실도 정확성을 보장하지 않았다. 재현 입력과 기대 결과가 있어야 다음 판단으로 넘어갈 수 있었다.
그래서 공개 PR에는 고친 코드뿐 아니라 무엇이 문제였고 왜 이렇게 바꿨는지를 남겼다. 마지막 확인에서는 리뷰 스레드 13개가 모두 해결 상태였다. 이 숫자 역시 품질 점수는 아니다. 이번에 제기한 의견들이 어떤 결론으로 정리됐는지를 보여주는 기록이다.
다음 단계는 기능을 더 붙이기 전에 실제 모델을 통과시키는 것
로컬 HTTP/MCP 통합 테스트에서는 가짜 analyzer로 세 도구의 연결을 확인했다. 정책 테스트는 잘못된 모델 초안이 들어왔을 때 어떤 결과가 나오는지 확인했다.
아직 확인하지 못한 것은 실제 모델이 합성 이력서와 JD에서 어떤 프로필과 판정을 반환하는지다. 라이브 호출은 크레딧 부족으로 429 credit_balance_exhausted에서 중단됐고, 이번에는 유료 호출을 다시 실행하지 않았다.
크레딧을 준비한 뒤에는 개인정보 없는 합성 입력으로 전체 흐름을 확인할 예정이다. 추출 단계에서 틀린 것인지, 판정 단계에서 틀린 것인지, 후처리 정책이 정상적인 근거를 과하게 버린 것인지 구분해서 봐야 한다. 그다음 실제 ChatGPT 카드도 확인해야 한다.
사용자 인증과 사용자별 데이터 격리도 아직 없다. 지금 병합의 기준은 공개 서비스 출시가 아니라, 소유자 한 명이 비공개로 이어갈 토이 프로젝트의 Milestone 1이다.
명세부터 썼던 첫 단계가 “무엇을 만들지”를 정한 과정이었다면, 이번 PR은 “만든 것을 어디까지 믿을지”를 정리한 과정이었다.
앱은 조금 더 기능을 갖췄다. 그보다 쓸모 있게 남은 것은, 틀린 판정을 다시 만들어볼 수 있는 입력과 테스트다. 다음에 프롬프트나 코드를 바꾸더라도 이번에 확인한 실패부터 다시 확인할 수 있게 됐다.
구현과 리뷰 기록: Career Radar PR #1
검증한 정책 코드: assessment/policy.ts — 03452bf