본문으로 건너뛰기

resumate 10. 약관 위반 확인과 호스팅 전환

·36 min read·10 / 17

사이드카(앱과 함께 실행되면서 LLM 호출을 대신 맡는 별도 로컬 프로세스)가 유저 머신의 Claude CLI를 그대로 실행해 쓰고 있었다. 약관을 다시 읽으니 그 구조가 통째로 위반이었다.

약관을 다시 읽으니 구조 자체가 위반이었다

지금까지 사이드카는 유저 머신에 로그인된 Claude/Codex CLI를 spawn(자식 프로세스로 실행)해서 썼다. 유저가 자기 구독으로 자기 요청을 쓰는 모양이라 문제없다고 봤다. 오늘 Claude Code 문서의 법률·컴플라이언스 페이지를 다시 확인하니 제3자 개발자가 자기 앱에 Claude.ai 로그인을 제공하거나 Free/Pro/Max 자격증명으로 유저 요청을 대신 보내는 것을 허용하지 않는다고 적혀 있었다. Agent SDK로 제품을 만드는 개발자는 API 키 인증을 쓰라고 콕 집어 적혀 있고, 자격증명이나 세션 토큰을 수집·저장·중개하는 것도 따로 금지된다. 로그인은 Anthropic 자체 플로우에서 끝나야 한다.

조항에 유료·무료 구분은 없다. 유저가 수정되지 않은 Claude Code 바이너리에 자기 구독으로 직접 로그인하는 경로는 막지 않는다는 예외가 붙어 있지만, 그 예외는 개발자가 사용량을 대납·재판매·중개하지 않을 것을 함께 요구한다. 우리 앱은 로그인 코드를 앱 안에서 받아 사이드카로 넘기고 있었으니 그 조건을 못 지킨다.

이날 새로 쓴 LLM 인증·과금 정책 설계문서에 이 판단과 구현 계획을 적었다. 결론은 호스팅 전환이다. 서버가 LLM 호출·계정·크레딧·결제를 맡고, 파일·PDF·보관함은 그대로 유저 로컬에 둔다. 프록시는 ANTHROPIC_BASE_URL을 서버로 바꾸는 방식이라 에이전트 루프와 도구 실행은 로컬에 남고 Nova의 파일 접근 같은 기능도 그대로 산다.

원가를 추정이 아니라 실측으로 바꿨다

전환 결정 전에 앱의 네 AI 캐릭터를 전부 실제로 돌려 요청 하나당 원가를 쟀다.

캐릭터캐시 히트캐시 미스
Nyx80원히트 대비 4.8배
Paws195원1,311원
Echo80원미측정
Nova124원미측정

이 시점의 비즈니스 모델 문서(docs/business-model.md)도 함께 갱신했다. 타깃은 일반 취업준비생, 데스크톱 우선이라는 전제를 재확인하고 "누가 API 비용을 내는가, 무엇에 돈을 받는가, 손해 보지 않는 구조"를 정리했다.

유저에게 모델을 고르게 하는 것부터 뺐다

호스팅으로 가면 API 비용을 개발자가 낸다. 그러면 원가에 영향을 주는 결정을 유저에게 맡길 수 없다. 유저가 opus를 고르면 원가가 3배가 되고, effort(추론에 얼마나 힘을 쓸지 지정하는 값)를 올리면 출력이 그만큼 늘어난다.

그래서 AgentModelSettings UI, agentModels 저장소, GET /models 엔드포인트, Codex model/list 카탈로그 조회, /chat body의 model·effort, AgentLoopOptions의 model·effort를 전부 걷어냈다. models.ts에는 정책(모델·effort·폴백 체인)만 남았다.

이날 아침에 로그인 화면과 모델 설정 UI를 완성해놓고 오후에 다시 뜯어냈다. 그 사이에 약관 문제가 확정되면서 "유저가 모델을 고른다"는 전제 자체가 무너졌다.

effort는 캐릭터별 정책으로 고정

models.ts가 provider별 모델 목록·캐릭터 기본값·폴백 체인을 한 곳에서 소유하게 만들었다. Claude는 별칭 세 개(opus/sonnet/haiku)를 정적으로 두고, --fallback-model은 고른 모델 뒤에 오는 항목들로 자동 생성한다. haiku를 고르면 강등할 곳이 없어 플래그 자체를 뺀다. 빈 값을 넘기면 CLI가 받지 않는 걸 확인했다.

