지난 글에서는 Career Radar에 로컬 저장과 지원 상태 관리를 붙였다. 판정을 저장하고, 메모를 비우고, 전체 기록을 지우는 흐름을 확인했다. 데이터를 남기기 시작하자 어디에 사본이 생기는지와 누가 로컬 서버에 접근할 수 있는지가 중요해졌다.

이번 Milestone 3에서는 그 앞에 공고 탐색을 붙였다. 지정한 채용 보드에서 공고를 찾고, 사용자가 선택한 소수 공고를 기존 프로필과 비교해 추천 그룹으로 보여주는 기능이다.

화면만 보면 조금 더 취업 비서처럼 보인다. 하지만 이번 리뷰에서 눈에 들어온 문제는 검색 품질보다 가까운 곳에 있었다.

공고 세 개를 판정해 놓고 하나만 보여준다면, 나머지 두 개를 보려는 사용자는 무엇을 해야 할까?

초기 구현에서는 같은 판정을 다시 요청해야 했다. 실제 모델을 연결하면 불필요한 추가 비용으로 이어지는 구조였다.

이 글은 PR #30a78dda까지 정리한 병합 전 기록이다. Codex로 구현과 검증을 진행하고 Claude Code의 리뷰·수정 결과를 다시 확인하면서 이어갔다. 여전히 소유자 한 명이 로컬에서 다듬는 초안이며, 아래 모델 호출 횟수는 별도 표시가 없는 한 합성 응답을 주입한 테스트의 호출 횟수다. 실제로 그만큼 과금됐다는 뜻은 아니다.

처음에 미뤘던 검색을, 작게 붙였다

첫 글에서는 처음부터 검색을 만들지 않겠다고 적었다. 판정이 틀린 상태에서 공고만 많이 모으면 잘못된 추천을 더 빠르게 늘릴 수 있기 때문이다.

이후 단일 공고의 판정 정책과 저장·상태 변경 흐름을 구현했다. 그렇다고 실제 모델 검증까지 끝난 것은 아니다. 앞선 글에서 다음 단계로 적었던 라이브 검증은 크레딧 문제로 아직 남아 있다. 이번에는 그 검증을 기다리는 동안, 모델 없이 확인할 수 있는 검색과 호출 제어를 먼저 확장했다.

검색 범위도 작게 잡았다. 전체 채용 사이트를 돌거나 이력서에서 알아서 검색어를 만들지는 않는다. 사용자가 지정한 Greenhouse 보드 하나를 읽고, 그 안에서 직무 키워드와 지역 문자열을 필터링한다. 보드 토큰을 모르면 먼저 확인해야 한다.

이번에 붙인 흐름은 다음과 같다.

지정한 Greenhouse 보드
          ↓ job_search
미판정 공고 목록 + 출처 + 조회 시각
          ↓ 사용자가 후보 ID 1~5개 선택
기존 프로필과 비교해 판정
          ↓ job_recommend
REALISTIC / STRETCH / 선택적 PASS 설명
          ↓ 사용자가 명시적으로 저장 요청
기존 지원 현황에 연결

공고를 찾았다는 것은 적합하다고 판단했다는 뜻이 아니다. 검색 결과는 제공자의 수정 시각순으로 보여주고, 적합도는 선택한 공고에 기존 근거 판정 정책을 적용한 뒤에만 표시한다.

추천 결과를 받았다고 지원 기록을 자동으로 만들지도 않는다. 판정 스냅샷은 저장하지만, 지원 후보로 남기는 것은 반환된 assessmentId로 별도 application_save를 요청했을 때다. 기업에 지원서를 보내는 기능은 없다.

검색에는 이력서도 모델도 필요하지 않았다

검색과 판정을 나누니 데이터가 나가는 경로도 구분할 수 있었다.

단계 외부로 전달되는 내용 모델 호출
보드 조회 지정한 보드 토큰. 직무·지역 필터는 로컬 처리 없음
실제 공고 구조화 선택한 공고 본문 있음
실제 적합도 판정 구조화된 후보자 프로필과 공고 있음

