이력서와 공고를 비교하는 Career Radar에 로컬 저장을 붙였다. 지난 글에서는 AI가 만든 공고 판정 코드를 PR로 검증한 과정을 정리했다. 없는 경력을 긍정 근거로 인정하지 않는지, 우대 조건을 지원 불가 사유로 잘못 올리지 않는지 확인했다.

이번 Milestone 2에서는 그 판정 뒤의 흐름을 붙였다. 공고 URL을 읽고, 지원할지 판단한 결과를 저장하고, 지원·면접·탈락 같은 상태를 기록하는 기능이다.

화면이 생기고 데이터가 남으니 조금은 직접 써볼 도구처럼 보였다. 그러자 기능을 더 붙이는 것보다 먼저 궁금해진 것이 있었다.

이력서를 넣는 앱인데, 개인정보는 괜찮을까? 어차피 로컬에서 돌리는 거 아닌가?

이번 PR에서 가장 오래 남은 질문은 이것이었다. 로컬에 저장한다는 설명만으로는, 어디에 데이터가 남고 누가 접근할 수 있는지 알 수 없었다.

답부터 쓰면, 로컬 저장이어도 세 가지가 빠져 있었다. 비운 메모가 과거 이력에 남았고, 접근 검사는 DNS rebinding을 모사한 요청을 통과시켰고, 전체 데이터를 지우는 명령이 없었다. 새 이력에서 메모를 제외하고, 접근 검사를 보완하고, 초기화 명령을 추가했다. 다만 OpenAI API나 ChatGPT로 전달한 데이터는 이 로컬 삭제의 대상이 아니다.

이 글은 PR #2의 마지막 보완 커밋 55cea66까지 정리한 병합 전 기록이다. 아직 공개 서비스가 아니라 소유자 한 명이 직접 써보며 다듬는 초안이다. 재현과 데모에는 실제 이력서 대신 합성 데이터를 사용했다.

구현과 재검토는 서로 다른 AI 세션에서 진행했다. 아래의 ‘구현 세션’과 ‘검토 세션’은 각각 코드를 수정한 세션과 별도 체크아웃에서 검증한 세션을 가리킨다.

판정을 보여주는 앱에서, 그다음을 기록하는 앱으로

Career Radar는 이력서를 공고에 맞춰 어떻게든 고쳐주는 도구보다, 지금 경력으로 지원할 가치가 있는 공고인지 먼저 판단하는 도구에 가깝다.

Milestone 1은 REALISTIC / STRETCH / PASS 판정과 근거를 보여주는 데까지였다. Milestone 2에서는 아래 흐름을 연결했다.

기본 이력서 → 구조화된 프로필
                      +
붙여 넣은 JD 또는 허용된 공고 URL
                      ↓
              근거 기반 판정
                      ↓
              지원 후보로 저장
                      ↓
       지원·면접·탈락 등 상태 기록
                      ↓
              지원 현황 확인

공고를 알아서 탐색하거나, 맞춤 이력서를 작성하거나, 실제로 지원서를 보내는 기능은 아직 없다. URL 읽기도 사용자가 명시적으로 전달한 공개 공고와 허용된 호스트에 한정했다. 읽지 못하는 페이지는 JD를 붙여 넣는 방식으로 돌아간다.

저장할 때는 판정 결과의 ID를 사용한다. 같은 판정을 다시 저장하라는 요청이 와도 이미 기록한 면접 상태를 초기 상태로 되돌리지 않게 했다. 나중의 상태와 별개로, 처음 저장한 판정도 연결해 둔다.

앞 글의 마지막에는 실제 모델 검증을 다음 단계로 적었다. 그 검증은 아직 끝내지 못했다. 대신 이번에는 합성 데이터로 확인할 수 있는 저장·상태 변경·화면 연결을 먼저 확장했다. 순서를 바꾼 만큼, 화면이 동작하는 것과 실제 모델이 이력서를 잘 판단하는 것은 계속 나눠서 설명하기로 했다.

SQLite를 붙이면서 보관 기간도 바뀌었다

이전 버전의 프로필과 공고는 메모리에 있었고, 마지막 저장 후 30분이 지나면 만료됐다. 서버를 종료해도 사라졌다.