effort 기본값도 정책으로 박았다. 출력이 원가의 75%를 차지하고, 그중 thinking 비율이 캐릭터마다 43%에서 6%까지 갈린다. effort가 마진을 결정하는 핵심 레버라서 캐릭터별로 따로 잡아 원가를 통제한다. Paws만 상향했다. 유료 전환 지점이라 깎는 대신 품질에 투자한다는 판단이다. 값은 테스트로 고정해뒀고, 바꾸려면 설계문서 실측을 다시 하고 문서도 함께 고쳐야 한다.

같은 질문을 effort만 바꿔 돌려 토큰 수를 비교했다.

effort low  → thinking 0    / output 1,195
effort high → thinking 177  / output 1,771   (출력 33% 차이)

단, 캐시 미스 상황에서는 총비용 차이가 작다. 원가의 75%가 캐시 쓰기라서다. 캐시가 쌓인 운영 환경에서야 effort 절감 효과가 실제로 나타난다.

Codex 쪽(turn/start)에도 effort만 정책으로 실었다. 모델은 지정하지 않고 계정 기본 모델을 쓴다. app-server는 JSON-RPC(요청·응답을 JSON 한 덩어리로 주고받는 프로토콜)로 말하므로 잘못된 effort 값이면 오류 응답이 돌아온다. echo(low)·paws(high) 둘 다 실제 왕복으로 확인했다.

Paws만 opus로 남긴 이유를 실측으로 다시 확인

"Paws는 opus, 나머지는 sonnet"이라는 배정이 예전부터 있었는데, 실측 없이 내려진 판단이었다. 같은 입력으로 opus와 sonnet을 나란히 돌려 출력을 저장하는 비교 스크립트를 만들어 재검증했다.

결과는 opus 유지. 결정적 차이는 결과물의 유무였다. sonnet은 전략 제안까지만 하고 바로 쓸 수 있는 이력서 초안을 만들지 않는다. "MAU 3,000을 빼야 한다" 같은 구체적 지시나 면접 서사 제안도 opus에만 나왔다. 유저가 돈을 내는 이유가 "고쳐진 결과물"이므로 여기서 원가를 깎으면 상품 자체가 죽는다.

thinking 비율이 6%로 낮다는 사실도 "낭비"의 증거는 아니었다. 그 비율은 얼마나 고민했는지를 보여주지, 얼마나 잘했는지를 보여주지 않는다.

요청 상한을 CLI에 직접 강제했다

유저 요금을 개발자가 대신 내는 구조로 가면, 상한 없는 에이전트 루프는 곧 무한 청구다. 요청 하나가 얼마까지 쓸 수 있는지에 반드시 천장이 있어야 한다.

--max-budget-usd를 주 방어선으로 걸었다. 턴 수는 원가의 대리지표일 뿐이지만 예산은 원가 그 자체라서다. --max-turns는 무한 루프를 막는 보조로 함께 걸었다. 값은 실측 원가 위에 여유를 뒀다. 정상 요청을 자르면 그건 상한이 아니라 버그다. Nova는 파일을 읽으며 루프를 도니 턴이 가장 많고, Paws는 opus라 예산이 가장 크다.

두 플래그 모두 실제 CLI로 강제 여부를 확인했다. 없는 플래그를 넣어봤을 때 CLI가 거부하는 것도 같이 봐서, 오타가 조용히 무시되지는 않는다는 걸 확인했다. Codex CLI에는 대응 플래그가 없어, 그 경로는 프록시가 직접 강제해야 하는 채로 남았다.

상한에 걸린 걸 실패로 보여주지 않는다

CLI는 상한 도달 시 subtype만 주고 사람이 읽을 문구는 주지 않는다. 그대로 두면 유저에게 "Claude CLI가 요청을 처리하지 못했습니다"가 뜨는데, 상한은 실패가 아니라 정책이 개입한 정상 동작이다. 이유를 밝히도록 고쳤다. subtype 이름(error_max_budget_usd)은 추측하지 않고 실제 CLI 출력에서 확인했다.