검색 결과는 최대 10개이며, 검색 ID와 공개 공고 본문은 프로세스 메모리에 잠시 보관한다. 검색 10개, 각 검색 최대 10개 공고, 30분 만료로 제한했다. 서버를 다시 시작하거나 검색이 만료되면 새로 검색해야 한다.

이 캐시에 이력서는 들어가지 않는다. 다만 일반 실행에서 판정에 사용한 구조화 프로필과 결과는 기존 SQLite 저장 정책을 따른다. 검색이 개인정보를 보내지 않는다고 앱 전체가 외부 전송 없는 로컬 도구가 되는 것은 아니다.

제공자도 고정했다. 공식 API 호스트의 공개 IP를 확인해 연결하고, 리다이렉트와 크기·시간 초과를 거부한다. 응답에 들어 있는 임의 URL을 그대로 따라가는 대신, 출처 링크도 정해진 호스트와 공고 ID로 구성한다. Greenhouse 전송 코드에 구현 범위를 남겼다.

넓은 웹 검색보다 제약이 많다. 대신 지금 무엇을 읽고, 무엇을 읽지 않는지 설명하기 쉬운 출발점이었다.

세 개를 판정하고 하나만 반환하고 있었다

추천 도구에는 realisticCountstretchCount라는 입력이 있었다. 사용자가 기대하는 추천 개수를 표현하려고 넣은 값이다.

초기 구현은 선택된 후보를 전부 판정한 다음, 각 그룹을 요청 개수만큼 잘라 반환했다. 의미를 줄이면 아래와 같다.

realistic: realistic.slice(0, realisticCount)

일반적인 목록이라면 자연스러운 코드다. 하지만 여기서 자르는 대상은 이미 모델 분석을 마친 결과였다.

리뷰에서는 후보 세 개가 모두 REALISTIC이 되는 합성 analyzer를 넣고, realisticCount: 1로 호출했다.

선택 후보: 3개
판정 함수 호출: 3회
반환된 REALISTIC: 1개

나머지 두 결과도 DB에 저장돼 있었다. 그러나 반환하지 않은 assessmentId를 사용자가 찾을 경로가 없었다. 지원 현황 조회는 지원 기록을 대상으로 하므로, 아직 지원 후보로 저장하지 않은 판정 스냅샷을 거기서 꺼낼 수도 없었다.

나머지를 보려면 목표 개수를 늘려 추천을 다시 요청해야 했다. 같은 배치를 재요청하면 판정 함수도 다시 세 번 실행됐다. 실제 모델 경로라면 이미 계산한 결과를 보기 위해 추가 호출을 하는 셈이다.

이번에는 그룹을 자르지 않도록 바꿨다. REALISTIC과 STRETCH는 판정한 결과를 전부 반환하고, PASS 설명은 기존 선택 사항으로 남겼다. 목표 개수는 결과를 숨기는 상한이 아니라 부족분을 알려주는 기준으로 사용한다.

선택 후보: 3개
REALISTIC 목표: 1개
실제 REALISTIC: 3개
반환: 3개와 각각의 assessmentId
부족분: 0개

목표가 0이어도 해당 그룹으로 판정된 결과는 반환한다. 목표를 채우려고 판정을 바꾸지 않는 것과, 판정한 결과를 모두 보여주는 것을 함께 지키기로 했다.

수정 후에는 README와 위젯의 언어도 다시 봤다. 코드가 바뀌었는데 설명에는 여전히 ‘최대 개수’가 남아 있었고, 화면은 3 of 1 requested처럼 보일 수 있었다. 최종적으로 3 assessed · target 1로 바꾸고 한·영 README도 맞췄다.

배열의 slice를 없애는 것으로 구현은 끝났지만, 사용자가 이해하는 동작까지 바꾸려면 설명도 함께 고쳐야 했다.

설정 오류를 공고별 분석 실패로 바꾸면 안 됐다

두 번째 문제는 실패를 처리하는 위치였다.

각 공고의 분석은 try/catch 안에 있었다. 한 공고가 실패해도 이미 완료한 결과는 반환하고, 뒤의 유료 호출은 멈추려는 의도였다.

