본문으로 건너뛰기

resumate 13. 사용량 몫 표시와 인앱 자동 업데이트 구현

·28 min read·13 / 17

설정 화면의 사용률 막대는 계정 전체 숫자만 보여줬다. 이 앱이 그 중 얼마를 썼는지는 어디에도 없었다.

사용량은 근사값이라는 것부터 인정하고 시작했다

resumate는 유저의 Claude Code·Codex 자격증명으로 CLI를 별도 프로세스로 띄운다(spawn). API 입장에서는 그게 유저가 터미널에서 직접 쓴 것과 구분되지 않는다. oauth/usage 응답에 seven_day_oauth_apps라는 필드가 있어서 앱별 사용량을 받을 수 있을 것처럼 보였지만, resumate는 별도 OAuth 앱으로 등록돼 있지 않아 그 필드는 계속 null로 왔다. used_dollars·limit_dollars도 이 플랜에서는 null이라 토큰 수를 %로 환산할 방법조차 없었다.

그래서 정확한 값을 구하는 대신 대화가 도는 동안 오른 만큼을 우리 몫으로 적립하기로 했다. 전체 막대와 단위(%)가 같아서 그대로 겹쳐 그릴 수 있다는 게 이 방식을 택한 이유다. 화면에는 근사 기호를 붙이지 않는다. 근사값이라는 사실을 알고 있되 사용자에게는 확정된 숫자처럼 보여주기로 한 의도된 선택이다.

이 로직을 usageAttribution.ts의 순수 함수 accrue로 뗐다. 상태는 세 필드뿐이다.

interface Accrual {
  lastSeenPercent: number;  // 마지막으로 관측한 계정 전체 사용률
  resetsAt: number | null;  // 이 창이 초기화되는 시각
  appPercent: number;       // 이 창에서 resumate 몫으로 적립된 사용률
}

관측이 들어올 때마다 resetsAt이 크게 밀렸으면 창이 갈린 것으로 보고 기준선을 새로 잡는다.

flowchart TD
    A["사용량 관측 도착"] --> B{"resetsAt이 60초 넘게 밀렸나"}
    B -->|예| C["창이 갈린 것으로 보고 기준선 재설정"]
    B -->|아니오| D["직전 관측 대비 상승분을 몫으로 적립"]

여기서 60초 여유를 뒀다. Claude 사용량 API는 2026-08-21T08:40:00.137452+00:00처럼 마이크로초까지 주는데, 서버가 경계를 매번 다시 계산해 몇 초씩 흔들릴 수 있다. 그 흔들림마다 창이 갈린 것으로 판정하면 적산이 계속 0으로 리셋되어 기능이 조용히 죽는다. 실제 창 경계는 5시간·7일 단위로 밀리므로 60초 여유로 흔들림과 실제 리셋을 구분할 수 있다. 판정에 Date.now()(로컬 시계)는 쓰지 않는다. 관측값만으로 판정해야 로컬 시계가 틀어져도 오판하지 않는다.

이 값을 매기는 건 usageTracker.ts다.

  • 관측 지점을 한 곳으로 모은다. 사용량을 읽는 모든 경로가 trackedUsage를 통과해야 한다. 설정 화면 새로고침도 델타를 볼 기회라, 놓치면 그 구간의 상승분이 통째로 미분류가 된다.
  • turn마다 폴링하지 않는다. 짧은 간격으로 물었을 때 429가 왔다. 대화가 도는 동안은 60초 주기로 묻고, 마지막 turn이 끝난 뒤 5분 유예 동안만 더 본다. 유예가 필요한 이유는 사용량 API가 즉시 반영되지 않아서다. turn이 끝나는 순간 분류를 닫으면 마지막 응답의 사용량을 통째로 놓친다. 대화가 동시에 여러 개 돌 수 있어 진행 카운터로 세고, /chatfinally에서 종료를 표시해 취소·실패에도 폴링이 안 남게 했다.
  • 계정이 바뀌거나 로그아웃하면 적산을 버린다. 남기면 이전 계정에서 쌓은 몫이 새 계정 막대에 칠해진다.

