resumate 05. 사이드카 대공사 — 인증, 보관함, 한글 PDF, OCR, 음성
넉 달 만에 프로젝트를 다시 열었다. 마지막으로 코드를 만진 게 4월 1일이었고, 어제 홈 화면을 조금 손댄 게 전부다. 오늘은 백엔드를 통째로 갈아엎었다. 설계서를 먼저 쓰고, CLI 경로 탐색과 인증, 보관함, 한글 PDF 내보내기, OCR, 음성 전사, 좀비 프로세스 방지까지 사이드카 전체를 다시 짰다.
코드보다 설계서를 먼저 썼다
다시 시작하면서 제일 먼저 한 건 코딩이 아니라 문서였다. docs/commercialization-design.md에 제품 정의와 설계 원칙을 정리했다. 원칙은 다섯 개로 추렸다.
| 원칙 | 뜻 |
|---|---|
| 명시적인 자료 전달 | 무엇을 AI에게 넘길지 사용자가 고른다 |
| 작업 단위 실행 | 대화가 아니라 "작업"이 단위다 |
| 근거 우선 | 자료에서 확인되는 것만 말한다 |
| 비파괴 편집 | 원본을 덮어쓰지 않는다 |
| 실패를 숨기지 않기 | 조용히 넘어가지 않는다 |
이걸 먼저 적어둔 게 하루 내내 도움이 됐다. 애매한 판단이 나올 때마다 근거 우선 원칙에 맞는지 물어보면 답이 나왔다.
claude CLI가 어디 있는지 모른다
이 앱의 핵심은 claude CLI를 서브프로세스로 띄우는 것이다. 그런데 CLI가 어디 있는지 모른다.
개발 중에는 문제가 없다. 터미널에서 pnpm start로 띄우면 내 셸의 PATH를 그대로 물려받으니까 claude가 그냥 잡힌다. 문제는 패키징한 .app을 Finder에서 더블클릭할 때다. macOS가 GUI 앱에 주는 PATH는 /usr/bin:/bin 수준으로 축소돼 있다. 사용자가 ~/.local/bin이나 homebrew에 설치한 claude는 안 보인다.
claudeBin.ts에서 네 단계로 찾는다. 먼저 CLAUDE_BIN 환경변수가 있으면 명시적 지정이니 무조건 우선한다. 없으면 로그인 셸로 command -v claude를 실행하고, 그래도 못 찾으면 로그인 셸의 PATH를 통째로 가져와 디렉토리를 스캔한다. 마지막으로 ~/.local/bin, ~/.claude/local, /opt/homebrew/bin, /usr/local/bin, ~/.bun/bin 같은 관용적 설치 위치를 직접 확인한다.
핵심은 두 번째 단계다. zsh -l -c로 로그인 셸을 한 번 거치면 사용자의 진짜 PATH가 나온다. 이걸 캐싱해서 세 번째 단계에도 재활용한다. 함정이 하나 있었는데, command -v가 alias나 셸 함수를 반환할 수 있다. 그래서 결과가 /로 시작하는 절대경로일 때만 후보로 채택한다.
같이 만든 auth.ts는 CLI에게 로그인 상태를 물어본다. 이 앱은 API 키를 받지 않기 때문에, 사용자가 claude CLI에 로그인돼 있는지가 곧 사용 가능 여부다.
모델 이름을 핀 고정하지 않기로 했다
4월엔 CHARACTER_MODELS에 모델을 배정만 해뒀는데 두 가지를 바꿨다.
별칭을 쓰기로 했다. claude-sonnet-4-6 같은 전체 모델명 대신 sonnet이라고만 적는다. CLI가 그 유저 플랜에서 쓸 수 있는 최신 모델로 알아서 해석하니까, 새 모델이 나와도 코드를 안 고쳐도 된다.
폴백도 건다. Paws는 opus에서 sonnet, 다시 haiku 순으로 내려가고, 나머지 캐릭터는 sonnet에서 haiku로 내려간다. --fallback-model 플래그로 넘긴다. 1순위가 과부하일 때도 쓰이지만, 진짜 중요한 건 유저 플랜에 그 모델이 없을 때다. Pro 계정에서 opus를 요청하면 CLI가 조용히 sonnet으로 내려준다.
여기서 "조용히"가 걸렸다. 사용자는 opus로 돌아가는 줄 알고 있는데 실제로는 아닐 수 있다. 다행히 강등이 일어나면 system/model_fallback 이벤트가 스트림에 들어와서, 사이드카가 그걸 stderr에 기록하게 해뒀다. 실패를 숨기지 않기 원칙이다.
참고로 이 플래그는 --print 모드에서만 동작한다. 어차피 -p로 쓰니까 상관없었다.
보관함은 파일과 메타데이터를 한 쪽이 소유한다
사용자가 올린 이력서 원본을 어딘가 보관해야 했다. 추출한 텍스트만 남기면 나중에 원본 PDF를 다시 못 준다.
처음엔 파일은 디스크에, 메타데이터는 프론트 SQLite에 두려고 했다. 그러다 멈췄다. 둘이 어긋나면 복구할 방법이 없다. 디스크에 파일은 있는데 DB에 레코드가 없으면, 혹은 그 반대면 어떻게 할 것인가.
그래서 사이드카가 둘 다 소유하게 했다.
~/Library/Application Support/resumate/archive/
index.json ← 메타데이터 (원자적 교체로 기록)
<uuid>__<파일명> ← 원본 바이트파일 옆에 index.json 하나. 단일 출처다.
경로를 잡을 때 함정이 있었다. $HOME을 직접 읽으면 안 된다. Tauri가 spawn한 프로세스는 process.env가 비어 있을 수 있어서 os.homedir()을 써야 한다.
한글 PDF는 조용히 깨진다
결과물을 PDF로 내보내는 기능. 여기서 오늘 제일 오래 헤맸다. 한글 폰트를 PDF에 임베딩해야 하는데 두 가지가 예외 없이 깨졌다.
Pretendard OTF는 로드 자체가 안 된다.
@pdf-lib/fontkit → Not a CFF FontOTF가 CFF 아웃라인이 아니라서 즉시 죽는다. 이건 그나마 에러가 나서 다행이었다. alternative/*.ttf의 TTF 버전으로 바꿔 해결했다.
진짜 문제는 pdf-lib의 subset: true가 한글을 지우는 것이었다. 서브셋(문서에 실제로 쓰인 글자만 폰트에서 뽑아내는 것)을 pdf-lib에 맡기면 성공한다. 예외도 안 나고, 파일도 생기고, 텍스트 추출도 된다. 그런데 열어보면 한글이 빈칸이다. 타입체크로도 안 잡히고, 파일 크기로도 안 잡히고, 텍스트 추출로도 안 잡힌다. 렌더된 이미지를 눈으로 봐야만 보인다. 에러 없이 성공했다는 건 검증이 아니었다.
결국 서브셋을 harfbuzz(subset-font)로 미리 뜬 다음, pdf-lib에는 subset: false로 넘기는 조합으로 고정했다. 문서당 2.5MB가 60KB로 줄었다.
폰트는 import ... with { type: "file" }로 참조한다. bun build --compile이 바이너리에 같이 넣어주니까 개발과 패키징 동작이 같아진다.
시스템 폰트를 안 쓴 이유는 라이선스다. macOS 기본 한글 폰트는 PDF 임베딩 후 배포에 제약이 있어서, OFL 라이선스인 Pretendard를 저장소에 커밋했다.
스캔 이력서와 음성은 로컬로 처리했다
이미지로 스캔된 이력서가 들어오면 /parse-pdf가 빈 문자열을 돌려준다. macOS에는 Vision 프레임워크가 이미 있으니 Swift로 OCR 헬퍼를 하나 짰다.
let request = VNRecognizeTextRequest()
request.recognitionLanguages = ["ko-KR", "en-US"]73줄짜리 파일 하나다. 외부 OCR API를 붙이는 것보다 훨씬 간단했고, 비용도 없고, 네트워크도 안 탄다. macOS 전용이 되는 대가로.
음성도 로컬로 갔다. Echo가 면접 답변을 녹음해서 전사할 수 있어야 했는데, whisper.cpp의 whisper-cli를 찾아서 실행한다. 없으면 없다고 알려준다.
두 기능 다 클라우드 API를 안 쓴다는 이 프로젝트의 성격과 맞았다. Pro 구독 하나로 끝내겠다고 시작한 프로젝트에 OCR 비용을 따로 붙이는 건 이상하다.
자식이 부모를 지켜보게 했다
앱을 강제 종료하면 사이드카 프로세스가 좀비로 남는다. Rust 쪽에서 종료 시 kill을 하지만, 크래시하면 그 코드가 실행되지 않는다.
그래서 사이드카에게 부모를 감시하게 했다. --parent-pid로 앱 PID를 넘기면 주기적으로 부모 생존을 확인하고, 죽었으면 스스로 종료한다. 부모가 자식을 정리하는 대신 자식이 부모를 지켜본다. 크래시든 강제 종료든 상관없이 동작한다.
곁다리로 tools.ts에서 anthropic SDK 타입 의존을 걷어냈다. 도구 정의에 SDK 타입을 쓰고 있었는데, 이 앱은 SDK로 API를 부르지 않는다. CLI를 서브프로세스로 띄울 뿐이다. 타입 하나 때문에 안 쓰는 의존성이 남아 있는 게 이상해서 직접 선언으로 바꿨다.
절반은 AI 기능이 아니라 설치 환경과 싸우는 코드였다
CLI가 어디 있는지, PATH가 왜 잘렸는지, 폰트가 왜 안 박히는지, 프로세스가 왜 안 죽는지. 데스크톱 앱은 이게 본체다. 브라우저에서는 런타임이 하나뿐이니까 신경 쓸 일이 없던 것들이 전부 올라온다.
한글 PDF 건은 좀 무서웠다. 성공하면서 틀리는 코드를 처음 제대로 만났다. 자동화된 검증이 전부 통과하는데 결과물은 빈칸이었다. 이런 종류는 결국 사람이 봐야 한다.
백엔드는 다 됐는데 이 기능들을 붙일 프론트엔드가 없다. 무엇을 AI에게 넘길지 고르는 UI도, 테스트도 아직 하나도 없다.