상한으로 멈춘 세션은 폐기하지 않는다. 그 세션은 멀쩡한데 버리면 다음 요청이 전문 캐시 미스로 다시 올라가 실측 4.8배 비싸진다. 비용 상한에 막힌 직후에 원가를 올리는 건 정확히 거꾸로 가는 것이다. 인증 실패처럼 세션 ID 자체가 의심스러운 경우의 폐기는 그대로 남겨뒀다.

안내 문구도 한 번 더 고쳤다. 턴·예산 상한이 세션 누적인지 호출 단위인지 확인 없이 문구를 썼는데, 실측해보니 호출 단위였다. 3턴을 이미 쓴 세션을 --max-turns 1로 resume해도 정상 응답했다. Echo(턴 10)는 대화가 길어져도 상한에 안 걸리고, 재시도는 멈춘 지점부터 이어지므로 "더 작게 나눠서"가 아니라 "이어서"가 맞는 안내다.

Codex를 걷어냈다

Claude 쪽 약관 리스크를 정리하면서 Codex 지원도 함께 정리했다.

  • 포크·제3자 앱이 "Sign in with ChatGPT"로 구독을 쓰게 해도 되는지가 여전히 불명확하다. openai/codex 저장소 discussions#8338에서 메인테이너가 Apache 라이선스라 포크·수정은 자유롭다고 답했지만, 구독 자격증명을 대행해도 되는지는 답하지 않았다. 그 뒤로 개발자 넷이 같은 걸 더 물었어도 그대로다. Claude 쪽 리스크를 없애면서 미확인 리스크를 남길 이유가 없다.
  • Codex CLI에는 --max-turns/--max-budget-usd에 해당하는 플래그가 없어, 방금 만든 요청 상한을 Codex만 프록시에서 따로 구현해야 한다.
  • 호스팅 구조에서는 프로바이더가 둘이면 청구 연동·원가 모델·가격표가 둘이 된다. 1인 개발에서 감당할 이유가 없다.

codex.ts/codexAuth.ts/codexBin.ts/provider.ts를 지우고, AiProvider가 사이드카·프런트로 퍼져 있던 흔적을 전부 걷어냈다. 제공자 전환 UI도 함께 없앴다. 배포본에서 유저는 자기 AI 계정이 아니라 resumate 계정으로 로그인하게 되므로 그 선택지 자체가 무의미해진다.

clearSession에서 provider 인자를 뺄 때는 위치 인자가 밀리는 문제를 조심해야 했다. provider와 lane이 둘 다 string이라 타입체크가 이 실수를 못 잡는 자리다. 호출자를 먼저 다 센 뒤에 옮겼다.

이 작업을 하다 pnpm lint가 이 브랜치에서 이미 깨져 있던 것도 발견했다. authRequired.test.tsx가 렌더 중에 globalThis에 직접 대입하고 있었다. publish 워크플로가 lint를 돌리므로 이게 릴리스를 막고 있었다. 다른 테스트가 쓰는 useEffect 패턴으로 맞췄다. CLAUDE.md의 Codex·모델 선택 UI·GET /models 관련 설명도 현실과 어긋나 있어서 실행 모드와 요청 상한 절을 새로 써넣었다.

배포 빌드에서 개발자 구독 경로를 물리적으로 지웠다

배포본이 유저 구독 자격증명으로 요청을 보내는 것은 명시적으로 금지된다. 그렇다고 CLI 경로를 통째로 지우면 정작 개발할 때 내 구독을 못 쓴다. 그래서 코드는 남기되 배포 산출물에는 존재하지 않게 갈랐다.

런타임 env가 아니라 빌드타임 --define으로 가른다. env로 감추면 배포된 바이너리 안에 기능이 그대로 남아 유저가 몰래 켤 수 있고, 그건 "우리가 그 경로를 제공했다"로 읽힐 여지가 생긴다.