상태는 ~/Library/Application Support/resumate/usage-attribution.json.tmp 파일로 쓴 뒤 rename으로 갈아치우는 원자적 교체로 저장한다. 경로를 os.homedir()로 잡는 것도, 저장 경로를 예약 시점에 미리 캡처해두는 것도 archive.ts와 같은 이유다. Tauri가 띄운 프로세스는 process.env가 비어 있을 수 있고, 파일 쓰기 타이머가 뒤늦게 깨어나면 그 사이 바뀐 경로(테스트의 임시 디렉터리 등)를 따라가 엉뚱한 곳에 쓸 수 있다.

막대는 색으로 두 가지 일을 시키지 않았다

SettingsView.tsxUsagePanel이 이 값을 그린다. 여기까지 오는 동안 두 방식을 시도했다가 버렸다.

  • 같은 색조의 명도 차이로 구간을 나눴더니 8px 높이에서는 그라디언트처럼 보여 자세히 봐야 구분됐다.
  • 막대 안에 얇은 선으로 겹쳐 얹기는 굵기가 다른 실오라기가 왼쪽에 박혀 흠집처럼 보였다.

결국 두 구간을 flex로 나란히 두고 같은 높이(8px)로 붙였다. translateX로 밀어 배치하지 않은 이유는 퍼센트 translate가 자기 폭 기준이기 때문이다. 몫이 1%일 때 오프셋이 5900%가 나오고 몫이 0이면 0으로 나누기가 된다. flex는 순서대로 배치를 공짜로 준다. 등장 애니메이션은 translateX로 -100%에서 0까지 밀어 넣는 대신 transform-origin: leftscaleX를 썼다. 밀어 넣으면 그룹의 오른쪽 끝이 먼저 보여서 accent 블록이 앞장서서 들어오는 것처럼 보이는데, 진행 막대로는 거꾸로다.