지원 현황을 이어서 보려면 그 방식으로는 부족하다. 다음 날 앱을 열었을 때 어떤 공고에 지원했는지 남아 있어야 하기 때문이다. 그래서 일반 실행 경로에 SQLite 파일 저장을 붙였다.

이 변경은 기능 추가이면서 동시에 데이터 보관 정책의 변경이었다.

구분 Milestone 1 Milestone 2 일반 실행
저장 위치 프로세스 메모리 로컬 SQLite 파일
보관 수명 마지막 저장 후 30분 만료 소유자가 지우기 전까지 유지
서버 재시작 기존 기록 소멸 기존 기록 유지

DB에는 구조화된 프로필, 공고, 판정 스냅샷, 지원 기록과 이벤트 이력이 남는다. 원본 이력서 텍스트를 그대로 저장하는 컬럼이 없다고 해서 개인정보가 없는 것은 아니다. 경력 문장, 판정 근거, 자유 입력 메모에도 개인을 식별할 정보가 들어갈 수 있다.

README에도 이전 30분 만료가 더 이상 적용되지 않는다고 적었다. “로컬 저장을 지원한다”와 함께 “자동으로 사라지지 않는다”도 알려야 했다.

메모를 비웠는데 이전 메모가 남아 있었다

첫 번째로 확인한 문제는 이벤트 이력이었다.

지원 상태가 바뀔 때마다 당시의 application 객체를 이벤트 테이블에 복사하고 있었다. 상태 변경을 기록하기에는 간단한 방법이다. 하지만 그 객체에는 자유 입력 메모인 notes도 들어 있었다.

예를 들어 메모를 적은 뒤 빈 문자열로 수정하면 현재 application의 메모는 비워진다. 현재 지원 기록만 조회하면 삭제가 끝난 것처럼 보인다. 그런데 과거 이벤트에는 이전 메모가 그대로 남는다.

현재 지원 기록: notes = ""
과거 이벤트:    notes = "이전에 입력한 메모"

현재 행의 수정이 다른 테이블에 복사된 과거 값까지 지워주지는 않았다.

이번에는 새 이벤트를 기록할 때 notes를 제외하도록 바꿨다. 코드의 핵심은 아래 한 줄이다.

JSON.stringify({ ...application, notes: undefined })

상태와 지원 건을 식별하는 정보는 남기되, 자유 입력 메모까지 매번 복제하지 않도록 한 것이다. 메모를 입력하고 비운 뒤 이벤트 데이터에 해당 문장이 없는지 확인하는 테스트도 추가했다. 수정된 저장 로직에서 확인할 수 있다.

이것은 앞으로 메모 사본을 만들지 않는 수정이다. 수정 전 DB에 이미 쌓인 이벤트를 소급해서 정리하는 마이그레이션은 아니다. 또한 현재 필드가 비었다는 것과 DB 파일의 남은 공간까지 정리했다는 것도 다른 이야기다. 전체 데이터를 비우는 경로는 따로 필요했다.

삭제 명령도 만들고, 파일까지 확인했다

이전 안내는 DB 파일을 직접 지우라는 수준이었다. 이번에는 소유자가 실행할 수 있는 명령을 추가했다.

# 서버를 먼저 종료한 뒤 실행한다.
# 설정된 DB의 프로필·공고·판정·지원 기록·이력을 모두 비운다.
pnpm db:reset

이 명령은 특정 메모만 지우는 기능이 아니다. 현재 설정된 Career Radar DB 전체를 초기화한다. 서버와 같은 환경 설정 및 DB 경로 해석을 사용하도록 공용 설정 코드도 분리했다.

내부에서는 테이블을 비운 뒤 VACUUM으로 DB 파일을 재구성하고, wal_checkpoint(TRUNCATE)를 실행한다. WAL은 SQLite가 변경 내용을 기록하는 별도 파일이다.

행을 삭제했다고 파일 안의 이전 내용이 항상 즉시 지워지는 것은 아니다. SQLite의 VACUUM 문서도 삭제된 공간의 재사용과 파일 재구성을 구분해 설명한다. 그래서 조회 결과가 0건인지뿐 아니라 파일에 검증용 문자열이 남는지도 확인했다.