게이트 모양을 정하는 데 실측이 필요했다. bun의 --define은 식별자를 빌드타임에 값으로 치환하고, --minify와 함께 쓰면 도달 불가 분기를 지운다(DCE, dead code elimination). 그런데 define 식별자가 분기 조건에 직접 들어가야 지워졌다. 모듈 상수나 함수를 한 겹 거치자 --minify를 붙여도 코드가 산출물에 남는 걸 실측으로 봤다. typeof 가드를 함께 쓰는 모양만 세 조건을 동시에 만족했다. false면 제거되고, true면 유지되고, --define 없이 실행해도 크래시가 나지 않는다. 이 모양은 소스 검사 테스트로 고정했다.

기본값은 거부로 뒀다. --define을 깜빡한 빌드가 "허용"으로 떨어지면 조용히 약관을 위반한 채 배포되지만, "거부"로 떨어지면 앱이 그 자리에서 바로 티를 내 잡힌다.

hostedEnv는 ANTHROPIC_API_KEY도 지운다. 이 변수가 설정돼 있으면 로그인 상태여도 구독 대신 그 키가 쓰이고, 비대화형 모드(-p)에서는 있으면 항상 쓰인다. 유저 머신에 그 변수가 남아 있으면 유저 계정으로 청구되고 크레딧은 차감되지 않는 사고가 날 수 있다.

배포로 가는 세 경로(build:all, 프리뷰 DMG, publish 워크플로)가 셋 다 개발 빌드를 부르고 있던 것도 이참에 전부 배포 빌드로 바꾸고, 새 경로가 생겨도 걸리도록 검사를 추가했다.

"배선했다"고 믿었는데 실제로는 안 됐다

첫 시도(runMode.ts + 테스트)는 통과했지만 사실이 아니었다. claude.ts는 여전히 claudeEnv()로 그대로 spawn하고 있었다. 아무도 buildRunEnv를 호출하지 않았다. 배포 빌드도 유저 구독을 그대로 쓰는 상태였다.

DCE 검증을 probe 파일로만 한 게 빈틈이었다. probe는 runMode를 직접 import했지만 실제 사이드카는 하지 않았다. 그래서 이번엔 실물 배포 바이너리를 띄워 /chat을 직접 쏴서 확인했다.

빌드토큰결과
배포 빌드없음error, 요청이 나가지 않는다
개발 빌드없음내 구독으로 정상 응답
개발 빌드있음프록시로 향한다, 내 구독으로 답하지 않는다

배선 여부는 소스 검사 테스트로도 고정했다. buildRunEnv를 실제로 부르는지, spawn에 claudeEnv()를 그대로 넘기지 않는지를 본다.

토큰은 /chat 요청마다 앱이 실어 보내는 구조로 정했다. 사이드카가 토큰 수명주기를 들고 있으면 로그아웃·계정 전환과 어긋나기 때문이다. 계정과 크레딧은 앱이 서버와 직접 주고받고, 사이드카는 LLM 요청에 토큰을 붙여 통과시키기만 한다.

개발 빌드는 토큰이 없을 때 개발자 구독으로 떨어지게 뒀다. 서버도 계정도 아직 없는 시점에 hosted가 기본이면 개발 자체가 멈춰버리기 때문이다. 로그인해서 토큰이 생기면 조건이 사라져 자동으로 프록시를 타므로, 평소에는 배포본과 같은 경로를 보게 된다.

검증을 두 겹으로 둬야 했던 이유

배포 빌드가 산출물을 직접 띄워 스스로 검증하는 스텝도 추가했다(build:app). 처음엔 /health의 localCli 값만 봤는데, 게이트 배선을 일부러 끊는 변형을 넣어보니 그 검증과 소스 검사 테스트가 모두 통과해버렸다. /health는 게이트 함수를 직접 부르고 /chat은 게이트 배선을 타는 별개 경로라서, 플래그는 false라고 답하면서 요청은 실제로 구독으로 나가는 상태가 만들어질 수 있었다.

검증잡아내는 것
/healthlocalCli:false--define 누락
토큰 없는 /chat이 실제로 거부됨게이트 배선 끊김

두 검증을 모두 통과해야 빌드가 성공한다. 검증 프로세스는 PID(프로세스 식별 번호)로만 정리했다. 이름으로 죽이면 실행 중인 앱의 사이드카까지 함께 잡히는데, 실제로 그렇게 앱을 죽인 일이 이날 있었다.