그런데 analyzer를 만드는 코드까지 그 안에 들어 있었다. API 키가 없어 analyzer를 만들 수 없는 상황도 공고 분석 실패와 같은 catch로 들어갔다. 원래의 키 설정 안내가 사라지고 일반적인 “Analysis failed or timed out” 안내로 바뀌었다.

키 설정 문제와 특정 공고의 분석 실패는 사용자가 취해야 할 다음 행동이 다르다. 설정을 고쳐야 하는 사람에게 공고 분석을 다시 해보라는 인상을 주면 실패를 설명한 것이 아니다.

analyzer 생성은 공고 루프 밖으로 옮겼다. 배치 실행 락을 잡기 전이라, 설정 오류가 나도 실행 중 상태가 남지 않는다. 실제 분석 중에 발생한 오류만 공고별 실패로 처리한다.

회귀 테스트에서는 원래 설정 메시지가 전달되는지, 추출 함수가 한 번도 호출되지 않는지, 설정을 바로잡은 뒤 다시 실행할 수 있는지를 확인한다. 추천 도메인 테스트에 결과 반환과 함께 남아 있다.

추출 결과는 살렸지만, 비용이 절반이라고 말할 수는 없었다

공고 분석은 크게 두 단계다. 본문에서 구조화된 공고를 추출하고, 그 공고를 후보자 프로필과 비교해 판정한다.

초기 구현은 두 단계가 모두 성공해야 공고를 저장했다. 추출에는 성공했는데 판정이 실패하면, 다음 시도에서 방금 추출했던 내용을 다시 추출했다.

수정은 단순했다. 추출이 끝난 직후 공고를 저장하고, 판정 결과는 판정이 성공했을 때 따로 저장한다. 같은 출처와 본문이라면 재시도에서 구조화된 공고를 재사용할 수 있다.

합성 테스트는 세 후보 중 두 번째 판정에서 실패하도록 만들었다.

구간 추출 호출 판정 호출
첫 배치 첫 번째·두 번째 후보: 2회 첫 번째 성공, 두 번째 실패: 2회
같은 세 후보 전체 재시도 미시도 세 번째 후보만: 1회 세 후보를 다시 판정: 3회
누적 3회 5회

수정 전이라면 재시도에서 두 번째 후보도 재추출하므로 추출 누적 횟수는 4회가 된다. 실패했던 공고의 추출을 한 번 덜 한다는 점은 확인할 수 있었다.

그런데 리뷰 답글에는 이를 두고 “재시도 비용이 절반으로 줄었다”는 설명이 붙었다. 그 표현도 다시 확인했다.

추출 한 번과 판정 한 번의 가격이 같다고 확인한 적이 없다. 입력·출력 토큰 수도 다를 수 있다. 무엇보다 전체 배치를 다시 요청하면 이전에 성공한 공고도 재판정한다. 추출 재사용을 전체 비용 50% 절감으로 바꿔 말할 근거가 없었다.

답글의 표현은 정정했고, README에는 실패했거나 시도하지 않은 ID만 다시 선택하라는 안내를 추가했다. 코드가 줄인 것은 재추출이다. 실제 비용 절감률은 아직 측정하지 않았다.

Milestone 1 리뷰에서도 리뷰의 수정 제안을 그대로 믿지 않고 실패 입력으로 다시 확인했다. 이번에는 그 대상이 코드뿐 아니라 리뷰어가 쓴 결과 설명까지 넓어졌다. 리뷰를 받아들였다는 사실과 설명이 정확하다는 사실도 별개였다.

90초 제한은 결론을 내리지 않았다

추천은 한 배치에서 후보를 최대 다섯 개만 받는다. 새 공고라면 추출 다섯 번과 판정 다섯 번, 최대 열 번의 모델 연산이 순차적으로 실행된다. 배치 전체 제한은 90초다.

리뷰에서는 이 예산이 실제 모델에 빠듯할 수 있다는 의견이 나왔다. 가능성은 있다. 하지만 후보 다섯 개를 실제 모델로 돌려 측정하지 않았으므로, 항상 시간 초과가 난다고 결론 내릴 수도 없었다.