색은 accent(#a99bff) 하나만 쓴다. provider 색(클로드 주황, 코덱스 밝은 회백색)은 남의 브랜드 색이고, warn·danger는 기능 색이라서 이 막대에 올 수 있는 색 중 앱 팔레트 소속은 accent뿐이었다. 사용률에 따라 노랑(70%)·빨강(90%)으로 색을 바꾸는 안은 걷어냈다. 범례에서 이 색은 "resumate 몫"이라는 분류를 가리키는데 같은 색이 위험도까지 나타내면 노란 점을 보고 그게 경고인지 다른 항목인지 알 수 없다. 그 판단으로 usageFormat.tsusageTone도 함께 지웠다. 몫이 0이어도 범례에는 적는다. 있을 때만 보여주면 이 앱으로 쓴 게 없는 동안 화면에 흔적이 없어 기능이 도는지 고장인지 구분할 수 없다.

설정 화면에 목차(SettingsNav)도 붙였다. activeSectionId는 스크롤이 없거나(scrollHeight <= clientHeight) 화면 안에 다 들어오는 경우 첫 항목을, 끝까지 내렸으면 마지막 항목을 강제로 켠다. 마지막 섹션이 화면보다 짧으면 그 제목이 위쪽 경계까지 올라올 수 없어서 경계 판정만으로는 영원히 켜지지 않기 때문이다.

작업창을 다시 띄우면 이전 화면이 한 번 보였다

캐릭터 창을 닫았다 다시 열거나 다른 캐릭터로 넘어갈 때, 창이 뜨는 순간 옛 화면이 한 프레임 노출된 뒤 새 화면으로 바뀌는 번쩍임이 있었다.

원인은 상태를 먼저 바꾸는 것만으로는 부족하다는 데 있었다. 각 화면은 <Activity>(React 19.2에 들어온 컴포넌트로, 자식을 display: none으로 숨긴 채 상태를 유지하고 다시 보일 때 이전 상태로 복원한다)로 숨겨두는데, 드러나는 렌더가 창을 띄우는 그 한 번의 커밋 단계(렌더 결과를 DOM에 반영하는 단계)에 끝나지 않는다. 캐릭터 화면은 Tauri가 따로 띄우는 네이티브 창이라, 숨어 있는 동안 마지막으로 그린 화면을 그대로 들고 있다. 그 사이에 창을 보이면 이전 화면이 먼저 보였다가 바뀐다. 게다가 기다릴 대상이 한 겹이 아니다. 캐릭터 화면은 바깥(data-view="character")과 안쪽(data-character="nyx") 각각이 <Activity>라, 다른 캐릭터를 보다 닫고 새 캐릭터를 누르면 바깥은 이미 드러나 있어 그 조건만으로는 통과해버려 이전 캐릭터가 번쩍인다.

revealWindow.tswaitForRevealed가 넘겨받은 selector들이 전부 checkVisibility를 통과할 때까지 8ms 간격으로 폴링한다. 반드시 상한(400ms)을 둔다. requestAnimationFrame은 숨은 창에서 안 돌 수 있고, 대상 화면이 Suspense로 멈춰 있으면 영영 드러나지 않을 수도 있어서다. 그때는 번쩍임을 감수하고 그냥 띄운다. 안 열리는 창이 번쩍이는 창보다 나쁘다는 판단이다.

const waited = await waitForRevealed([
  `[data-view="${view}"]`,
  ...(view === "character"
    ? [`[data-character="${activeCharacter}"]`]
    : []),
]);

기다리는 동안 사용자가 창을 닫아버릴 수도 있다. 그 경우를 놓치면 상태는 '닫힘'인데 투명한 창이 떠서 도크 위 마우스 이벤트를 전부 삼키는 문제가 생기므로, effect cleanup에서 cancelled 플래그를 세워 기다림이 끝난 뒤 상태를 다시 확인한다.

캐릭터 클릭 핸들러도 함께 손봤다. 예전에는 goTo("character") 뒤 곧바로 setFocus를 불렀는데, 그러면 위의 대기 effect보다 먼저 창이 앞으로 나와 이전 화면이 그대로 노출됐다. tao(Tauri가 쓰는 창 관리 라이브러리)의 macOS set_focus는 창이 최소화되지 않고 보이는 상태일 때만 makeKeyAndOrderFront(창을 최상단으로 올려 키 창으로 만드는 macOS API)와 activateIgnoringOtherApps(다른 앱을 제치고 자기 앱을 활성화하는 API)를 호출한다. 그래서 이미 열려 있던 경우에만 포커스를 요청하도록 바꿨다. 창 표시·숨김은 여전히 showWorkWindow effect 하나가 전담한다.

번쩍임을 잡은 날 v0.2.4를 릴리스했다.

인앱 자동 업데이트를 설계하고, 계획을 세우고, 실제로 만들었다

release.yml이 main push마다 DMG를 만들어 공개 배포 레포에 릴리스로 올리는 것까지는 이미 자동이었다. 그런데 그 다음이 끊겨 있었다. 유저는 새 버전이 나온 사실을 알 방법이 없고, 알더라도 릴리스 페이지에서 DMG를 다시 받아 응용 프로그램 폴더에 끌어다 놓고 우클릭으로 열어야 했다. 타깃이 일반 취업준비생이라 이 마찰은 사실상 "업데이트 안 함"과 같다.

새 버전 알림만 띄우는 안은 마찰을 유저에게 돌려주는 일이었다

"새 버전 있음"만 띄우고 브라우저로 릴리스 페이지를 여는 방법이 제일 싸지만, 그건 마찰을 그대로 유저에게 되돌려주는 것이라 문제를 풀지 못한다. 그래서 tauri-plugin-updater를 붙여 앱이 직접 받아 자기를 교체하고 재시작하는 쪽으로 갔다.

DMG는 애드혹 서명(APPLE_SIGNING_IDENTITY="-", Apple 개발자 인증서 없이 신원 정보를 넣지 않고 찍는 서명)이고 Apple 공증도 없는 상태였는데, 이 방식은 그래도 성립한다. updater 서명은 Apple 신원과 무관한 자체 ed25519 서명(minisign 방식의 공개키 서명으로, 앱에 심어둔 공개키로 내려받은 파일의 서명을 검증한다)이라 앱이 내려받은 아티팩트를 스스로 검증한다. macOS의 quarantine은 앱이 만든 파일을 격리할지 스스로 정하는 옵트인 속성이고(LSFileQuarantineEnabled 기본값은 격리하지 않음), updater가 직접 쓴 파일에는 붙지 않는다. quarantine 안내는 그대로 남긴다. 최초 설치는 여전히 브라우저로 받은 DMG이기 때문이다.

tauri.conf.json에 업데이트 아티팩트 생성 옵션(bundle.createUpdaterArtifacts)이 켜지는 순간 tauri build는 서명 개인키(TAURI_SIGNING_PRIVATE_KEY)를 요구한다. 그대로 두면 로컬 pnpm build:dmg:preview가 키 없이는 죽는다. 그래서 createUpdaterArtifacts만 CI 전용 오버레이 config(src-tauri/updater.conf.json)로 분리하고, 앱 런타임이 읽는 pubkey·endpoints는 base config에 남겼다.

확인은 삼키고, 설치는 던지게 했다

src/lib/updater.tscheckForUpdate는 어떤 이유로도 던지지 않는다. 부팅 직후 실행되는 경로라 오프라인·404·서명 검증 실패가 앱 시작을 막으면 안 되기 때문이다.

export async function checkForUpdate(): Promise<AvailableUpdate | null> {
  try {
    const update = await check();
    if (!update) return null;
    return {
      version: update.version,
      currentVersion: update.currentVersion,
      body: update.body ?? "",
      handle: update,
    };
  } catch (error) {
    console.warn("[updater] 업데이트 확인 실패", error);
    return null;
  }
}

이 try/catch는 테스트도 함께 떠받친다. App을 마운트하는 vitest는 jsdom이라 진짜 check가 IPC 없이 던지는데, 여기서 삼키지 않으면 관련 없는 테스트들이 무더기로 깨진다. 반대로 실제 설치를 수행하는 runUpdate는 실패를 던진다. 유저가 버튼을 눌러 시작한 동작이므로 조용히 삼키면 아무 일도 안 일어난 것처럼 보인다.

UpdateModal.tsx는 세 상태를 오가는 상태 기계다. 다운로드 상태에서는 진행률을 퍼센트로 함께 표시한다.

stateDiagram-v2
    state "새 버전 있음" as available
    state "내려받는 중" as downloading
    state "설치 중" as installing
    [*] --> available
    available --> downloading
    downloading --> installing
    installing --> [*]

내려받는 동안에는 배경 클릭, Escape, '나중에' 버튼을 전부 막는다. 중간에 닫으면 앱 교체가 반쯤 된 상태를 만들 여지가 있어서다. '나중에'는 컴포넌트를 언마운트하는 게 전부라 별도 영속화가 없다. 확인이 실행당 한 번뿐이라 다음 실행에서 자연히 다시 묻는다. 부팅 직후 App.tsx에서 한 번만 확인하고, 온보딩 중에는 띄우지 않는다. 처음 켠 사람에게 모달 두 개가 겹치면 무엇부터 해야 할지 알 수 없다.

CI 쪽은 release.yml에 세 스텝을 더했다. macOS에서 .app.tar.gz는 app 번들 타깃이 함께 빌드될 때만 만들어진다. 그래서 --bundles dmg,app으로 빌드해 .app.tar.gz가 실제로 나오는지 확인하고 없으면 즉시 실패시키며, 앱이 최신 버전을 조회할 때 읽는 매니페스트인 latest.json을 만들어 DMG·tar.gz와 함께 릴리스에 올린다. signature 필드에서 한 번 걸렸다. .sig 파일의 경로가 아니라 내용이어야 한다. url도 아직 만들어지지 않은 릴리스를 가리키는 값이라 gh release create가 태그 v 뒤에 버전을 붙여 올릴 것을 미리 알고 결정론적으로 조립해야 한다.

{
  "version": "0.2.4",
  "notes": "…",
  "pub_date": "2026-08-24T…",
  "platforms": {
    "darwin-aarch64": {
      "url": "https://github.com/…/releases/download/v0.2.4/Resumate.app.tar.gz",
      "signature": "<.sig 파일 내용>"
    }
  }
}

앱은 부팅 직후 이 파일을 읽어 자기 버전과 비교한다.

updater가 없는 버전을 쓰는 유저는 이 기능이 들어간 빌드를 한 번은 손으로 설치해야 그 다음부터 자동이 되고, 인앱 업데이트 자체도 그 빌드가 실제로 배포된 뒤에야 처음 검증된다. 닭-달걀은 피할 수 없다는 걸 설계 문서에 그대로 남겼다.

설계와 구현 계획을 먼저 문서로 적어두고, 그 다음 작업이 그 계획을 그대로 따라갔다. 플러그인 등록, 엔드포인트·공개키 설정, 확인/설치 경계, 모달, 부팅 체크, CI 순서였다.

프롬프트에 규약이 하나 빠져 있어서 지원본 PDF가 통째로 납작해졌다

이력서 PDF 서식은 sidecar/resumeTemplate.ts가 마크다운 제목 단계(# 이름, ## 섹션, ### 항목, #### 소제목)를 그대로 서식 단계로 옮겨 만든다. 사이드카는 Tauri가 앱과 함께 번들해 실행하는 별도 바이너리 서버다. 그런데 Paws의 맞춤 이력서 프롬프트에만 이 서식 규약(RESUME_MARKDOWN_FORMAT)이 빠져 있었다. 제목이 하나도 없는 마크다운이 왔고, 렌더러는 제목 단계를 그대로 옮기는 방식이라 손댈 것이 없어 섹션 구분도 항목 제목도 없이 문단과 목록만 남은 납작한 PDF가 나왔다. 같은 렌더러를 타는 첨삭본과는 전혀 다른 모양이었다.

규약과 추출기를 resumeMarkdownFormat.ts로 옮겨 여러 프롬프트가 함께 쓰게 했다. 첨삭 고유 규칙(원본 섹션·순서 유지)은 이 공용 규약에 넣지 않는다. Paws의 맞춤 이력서는 순서 재배치가 목적이라 그 규칙을 받으면 기능이 망가진다. 그리고 프롬프트만으로는 제목 단계가 보장되지 않으므로, 응답에 ## 섹션이 하나도 없으면 저장하지 않고 재시도 경로로 되돌리는 방어도 추가했다.

자기소개서 기능을 새로 만들고, 산출물을 보며 다섯 번 고쳤다

맞춤 이력서에는 지원 동기도 기여할 점도 없었다. 이력서 프롬프트가 양쪽 모두 "원본에 없는 섹션을 만들지 말 것"을 규칙으로 걸고 있기 때문인데, 그건 의도된 제약이다. 이력서는 검증 가능한 사실만 담아야 한다. 그래서 지원 동기·기여할 점을 이력서에 붙이지 않고 별도 문서(자기소개서)로 만들기로 했다. 설계 배경을 먼저 문서로 적었다.

공고 원문에서 문항과 자수 제한을 뽑고, 문항이 없으면 기본 3문항(지원 동기 / 입사 후 기여 / 직무 역량)으로 시작한다. 경력직 공고는 문항 없이 지원서만 받는 경우가 흔하다. 자수 제한은 charLimit을 nullable로 두고 확인된 문항에만 표시한다. 기본값을 넣으면 확인되지 않은 제한을 사실처럼 보여주고 사용자가 그 숫자에 맞춰 글을 자르게 된다.

문항마다 서로 다른 각도의 후보를 2~3본 쓰고 사용자가 하나를 고르는 방식을 택했다. 완성본 하나만 주면 그게 본인 이야기가 아닐 때 손볼 기준이 없고, 각도만 제안하면 바로 내보낼 문서가 없다. 본인만 아는 사정은 지어내지 않고 "확인 필요"로 남긴다.

여기까지가 첫 구현이고, 이후 실제 산출물을 보면서 네 번 더 고쳤다.

문항 번호가 안 보였다

PDF가 메타 회색(#94a3b8)을 그대로 쓰고 있어서 12pt 굵은 문항 제목 옆에서는 거의 안 보였다. 앱 패널의 /25 표시도 어두운 카드에서 같은 상태였다.

어투가 몇 문장 만에 원래대로 돌아갔다

"세미포멀한 서술체"라고만 적어두면 모델이 학습 데이터의 자기소개서 기본값(상투어·번역체)으로 곧 돌아간다. 금지 낱말과 바꿔 쓸 말을 짝지어 표로 넣었다. 짝을 안 주면 금지어만 피하고 같은 뜻의 다른 상투어로 옮겨간다. 이 표가 실제로 프롬프트에 실리는지는 테스트가 본다.

이력서 문장을 그대로 옮겨왔다

"사실을 지어내지 말 것"이라는 지시를 모델이 문장 복사로 안전하게 처리하고 있었다. 실제 산출물의 "라이브 iframe 형태의 IA 맵으로"라는 구절이 원본 이력서 문구 그대로였다. 이력서는 명사를 쌓아 짧게 적는 글이라 그 문장을 자소서로 그대로 옮기면 읽히지 않는다. 사실만 가져오고 문장은 다시 쓰게 했다. 같은 산출물에서 나온 다른 문제들도 함께 막았다. 관형절로 주어 만들기("직접 만든 pubuilder는"), 명사 셋 이상 잇기, 보조용언 띄어쓰기 오류였다. 지원 동기 문항에 기능 설명 네 문장만 온 것은 근거를 강조한 지시 탓이었다는 것도 확인해, 문항이 실제로 묻는 것이 문단 안에 있어야 한다고 못 박았다.

막았더니 이번엔 일반론으로 도망갔다

기능 나열을 막자 모델이 반대쪽 극단으로 갔다. "여행 서비스의 품질은 ~에서만 결정되지 않습니다"로 시작해 본인 경험이 하나도 없는 답이 나왔다. 한쪽만 막으면 반대쪽으로 도망가므로 이번에는 양쪽을 함께 못 박았다. 어느 문항이든 지원자가 직접 한 일이 최소 하나 들어가야 하고, 첫 문장은 일반론이 아니라 겪은 일이나 확인되는 사실로 시작해야 한다. 같은 산출물에서 나온 기사체 표현("눈길이 갔습니다")과, "다룰 수 있어야"와 "다룬다는"처럼 붙어 있는 두 문장의 동사 반복도 함께 막았다.

한 번 만들고 끝낸 게 아니라 매번 실제로 나온 문장을 읽고서 다음 규칙을 정했다.

규칙을 적어둔 것과 규칙이 지켜지는 것은 달랐다

사용량 막대도, 창 번쩍임도, 자기소개서 어투도 처음 만든 버전은 전부 "말로 규칙을 적어두면 지켜지겠지"에서 출발했다가, 실제로 나온 결과를 보고서야 진짜 문제가 드러났다. 색조 차이로는 안 보이고, setFocus 하나가 대기 로직을 건너뛰고, "지어내지 말라"는 말은 복사를 허용하는 구멍이 된다. 셋 다 처음 설계에서 예상하지 못한 실패 모드였고, 고치는 방법도 같았다. 실제 산출물을 보고, 그 실패가 왜 일어났는지 한 줄로 설명할 수 있을 때까지 파고든 다음에야 규칙을 좁혔다.

특히 자소서 프롬프트는 한쪽을 막으면 모델이 반대쪽 극단으로 도망가는 걸 두 번 봤다. 문장 복사를 막으니 일반론이 나왔다. 금지 규칙만으로는 안 되고, 빈자리에 무엇이 와야 하는지를 같이 적어야 한다.