build:app:mine도 새로 추가했다. 배포용 번들만 있어서 지금까지 내 구독으로 쓰는 앱을 설치해서 쓸 방법이 없었다. pnpm start로 소스에서 띄우는 게 전부였다. 요구가 "배포본은 유료·크레딧, 나는 내 구독"으로 확정됐으므로 이 갈림은 임시가 아니라 영구 구조다.

비즈니스 모델 문서를 실측으로 다시 정정했다

1절의 "ToS 확인 완료, 문제 없음"이 틀린 문장이었다. Claude Code 문서에 제3자 개발자가 Free/Pro/Max 자격증명으로 유저 요청을 대신 보내는 것을 허용하지 않는다고 직접 적혀 있었다. 회색지대가 아니라 명시적 금지이고, 조항에 유료·무료 구분은 없다.

이 한 줄이 무너지면서 함께 무너지는 전제를 표로 정리했다. 지금 구조 그대로 1차 출시하는 계획, 유저가 자기 CLI 구독을 가져와 쓰는 무료 티어, "로컬 우선"이라는 마케팅 자산과 기관 세일즈 포인트가 전부 성립하지 않게 됐다. 문서 안에서 이 전제가 흩어져 있던 7절까지의 여러 절도 같이 표시했다. 정정을 한 곳에만 쓰면 나머지를 읽은 사람이 계속 오해한다.

원가표도 추정에서 실측으로 완전히 교체했다. Paws가 가장 비싸다(히트 195원, 미스 1,311원). 5.2절이 Paws를 1,000원에 팔기로 이미 정해뒀는데, 캐시가 식으면 그 가격이 적자가 된다는 것도 이번에 드러나 가격표에 "미스" 열을 새로 추가했다. opus를 유지하는 근거도 "원가가 낮아서"에서 "sonnet은 결과물을 만들지 않아서"로 바꿨다. 원래 근거는 사실이 아니었다.

문서에 한 줄을 더 남겼다. app-v* 태그는 호스팅 전환 전까지 밀지 않는다. 지금 배포하면 그 자체가 위반이다.

돈 계산은 인프라 없이도 먼저 만들 수 있었다

돈이 틀어지지 않는 크레딧 원장을 먼저 만들었다. Supabase·Postgres·API 키가 전부 사용자 계정을 요구해 지금 당장 붙일 수 없지만, 가장 어렵고 가장 위험한 부분인 "돈 계산" 자체는 인프라 없이도 만들 수 있다는 판단이었다. PGlite(브라우저·Node에서 그대로 돌아가는 WASM 빌드 Postgres)로 실제 SQL 시맨틱을 검증했다.

  • credit_entries가 진실의 출처이고 credits.balance는 조회용 스냅샷이다. balance == sum(delta) 불변식을 테스트로 고정했다.
  • 차감은 조건부 UPDATE 한 문장으로 처리한다. select 후 update로 나누면 두 요청 사이에 경쟁 상태가 생겨 잔액이 마이너스로 갈 수 있다. 조건을 WHERE에 두면 Postgres 행 잠금(같은 행을 건드리는 트랜잭션을 줄 세우는 잠금)이 직렬화한다.
  • 결제 멱등성(같은 요청이 여러 번 와도 결과가 한 번과 같음)은 provider_payment_id의 기본키 충돌 하나로 처리한다. 결제 대행사는 같은 웹훅을 중복으로 쏘기 때문이다.
  • settle은 실사용량이 선차감한 금액보다 크면 그 금액까지만 청구한다. 초과분은 개발자가 떠안는다. 유저에게 예상 밖의 청구를 하지 않으려고 상한을 둔 것이다.
  • 금액은 전부 정수로 다룬다. float는 언젠가 잔액을 어긋나게 한다.

PGlite는 단일 연결이라 진짜 동시성까지는 재현할 수 없다. 그래서 선차감 구현이 안전한 쿼리 모양을 유지하는지 소스를 검사하는 테스트를 따로 뒀다. select 후 update로 일부러 바꿔보면 기능 테스트 17개는 그대로 통과하고 모양 검사 2개만 실패하는 것도 확인했다.

pricing.ts는 설계문서에 적어둔 토큰 집계 버그가 정산에 흘러드는 것을 막는다. input_tokens만 읽으면 과금이 실제의 1/26,000이 된다.