이번에는 제한 시간을 추측으로 늘리거나 추출을 병렬화하지 않았다. 현재 제한은 유지하고, 승인된 합성 입력으로 실제 호출 시간과 실패 지점을 측정하는 후속 이슈 #4를 만들었다. 원래 리뷰 스레드도 해결 완료로 바꾸지 않고 열어뒀다.

지금 테스트가 확인하는 것은 제한 시간이 지나면 배치를 중단하고, 성공한 결과와 실패·미시도를 구분하며, 후속 호출을 멈추는 코드의 동작이다. 실제 제공자의 취소 처리와 완료 시간까지 확인한 것은 아니다.

어디까지 확인했는지

0a78dda 기준으로 로컬 검증을 다시 실행했다. 테스트 숫자를 맞추는 것보다 합성 실행, 실제 공개 API 조회, 실제 모델 검증을 섞지 않는 것이 중요했다.

범위 확인 상태
타입 검사·lint·빌드·고정 lockfile 설치 통과
단위·통합 테스트 112개 통과: shared 7개 + server 105개. 모델은 합성 응답
정책 eval 16개 통과. 모델 정확도 평가가 아님
결과 전체 반환·설정 오류·추출 재사용 회귀 테스트 재실행
HTTP/MCP 연결 합성 제공자와 analyzer로 검색 → 추천 → 명시적 저장 확인
Greenhouse 실제 공개 조회 최초 구현 시 한 보드 조회 확인. 다른 보드나 실제 모델 판정 검증은 아님
로컬 위젯 합성 데모에서 목표·부족분·빈 그룹·분석 실패 표시 확인
실제 Responses API·M3 ChatGPT 호스트 전체 흐름 미검증
인증·사용자별 데이터 격리 미구현. 공개 사용 준비와 별도

처음 ChatGPT에 상태 카드를 연결했던 M0의 성공은 이번 M3의 새 도구와 화면이 실제 호스트에서도 잘 동작한다는 증거가 아니다. 그 부분은 ChatGPT 호스트 검증 이슈 #5에 따로 남겼다.

개인정보·인증·격리는 공개 전 점검 이슈 #6으로 분리했다. 로컬 초안의 병합 가능 여부와 공개 서비스로 타인 데이터를 받아도 되는지는 같은 판단이 아니다.

앱을 빨리 만드는 것과, 한 단계를 끝내는 것

처음의 목적에는 “앱을 만들 수 있다는 것을 보여주자”도 있었다. 요구 사항을 Markdown으로 정리하고, Codex가 구현하고, 다른 세션에서 리뷰하는 흐름만으로도 토이 프로젝트를 꽤 빠르게 진행할 수 있었다.

하지만 이번에 남길 만한 내용은 검색과 추천 도구를 하나씩 붙였다는 사실만은 아니었다.

분석한 결과를 사용자가 다시 돈을 들이지 않고 볼 수 있는지, 설정 문제를 분석 실패로 설명하지 않는지, 실패 뒤에 어떤 작업을 다시 하는지까지 기능의 일부였다. 수정한 코드와 README와 화면이 같은 의미를 전달하는지도 확인해야 했다.

그렇다고 작은 문구와 아직 측정하지 않은 성능 문제를 모두 이번 PR에 담아 끝없이 고치지는 않기로 했다. 확인한 문제는 고치고, 관측이 필요한 문제는 완료 조건을 적은 이슈로 남겼다. 이 글을 정리하는 시점에는 PR을 병합하지 않았다. 글을 먼저 검토하고 남은 범위를 기록한 뒤 M3를 마무리할 예정이다.

Career Radar는 아직 완성된 취업 비서가 아니다. 다만 이제 지정한 보드에서 공고를 찾고, 선택한 공고를 기존 판정·저장 흐름에 연결하는 코드가 생겼다.

다음에 줄여야 할 간격도 분명하다. 기능 목록을 더 늘리는 것보다, 지금까지 합성 입력으로 확인한 흐름을 실제 모델과 ChatGPT 호스트에서도 확인하는 일이다.