검토 세션에서는 사용자 DB와 분리된 임시 DB에 합성 표식을 넣었다. 초기화 전에는 DB 파일에서 그 표식이 검색됐고, 초기화 후에는 모든 테이블의 행 수가 0이었으며 파일에서도 표식이 검색되지 않았다. 연결을 닫은 뒤에는 해당 테스트 DB의 WAL·SHM 보조 파일도 남아 있지 않았다.

이 확인을 “어디에서도 복구할 수 없다”는 보장으로 넓히지는 않았다. 검사한 것은 해당 임시 DB 파일과 보조 파일이었다. 별도 백업이나 디스크 스냅샷, 외부 서비스에 전달된 사본까지 삭제한 것은 아니다.

이번에 얻은 것은 완전한 개인정보 관리 시스템이 아니라, 어느 DB를 비우는지 알 수 있고 그 결과를 확인할 수 있는 삭제 경로였다.

로컬 서버의 Origin 검사도 충분하지 않았다

두 번째는 로컬 HTTP 서버의 요청 검사였다.

초기 검사는 Origin이 있을 때 Host 헤더로 만든 문자열과 비교했다. 코드로는 아래 조건에 걸린 요청을 거부하는 방식이었다.

origin !== `http://${host}`

서로 같으면 정상적인 출처라고 생각하기 쉬운 조건이다.

하지만 DNS rebinding 상황에서는 둘 다 같은 외부 도메인일 수 있다. 공격자가 제어하는 도메인이 루프백 주소를 가리키게 되면, 요청이 로컬 서버로 도착하더라도 헤더에는 그 도메인이 남을 수 있다. 두 값이 서로 같다는 것만으로 둘 중 어느 것도 신뢰할 수는 없다.

예를 들어 http://attacker.example:8000에서 같은 출처로 보내는 요청은 Host: attacker.example:8000, Origin: http://attacker.example:8000 형태가 될 수 있다. DNS가 가리키는 IP가 바뀌어도 URL의 호스트는 그대로여서 이 비교를 통과한다.

리뷰에서는 로컬 서버에 node:http로 직접 요청을 보내 HostOrigin에 같은 외부 도메인을 넣었다. 기존 게이트에서는 /health가 200으로 응답했다.

GET /health
Host: attacker.example:<테스트 서버 포트>
Origin: http://attacker.example:<테스트 서버 포트>
기존 게이트 응답: 200

여기서 재현한 것은 접근 검사 조건의 우회다. 실제 피해자의 브라우저에서 DNS 전환부터 개인정보 유출까지 수행한 전체 공격 실험은 아니다.

수정 후에는 두 헤더를 서로 비교하는 대신 각각 루프백 허용 목록으로 검사한다. Origin이 없는 요청도 Host 검사는 통과해야 한다. 회귀 테스트는 다음 경우를 확인한다.

요청 조건 수정 후 기대 응답
외부 도메인의 Host와 같은 도메인의 Origin 403
외부 도메인의 Host, Origin 없음 403
localhost Host와 localhost Origin 200
127.0.0.1 Host, Origin 없음 200
IPv6 루프백 Host와 Origin 200

HTTP 회귀 테스트에 이 조건이 남아 있다. 요청 게이트 뒤에서 더 이상 역할이 없어진 CORS 미들웨어와 의존성도 제거했다.

MCP의 Streamable HTTP 보안 경고 역시 DNS rebinding 방지를 위한 Origin 검사, 로컬 바인딩, 인증을 별도로 다룬다. 루프백 헤더 검사는 사용자 신원을 증명하지 않는다.

“로컬 저장”과 “외부로 보내지 않음”은 다르다

처음의 개인정보 질문으로 돌아가면, 지금 구조를 그냥 “어차피 로컬”이라고 설명할 수는 없다.

데이터가 어디로 가는지는 실행 경로마다 다르다. 표의 ‘실제 분석’ 행은 코드에 구현된 전송 경로를 보여준다. 실제 모델까지 포함한 전체 흐름의 검증 상태는 뒤의 검증 범위 표에 따로 적었다.