server/는 루트 pnpm workspace에 넣지 않았다. 자체 vitest.config.ts도 필수였다. 없으면 루트 설정을 끌어와 프론트엔드용 setup을 찾다가 실패한다.

크레딧 플로우 라우트와 E2E

외부 의존을 전부 인터페이스 뒤에 두면 Supabase·Postgres·API 키 없이도 플로우 전체를 만들고 검증할 수 있다.

외부 의존지금 (스텁)나중 (실제 구현)
DbPGlitePostgres
Auth스텁 세션Supabase Auth
LLM저장된 응답Anthropic 프록시
결제서명된 가짜 웹훅포트원(국내 결제 연동 서비스)

라우트는 (Request, Deps) => Response 모양만 지킨다. 프레임워크를 지금 고르지 않아도 되고, 나중에 Next.js가 얇게 감싸면 되는 구조다.

바깥에서 안으로(outside-in, E2E를 먼저 쓰고 안쪽 단위를 나중에 채우는 순서) TDD로 만들었다. E2E를 먼저 써서 RED를 확인하고, auth → LLM → chat → webhook 순으로 안쪽을 단위 테스트로 채운 뒤 E2E가 저절로 초록이 됐다.

/chat은 순서가 전부다. 선차감이 호출보다 먼저여야 잔액 없는 요청이 나가지 않는다.

flowchart TD
    A["/chat 요청"] --> B[인증]
    B --> C[요청 검증]
    C --> D["선차감(홀드)"]
    D --> E[LLM 호출]
    E --> F{성공?}
    F -->|예| G["정산(홀드 금액까지만)"]
    F -->|아니오| H[반환]
    B -->|실패| X[거부]
    C -->|실패| X
    D -->|잔액 부족| X

돈이 새는 실수를 실제로 코드에 넣어 테스트가 잡는지 검증했다.

넣은 실수실패한 테스트 수
선차감을 호출 뒤로 옮김(후불 과금)4
실패 시 반환 로직 제거2
웹훅 서명 검증 제거2

테스트는 총 62개. 도중에 내 테스트 두 개가 비현실적이었던 것도 발견했다. HTTP 헤더는 ByteString이라 한글 토큰·서명이 Request 생성 단계에서 막힌다. 이건 코드가 아니라 테스트를 고쳤다.

로그인은 브라우저로 보내고, 개발자 계기판은 배포본에서 감췄다

로그인(LoginFlowModal)은 터미널 대신 브라우저로 유도한다. Codex를 걷어내기 전에 만든 화면이라 provider마다 유저가 할 일이 갈렸다. Codex는 승인만 하면 끝이고 Claude는 코드를 붙여넣어야 한다. 진행 중 창을 닫으면 CLI 프로세스를 정리한다.

Escape 처리(useEscapeLayer)는 App.tsx의 전역 Escape 핸들러가 작업 창을 먼저 닫아버리는 문제를 훅으로 풀었다. 오버레이가 자기를 data-escape-layer로 표시하고 최상위일 때만 이벤트를 처리한 뒤 전파를 끊어야, Escape를 눌렀을 때 모달이 아니라 창이 닫히는 사고가 안 난다. 같은 로직이 세 번째로 필요해진 시점이라 훅으로 뽑아 AuthBadge의 두 모달(로그인 안내·환경 점검)에도 적용했다. 이 둘은 그 전까지 이 규약을 안 지키고 있었다.

폴더 변경 감지(useChat)도 함께 고쳤다. 시스템 프롬프트에 그 폴더의 파일 목록과 "목록 밖은 읽지 마라"는 규칙이 박히는데, 이건 새 세션에서만 만들어진다. 세션을 유지한 채 폴더만 바꾸면 이전 폴더의 목록으로 지금 폴더를 분석하는 문제가 있었다. rootDir을 세션 폐기 조건에 넣었다.