경로 처리하거나 보관하는 데이터 로컬 삭제와의 관계
API 키 없는 데모 미리 작성한 합성 프로필과 판정. 모델 호출 없이 메모리 DB에서 상태 변경 프로세스를 종료하면 데모 기록이 사라짐
ChatGPT에 입력 대화에 제공한 이력서·공고 등의 내용은 ChatGPT에서도 처리 로컬 DB를 비워도 ChatGPT 대화가 삭제되지는 않음
실제 분석 이력서 텍스트와 JD를 OpenAI API로 보내 각각 구조화하고, 구조화된 프로필·공고를 다시 보내 판정 API로 전달된 데이터는 db:reset의 삭제 대상이 아님
일반 실행에서 저장 후 구조화된 프로필·공고·판정·지원 기록·이력을 로컬 SQLite 파일에 보관 db:reset으로 설정된 DB 전체를 초기화

분석 코드의 세 Responses API 요청에는 모두 store: false가 들어 있다. 하지만 이 설정을 ‘외부에 전혀 보관되지 않는다’는 뜻으로 읽어서는 안 된다. OpenAI 공식 문서는 응답을 보관하는 기능과 악용 모니터링 로그를 구분한다. 기본 악용 모니터링 로그에는 입력과 응답이 포함될 수 있고 최대 30일 보관되며, 법적 의무나 위해 방지를 위해 더 오래 보관되는 예외도 있다. store: false만으로 Zero Data Retention이 적용되는 것은 아니다. (OpenAI API 데이터 처리 문서)

ChatGPT 대화도 별도의 보관·삭제 정책을 따른다. 실제 이력서를 넣기 전에는 입력할 정보를 줄이고, 사용하는 서비스의 데이터 처리 설정과 정책을 확인해야 한다.

이 단계의 사용 범위는 한 사람이 자신의 로컬·비공개 환경에서 확인하는 것으로 잡았다. 아직 갖추지 않은 보호 기능은 아래 검증 범위 표에 모았다.

마지막에는 삭제 명령의 시작 조건을 놓쳤다

큰 수정을 마치고도 작은 실행 문제가 하나 남았다.

새 체크아웃에서 의존성만 설치하고 pnpm db:reset을 실행하면 shared 패키지의 dist/index.js를 찾지 못했다. 삭제 스크립트가 불러오는 저장소 코드가 빌드된 shared 패키지를 필요로 했기 때문이다.

이미 빌드한 개발 환경에서는 정상 동작하므로, 일반 테스트를 순서대로 실행하는 동안에는 드러나지 않았다. 별도 체크아웃에서 빌드 전에 명령을 실행하자 ERR_MODULE_NOT_FOUND로 종료됐다.

마지막 커밋 55cea66에서는 루트 명령에 shared 선행 빌드를 추가했다.

"db:reset": "pnpm --filter @career-radar/shared build && pnpm --filter @career-radar/server db:reset"

보완 뒤에는 shared 빌드 산출물이 없는 상태에서 명령을 다시 실행해 정상 동작을 확인했다.

핵심 삭제 로직이 틀린 문제는 아니었다. 그래도 사용자가 데이터 정리가 필요할 때 실행할 명령이라면, 개발자가 이미 빌드를 해두었을 것이라는 전제는 줄이는 편이 좋았다.

직접 눌러볼 수 있는 데모와 검증 범위

블로그에서 “만들었다”고 소개하려면 다른 사람도 무엇이 움직이는지 확인할 수 있어야 했다. 그래서 API 키 없이 실행하는 별도 데모를 뒀다.

git checkout 55cea66cf407065cb319c887cb8824632aca9458
pnpm install
pnpm demo

저장소를 새로 내려받은 뒤, 작업 중인 변경이 없는 저장소 루트에서 실행하는 명령이다. 글을 쓰는 시점에는 병합 전이라 이 글에서 다룬 커밋을 명시했다. 실행에는 Node.js 22.13 이상과 pnpm 10이 필요하다.

브라우저에서 http://127.0.0.1:8001을 열면 세 가지 판정 카드와 지원 현황을 볼 수 있다. 첫 번째 지원을 면접으로 바꾸는 버튼을 누르면 저장소가 갱신되고 화면에도 반영된다.

여기에는 미리 작성한 프로필과 판정이 들어 있다. 모델이 즉석에서 판단하는 데모는 아니다. SQLite 쓰기와 화면 갱신은 실제 코드로 동작하지만, 데모 DB는 메모리에 있어서 프로세스를 종료하면 초기화된다. 앞서 설명한 일반 실행 경로의 파일 DB와 다르다.

최종 검토에서 근거로 삼은 결과와 남은 범위를 정리했다.

항목 확인한 결과와 범위
설치·lint·typecheck·build 검토 세션의 afadfc4 격리 체크아웃에서 통과
단위·통합 테스트 검토 세션의 afadfc4에서 67개 통과: shared 4개, server 63개
정책 eval 검토 세션의 afadfc4에서 합성 fixture 16개 통과. 실제 모델 정확도 아님
전체 DB 초기화 검토 세션에서 임시 DB의 행 수와 합성 표식 제거 확인
보완 커밋의 typecheck·테스트 구현 세션의 55cea66에서 typecheck 오류 0건, 테스트 67개(4 + 63) 재실행·통과
빌드 전 삭제 명령 실행 구현 세션의 55cea66에서 shared 빌드 산출물 없이 재검증. 검토 세션은 변경 내용과 전달된 결과 확인
실제 모델 호출·최신 ChatGPT 호스트 화면 M2 전체 흐름 검증은 미완료
DB 암호화·사용자 인증·사용자별 데이터 격리 미구현. 루프백 접근 검사나 파일 접근 권한으로 대체되지 않음

보완 커밋에서 통과한 항목과 별도 체크아웃에서 직접 재실행한 항목을 구분해 적었다.

지원 결과가 쌓여도 지금 화면의 집계는 기록된 상태의 개수다. 특정 직무에서 면접이 많다는 사실만으로 합격 확률이나 채용 시장의 반응을 단정하는 분석은 아직 아니다.

이번 PR을 어디서 마무리할지

처음에는 빠르게 동작하는 초안을 만들어 보여주고 싶었다. 그렇다고 이력서를 다루는 앱에서 데이터가 남는 곳을 모른 채 넘어가기는 어려웠다.

이번에는 메모 사본이 생기는 경로를 줄였고, 전체 DB를 비우는 명령을 만들었고, 로컬 요청 게이트가 잘못 허용하던 조건을 회귀 테스트로 남겼다.

최종 검토 시점에 PR은 병합 가능한 상태였고, 리뷰 스레드 7개도 모두 해결 상태였다. 그 숫자 때문에 병합할 만하다고 판단한 것은 아니다. 이번 로컬·개인용 초안의 범위에서 제기된 문제가 해결됐고, 확인하지 않은 범위를 숨기지 않았기 때문이다.

리뷰 방식도 제품에 맞출 필요가 있었다. 공개 서비스 출시와 개인용 초안의 병합은 같은 판단이 아니다. 다만 초안이라는 이유로 개인정보가 어디에 남는지, 지우려면 무엇을 해야 하는지까지 미룰 수는 없었다.

명세로 시작한 첫 단계가 무엇을 만들지 정한 과정이고, 지난 단계가 판정을 어디까지 믿을지 확인한 과정이었다면, 이번에는 사용한 뒤 무엇이 남는지 확인하는 과정이었다.

아직 완성된 취업 비서는 아니다. 공고를 찾고 지원서를 보내주는 앱도 아니다. 대신 판정을 저장하고 다음 상태를 기록하는 흐름, 그리고 필요할 때 로컬 기록을 비우는 경로가 생겼다.

이 정도에서 M2를 마무리하고, 다음에는 개인정보 없는 합성 입력으로 실제 모델과 ChatGPT 연결을 통과시켜 보려고 한다. 이번에 만든 데모가 보여주는 것과, 실제로 쓸 때 필요한 것 사이의 간격을 조금씩 줄여갈 생각이다.

구현과 리뷰 기록: Career Radar PR #2