이후 "Claude 연결됨" 배지와 환경 점검 패널을 배포본에서 감췄다. 배포본 유저는 자기 Claude 계정으로 로그인하지 않으므로 "Claude 연결됨"은 그 자체로 잘못된 안내이고, 절대 경로가 찍힌 도구 목록도 취준생에게는 무의미하다. 판정은 프런트가 아니라 사이드카에 물어보는 방식(GET /health의 localCli)으로 뒀다. 게이트가 두 곳에 있으면 언젠가 어긋나는데, 실제로 어느 경로로 도는지는 사이드카만 정확히 안다.

dev      → {"ok":true,"localCli":true}
RELEASE  → {"ok":true,"localCli":false}

이 작업 중 더 큰 문제도 발견했다. send()의 사전 점검이 로컬 claude CLI의 로그인 상태를 보고 있었는데, 배포본 유저에게는 그 CLI 자체가 없으므로 정상 유저의 모든 요청이 "CLI를 설치해주세요"에서 막히는 상태였다. 배포본에서는 이 사전 점검을 건너뛰고 사이드카 게이트가 판단하게 바꿨다. 거기서 나오는 "로그인이 필요합니다"가 이미 올바른 안내이기도 하고, 배포본을 통째로 막는 쪽이 더 나쁜 실패다.

트레이 아이콘에 메뉴를 달지 않았다

좌클릭하면 독 창을 토글하는 메뉴바 트레이 아이콘(Tauri가 macOS 상단 메뉴바에 띄우는 작은 아이콘)을 붙였다. 가시성 상태는 Rust가 따로 들고 있지 않고 매번 창에 직접 물어본다. 단일 출처를 창 자신으로 두면 프런트와 어긋날 일이 없다. TrayIconEvent::Click은 좌·우 버튼 모두에서 발생하므로 Left + Up으로 좁혀 걸었다. 메뉴는 붙이지 않았다. 라이브러리 기본값이 좌클릭에서 메뉴를 열게 돼 있어서 메뉴를 달면 좌클릭 토글과 부딪힌다. 옵션으로 끌 수도 있지만 메뉴가 필요하지 않아 아예 달지 않았다.

아이콘은 앱 아이콘(워드마크)을 그대로 쓰지 않고, 판을 걷어내 글자만 알파 채널로 남긴 전용 에셋을 새로 만들었다. 높이는 36px로 고정했다. tray-icon이 렌더 높이를 18pt로 강제하므로 36px(=18pt @2x)이라야 픽셀이 1:1로 맞는다.

app-v* 태그를 밀면 DMG(macOS 설치용 디스크 이미지)를 빌드해 릴리스하는 워크플로 뼈대도 만들었다. 배포 전 로컬 점검용 release:check와 미리보기 DMG 빌드 스크립트도 함께 뒀다. tauri.conf.json은 productName을 대문자화하고 CSP를 명시했으며 macOS 최소 버전을 13.0으로 못 박았다. 다른 작업 세션에서 진행 중이던 이력서 문서 모델 v2(빌더·자료 화면·버전 삭제)도 파일 단위로 병합해 타입체크와 테스트가 통과하는 상태로 정리했다.

남은 건 인프라와 안내다. Supabase·Postgres·포트원은 전부 스텁과 PGlite로 대신하고 있어 실제 연동이 필요하고, publish.yml에는 Apple 인증서가 없어 정식 서명 경로가 비어 있다. 환경 점검 패널을 감추면서 배포본에서 html2pdf 같은 필수 도구가 없을 때 알릴 방법이 사라졌으니, 기능이 실패하는 지점에서 안내해야 한다. 크레딧을 실제로 유저에게 보여주는 UI도 아직 없다.

가장 위험했던 건 "게이트를 만들었다"와 "게이트가 실제로 배선됐다" 사이의 간극이다. 테스트를 통과시켜놓고도 실물 바이너리로 확인하지 않았으면 배포본이 계속 유저 구독으로 요청을 보내는 상태로 나갔을 것이다. probe 파일이 실제 호출 경로를 대표하지 못하면 그 테스트는 안전판이 아니라 안전판이 있다는 착각만 만든다.

돈이 걸린 크레딧 원장은 반대로 처음부터 실패를 넣어보고 테스트가 잡는지 확인하는 방식이 잘 맞았다. "선차감을 뒤로 옮기면 테스트 4개가 죽는다"는 확인이 "선차감이 앞에 있다"는 주장보다 훨씬 믿을 만하다.