본문으로 건너뛰기

resumate 11. 정책 롤백과 연동 흐름 재설계

·69 min read·11 / 17

정책이 하루 만에 뒤집혔다. 전날 만든 것들을 되돌리고, 빈자리에 "연동"이라는 개념을 처음부터 다시 쌓았다.

하루 전에 만든 것을 하루 만에 되돌리다

정책 결정이 바뀌었다. 유저가 자기 머신의 Claude/Codex 로그인을 그대로 쓰고, 수익은 앱 자체를 유료로 파는 것으로 낸다. Orca 등 같은 방식으로 배포된 앱이 있고, 그 리스크는 검토한 뒤 받아들이기로 했다.

그래서 전날 만든 것들이 전부 반대 방향이 됐다. 배포 빌드는 토큰 없이 요청을 거부하고, AuthBadge는 숨겨지고, useChat은 CLI 로그인 점검을 건너뛴다 — 셋 다 새 정책에서는 정확히 거꾸로다. 되돌린 것은 빌드 게이트, 게이트 배선, 개발자 계기판 숨김, 배포 빌드 자체검증, Codex 제거 다섯 가지다.

버리는 게 아니라 hosted-llm-2026-08-18 태그에 남겨둔다. BYOK나 호스팅 티어를 만들 때 거기서 꺼낸다 — 원가 실측과 크레딧 원장은 그대로 유효하다. Codex도 함께 되살렸다. 되돌리는 과정에서 lint 수정(렌더 중 globalThis 대입) 하나가 같이 딸려 사라졌는데, 그건 Codex와 무관한 수정이라 따로 다시 넣었다.

타입체크와 lint가 통과하고 프런트 148 / 사이드카 123 테스트가 전부 지나갔다. 사이드카(앱과 함께 배포되는 별도 로컬 프로세스로, 프런트는 HTTP로 말을 건다)를 빌드해 Claude·Codex 양쪽 인증을 인식하는지, /chat 응답이 정상인지도 확인했다.

로컬 CLI 계정 방식으로 돌아온 순간 "연동"이라는 개념 전체를 다시 설계해야 했다.

연동을 버튼 하나로 끝낸다

CLI를 직접 설치한다

유저가 "연동하기"를 누르면 Claude/Codex CLI를 직접 깐다. 타깃이 일반 취업준비생이라 "터미널 열고 npm 설치하세요"는 그 자리에서 이탈이다.

사전 요구가 없는 경로만 쓴다. 두 provider 모두 공식 npm 설치 경로가 있지만 node를 요구해서 쓰지 않는다.

provider설치 경로형태
claudehttps://claude.ai/install.sh네이티브 바이너리, 요구사항 없음
codexGitHub 릴리스 tar.gz (82MB)바이너리 하나, 요구사항 없음

codex는 dmg도 있지만 유저가 창을 끌어다 놓아야 해서 버튼 하나로 안 끝난다. /latest/download/가 최신 릴리스로 리다이렉트되는 것을 확인했으므로 버전을 고정하지 않는다.

종료 코드로 성공을 판정하지 않는다. 설치 스크립트가 0으로 끝나도 바이너리가 안 생길 수 있다(중간에 끊긴 네트워크, 쓰기 권한). 그때 "완료"라고 하면 유저는 되지도 않는 앱을 붙들고 원인을 못 찾는다. 파일 존재로 확인한다.

계획과 판정을 순수 함수로 분리했다 — 82MB를 실제로 받지 않고도 검증할 수 있어야 URL·경로가 조용히 바뀌는 것을 막는다. I/O는 주입 지점으로 뺐다.

설치 경로(~/.resumate/bin)를 탐색 후보 맨 앞에 넣는다. 이게 빠지면 설치는 성공했는데 계속 "CLI를 찾을 수 없습니다"가 뜨는, 원인을 짐작할 수 없는 실패가 된다. 경로 문자열은 install.ts가 소유하고 두 번 적지 않는다 — 테스트로 고정했다.

진행률은 SSE(서버가 한 연결로 이벤트를 계속 밀어주는 방식)로 흘린다. 82MB를 받는 동안 화면이 멈춘 것처럼 보이면 유저가 앱을 닫는다. 완주율의 문제다. codex를 지우고 POST /install로 다시 설치해보니 start·progress·installing·done 순으로 이벤트가 오고, 205MB 바이너리가 실제로 실행됐다(codex-cli 0.148.0).

설정 화면에서 설치부터 로그인까지 이어간다

상단 톱니 → 설정. Claude·Codex 각각의 연동 상태를 보여주고, 버튼 하나로 설치부터 로그인까지 이어간다.

설치가 끝나면 로그인이 남는다. 거기서 멈추고 유저가 버튼을 한 번 더 누르게 하면 "연동하기 눌렀는데 안 됐다"가 된다. 그래서 한 번의 클릭이 로그인까지 자동으로 이어진다.

flowchart LR
    A["연동하기 클릭"] --> B["CLI 설치"]
    B --> C["상태 재확인"]
    C --> D["로그인 자동 시작"]
    D --> E["연동됨"]

상태 판정과 다음 동작은 순수 함수로 뺐다(providerState.ts). 여기를 틀리면 이미 로그인된 유저에게 설치를 다시 시키거나, 설치도 안 된 유저에게 로그인 버튼을 보여주고 실패시킨다. 화면을 띄우지 않고 검증할 수 있어야 한다.

flowchart TD
    S["provider 상태"] --> U["unknown"]
    S --> N["미설치"]
    S --> L["설치됨 · 로그아웃"]
    S --> C["연동됨"]
    U --> U2["자리표시자만 그림"]
    N --> N2["설치 후 로그인까지 이어감"]
    L --> L2["로그인만 실행"]
    C --> C2["주 동작은 해제"]

상태를 아직 못 물어봤을 때를 unknown으로 따로 둔다. 이걸 "미설치"로 떨어뜨리면 멀쩡히 연동된 유저에게 설치 버튼이 잠깐 번쩍인다.

뷰 전환도 이때 goTo() 한 곳으로 모았다. 불리언을 핸들러마다 따로 끄는 구조라 뷰가 하나 늘 때마다 기존 핸들러를 전부 고쳐야 했고, 빠뜨리면 새 뷰가 다른 뷰 위에 겹쳐 뜬다.

설정 화면이 자리 잡기까지 다섯 번을 고치다

독립 화면이 아니었고, "확인 중"에 영원히 멈췄다

두 가지가 겹쳐 있었다.

설정이 작업 화면 옆에 끼어 떴다. 캐릭터 뷰의 표시 조건이 !showHome && !showMaterials && !showBuilder로 플래그를 일일이 나열하는데 showSettings가 빠졌다. 뷰 전환 세터를 goTo()로 모았지만 읽는 쪽도 나열하고 있었다 — 세터만 모아서는 부족했다. 불리언 네 개를 값 하나(view)로 바꿨다. 값이 하나면 두 화면이 동시에 뜨는 것 자체가 불가능하다.

상태가 영원히 "확인 중"에 머물렀다. StrictMode가 effect를 마운트 → 언마운트 → 마운트로 두 번 돌리는데, "언마운트됐나"를 정리에서만 false로 두어 두 번째 마운트에서 영구히 false가 됐다. setState가 전부 무시되고 유저는 연동이 됐는지조차 알 수 없다. 시작할 때 다시 켠다. 상태 조회 실패를 조용히 삼키고 있던 것도 같이 고쳐 실패 사유를 보여주게 했다.

StrictMode 회귀 테스트를 넣었다. mounted 재설정을 빼는 변형으로 4개 전부 실패하는 것을 확인했다.

주 동작이 연동/해제가 아니었다

"기본으로"가 주 버튼 자리를 차지하고 해제는 아예 없었다. 유저가 기대하는 동작 짝은 연동/해제인데, 부차적인 선택이 그 자리에 있었다.

  • 연동됨 상태의 주 동작은 해제다(claude auth logout / codex logout)
  • 기본 provider 선택은 둘 다 연동됐을 때만 보여준다. 하나뿐이면 어차피 그게 쓰이는데 버튼을 띄우면 유저가 "이걸 왜 눌러야 하지"를 고민하게 된다
  • 해제는 확인을 한 번 거친다

해제는 로그인만 끊고 CLI는 남긴다. 유저가 다른 곳에서 쓰고 있을 수 있고, 앱에서 "해제"를 눌렀다고 남의 도구를 삭제하는 건 넘는 짓이다. 화면에도 그렇게 적었다. 자격증명이 사라지므로 그 계정으로 이어가던 CLI 세션도 함께 버린다 — 안 그러면 로그아웃한 계정의 세션으로 --resume(직전 대화를 이어받는 CLI 옵션)이 걸린다.

해제 실패가 개발자 에러메시지로 떴다

해제가 "The string did not match the expected pattern"으로 실패했다. 실제 원인은 앱이 켜진 뒤 사이드카가 갱신되어 /auth/logout이 404였던 것인데, logoutProvider가 응답 상태를 보지 않고 res.json()을 불러서 404 본문이 JSON 파싱 오류로 둔갑했다. 이 파일의 다른 함수들은 전부 res.ok를 확인하는데 새로 쓴 것만 빠뜨렸다. 404는 대개 이 상황이므로 "앱을 다시 시작해주세요"로 안내한다.

"기본으로"를 없앴다. 무엇의 기본인지 말해주지 않는 추상어였고, 실제로 사용자가 "기본으로는 뭐야?"라고 물었다. 만든 사람 다음으로 맥락이 많은 사람에게 안 통하면 취준생에게는 확실히 안 통한다. 하는 일을 그대로 적는 쪽으로 문구를 바꿨다.

기본으로이걸로 대화하기
기본대화에 사용 중

브랜드 로고를 넣었다. 유저가 "내 Claude 계정"과 "내 ChatGPT 계정"을 한눈에 구분해야 한다. Claude 마크는 프로젝트 규칙으로 쓰는 Phosphor에 없어서 simple-icons의 공식 경로를 그대로 썼다. 직접 그리면 어설픈 근사가 되어 브랜드를 잘못 표시한다.

개발자용 정보가 화면에 그대로 노출됐다

"조회 불가 / Claude 사용량 응답 429" — 429는 우리가 사용량 API에 너무 자주 물어봐서 잠시 막힌 것이다. 유저가 할 일이 없고 저절로 풀리는데, "조회 불가"라고 크게 써두면 뭔가 고장 났다고 오해한다. "잠시 후 다시 확인합니다"로 바꾸고, 인증이 풀렸거나 권한이 없는 경우(401/403)는 유저가 할 일이 있으므로 "다시 연동해주세요"로 구분했다. 원문은 툴팁에만 남겨 문제 추적은 가능하게 뒀다.

실행 파일 경로를 계정으로 바꾼다. /Users/taehoon/.nvm/versions/node/v22.11.0/bin/codex는 비개발자에게 아무 의미가 없다. 어느 계정으로 연결됐는지가 유일하게 궁금한 정보다 — "me@x.com · team 플랜".

하단 안내에서도 CLI를 없앴다.

전: "해당 CLI를 이 컴퓨터에 설치하고 … 해제는 로그인만 끊고 CLI는 그대로 둡니다"
후: "필요한 프로그램을 이 컴퓨터에 설치하고 … 해제하면 로그인만 풀립니다.
     설치한 프로그램은 지우지 않습니다"

셋 다 소스가 아니라 화면 텍스트로 검증한다. 렌더 결과에 "429", "조회 불가", ".nvm", "CLI"가 있으면 실패한다 — 어디서 새든 잡힌다.

카드 높이가 상태 도착마다 흔들렸다

가장 큰 흔들림은 사용량 패널이었다. 연동된 경우에만 그려서, 상태가 도착하는 순간 카드가 통째로 90px쯤 늘어났다. 연동 여부와 무관하게 같은 영역을 그리고 안쪽 내용만 바꾼다 — 연동 전에는 "연동하면 사용량을 볼 수 있습니다".

액션 버튼 묶음도 폭을 고정했다. 버튼이 상태에 따라 생기고 사라지며 글자까지 바뀌어서(연동하기 / 로그인 / 해제 / 해제할까요?) 옆의 새로고침 버튼이 좌우로 움직였다. 확인 전에는 같은 크기의 자리표시자를 둔다.

구조가 같으면 높이도 같다는 것을 테스트로 고정했다. 테스트가 CSS 클래스 문자열에 기대지 않도록 data 속성으로 표식을 달았다 — 클래스는 스타일을 손대면 바뀐다.

중간에 "대화에 사용 중" 배지도 자리를 잡아두려다 동작을 깼다. 그 배지는 이름 줄의 마지막 요소라 뒤로 밀 것이 없어서 애초에 흔들림의 원인이 아니었다. 되돌렸다.

로그인 파싱은 CLI 출력 문구에 기댄 가장 약한 고리다

Claude 로그인을 눌러도 ChatGPT 안내가 떴다. LoginFlowModal에 지금 로그인하는 provider가 아니라 defaultProvider("대화에 사용 중")를 넘기고 있었다. 둘이 같을 때만 우연히 맞았다. 세션을 provider와 함께 들고 다니도록 바꿨다.

회귀 테스트를 넣으면서 처음 쓴 테스트가 무의미했다 — 목의 getAiProvider가 항상 "claude"를 돌려줘서, 예전 코드로 되돌려도 통과했다. 목을 제어 가능하게 바꾸고 나서야 뮤테이션이 잡혔다.

인앱 로그인의 파싱을 loginParse.ts로 분리했다. 여기가 가장 약한 고리다 — CLI가 사람에게 보여주려고 찍는 문구에 기대고 있어서, 업데이트로 문구가 바뀌면 인앱 로그인이 통째로 깨진다. 폴백이 있어 완전히 막히진 않지만 그 순간 유저는 터미널 창을 보게 되고, 타깃을 생각하면 그게 곧 이탈이다.

  • 파싱을 순수 함수로 빼서 기록해둔 실제 출력으로 상시 검증
  • 규칙이 두 벌로 갈라지지 않는지 소스 검사로 고정
  • pnpm check:cli-login이 실제 CLI와 대조 — 자동으로 못 돌린다. CLI가 브라우저를 직접 열어서 테스트가 화면에 탭을 띄우게 된다. CLI를 올릴 때 수동으로

claude 2.1.235의 실제 출력에서 visit: https://…Paste code here가 그대로인 것을 확인했다. 사이드카는 stdout·stderr를 모두 듣고 있어 스트림이 바뀌어도 잡힌다.

main 창에 center를 켰다. 로그인 모달은 창 안에서 중앙인데 창 자체가 화면 아무 데나 떠서 중앙이 아닌 것처럼 보였다. dock 창은 트레이 위치를 따라야 하므로 그대로 뒀다.

첫 실행에 아무것도 못 하던 화면에 온보딩을 넣다

이게 없으면 처음 켠 유저는 아무것도 못 했다. 연동이 설정 화면 안에만 있어서 어디를 눌러야 하는지 알 방법이 없었고, AI가 연결돼야 앱이 돈다는 사실조차 화면에 없었다.

한 번 지나가면 다시 뜨지 않는다. 이미 CLI에 로그인된 사람에게도 띄우지 않는다 — 할 일이 없는 화면이다. 판단 전에는 그리지 않는다. 그리면 이미 연결된 사람에게 잠깐 번쩍인다.

하나만 연결했으면 그 계정을 대화 provider로 맞춘다. 안 맞추면 연결하지 않은 쪽이 선택된 채 첫 대화가 실패한다 — 온보딩을 막 통과한 사람이 곧바로 실패를 보는 건 최악이다. 둘 다 연결했으면 임의로 고르지 않는다.

"나중에 하기"를 남긴다. 연결을 강제하면 둘러보지도 못하고 이탈한다. 연동 로직은 설정 화면과 같은 훅(useConnect)을 쓴다. 복붙하면 설치·로그인 순서와 실패 처리가 두 벌이 되어 한쪽만 고쳐진다.

로그아웃 없이 화면을 다시 보는 스위치

온보딩은 "처음 한 번"이라 정상 경로로는 로그아웃해야만 다시 볼 수 있는데, 그러자고 실제 계정을 끊는 건 대가가 크다. 되돌리는 데도 시간이 든다. 그래서 콘솔에서 켜는 스위치를 뒀다.

localStorage.setItem("resumate:onboarding:force", "1");        // 실제 상태로 열기
localStorage.setItem("resumate:onboarding:force", "preview");  // 미연결 상태로 열기

preview는 상태를 묻지 않고 "아무것도 연결 안 됨"으로 그린다. 둘 다 연결된 상태에서 열면 체크 표시만 보여서 정작 확인하려던 연동 버튼이 안 나온다. 완료하면 스위치를 함께 끈다 — 안 그러면 새로고침마다 다시 열려 빠져나갈 수 없다.

이 스위치를 만들어 화면을 확인한 뒤, 확인용으로 켜뒀던 강제 표시 임시 코드 두 줄은 곧바로 지웠다. 확인이 다시 필요하면 임시 코드가 아니라 스위치를 쓴다.

화면 쏠림, 진행 중 리플로우, 문구

왼쪽으로 쏠려 보이던 것 — 온보딩과 설정 모두 부모가 flex 컨테이너인데 자식에 너비 지정이 없었다. flex 아이템은 콘텐츠 폭만큼만 차지하므로, items-center로 가운데 정렬을 걸어놔도 박스 자체가 작아 왼쪽에 붙는다. w-full/flex-1을 준다. 설정은 폭 제한도 없어 넓은 창에서 한 줄이 끝까지 늘어났다 — max-w-2xl로 묶고 가운데 뒀다.

연동하기를 누를 때 흔들리던 것 — 진행률 막대가 조건부로 나타나며 카드 높이를 늘리고, 버튼 글자가 "연동하기 → 진행 중"으로 바뀌며 폭도 변했다. 막대는 항상 자리를 차지하고 opacity로만 가른다. 버튼과 완료 체크 아이콘은 같은 고정 폭을 쓴다.

"나중에 하기"는 "AI 없이 이력서만 작성하기"로 바꿨다. 실제로 뭐가 되는지 확인해보니 AI를 쓰는 건 /chat 하나뿐이었다. 연결 없이도 이력서 작성·편집·PDF 내보내기·보관함·파일 탐색이 모두 동작한다. "둘러보기"는 할 게 없다는 뉘앙스인데 사실이 아니고, 유료 앱에서 산 사람이 로그인 벽에서 아무것도 못 하면 그 자리에서 환불이다. 실제로 할 수 있는 일을 적었다.

상태를 못 물어봤을 때는 안 띄우는 쪽이 낫다

getAuthStatus가 실패하면 null이 되는데, 그걸 "로그인 안 됨"으로 읽고 있었다. 사이드카가 아직 안 떴을 뿐인데 이미 연동한 유저가 온보딩을 본다. 앱 시작 직후 사이드카가 준비되기 전에 조회가 나가면 실제로 걸린다.

판단이 서지 않으면 띄우지 않는 쪽이 낫다. 연결이 필요하면 헤더의 "연결하기"와 요청 시 안내가 어차피 알려주므로, 잘못 띄워서 이미 연동한 사람을 막는 것보다 덜 나쁘다. 판단을 순수 함수로 뺐다(lib/onboarding.ts). App.tsx 안에 두면 검증할 방법이 없는데, 이 판단이 틀리면 이미 연동한 유저가 매번 온보딩을 본다.

헤더는 상태만 알리고, 연결은 설정 한 곳에서만 한다

"CLI"라는 말을 화면에서 없앤다. 유저는 그게 뭔지 모르고 알 필요도 없다. 연결 안 된 상태의 헤더는 이제 "연결하기"이고, 누르면 설정으로 간다. 연결된 상태도 눌러서 설정으로 갈 수 있다.

연결 행위를 한 곳으로 모았다. 예전에는 헤더에서도 로그인을 할 수 있었는데, 헤더는 터미널을 여는 옛 경로를 타고 설정은 인앱 경로를 탔다. 같은 "로그인"인데 결과가 다른 건 버그의 씨앗이다.

설치 안 됨과 로그아웃을 헤더에서 구분하지 않는다. 유저가 할 일은 어느 쪽이든 "설정에서 연결하기" 하나뿐이라, 구분해 보여줘봐야 무엇을 해야 하는지만 흐려진다.

이 과정에서 "AI 연결이 필요합니다" 안내를 지웠다가 되살렸다. useChat은 인증이 안 되면 조용히 null을 반환하고, 알리는 건 이 이벤트를 받는 곳뿐이다. 지운 채로 뒀으면 로그아웃 상태에서 메시지를 보낼 때 아무 반응 없이 먹통으로 보였을 것이다. 새 안내는 이유를 알리고 "연결하러 가기"로 설정에 보낸다.

캐릭터 이름을 바꾸면 어딘가는 반드시 옛 이름이 남았다

설정 → 캐릭터 이름에서 넷을 각각 바꾼다. 무엇을 바꾸는지 알 수 있게 역할과 기본 이름을 함께 보여준다 — "nyx"가 뭘 하는 앤지 모르는 채로 이름부터 바꾸게 하면 더 헷갈린다.

표시 이름과 식별자를 분리한다. CharacterId("nyx" 등)는 세션 키·DB 행·코드 전반에 쓰여 바꿀 수 없다. 바꾸는 건 화면 이름뿐이다.

그 이름을 시스템 프롬프트에 주입한다. 프롬프트가 You are Nova, ...로 이름을 박아두고 있어서, 화면만 바꾸면 화면은 "정리봇"인데 대화에서는 "Nova"라고 답한다. 프롬프트 문구가 바뀌어 첫 줄을 못 찾는 경우에도 조용히 넘어가지 않고 앞에 한 줄로 못박는다.

세션은 버리지 않는다. 시스템 프롬프트는 새 세션에서만 전달되므로 변경은 다음 대화부터 적용된다. 처음엔 "대화를 새로 시작한다"고 안내하려 했는데, 이름 하나 때문에 진행 중인 모의면접이 통째로 날아가는 건 값이 안 맞는다. 화면에도 "새 대화부터 적용됩니다"라고 사실대로 적었다.

이름 길이와 줄바꿈을 막는다. 이름이 프롬프트에 그대로 들어가므로, 유저가 붙여넣은 문단이 시스템 프롬프트 첫 줄이 되어 캐릭터 지시를 밀어낼 수 있다. 헤더 탭도 바뀐 이름을 쓴다. 한쪽만 바뀌면 어느 쪽이 진짜인지 알 수 없다. displayName을 "정리봇"으로 두고 /chat에 물으니 "정리봇이야."라고 답했다.

헤더는 됐는데 나머지는 안 됐다

바로 이어서 드러났다. 입력칸이 blur에서만 저장해서, 타이핑해놓고 화면 어디에도 반영이 안 되어 "안 바뀐다"고 느꼈다 — 입력 즉시 저장하게 바꿨다. 지운 순간 기본 이름이 되살아나면 이어서 타이핑할 수 없으므로, 저장값과 입력칸 표시는 따로 뒀다. 보관함이 캐릭터 이름을 하드코딩하고 있던 것도 함께 고쳤다. 이름을 바꿔도 거기만 옛 이름이 남는다 — 역할 이름("이력서", "프로젝트")으로 바꿔 이름과 무관하게 했다.

그러고 나니 헤더 탭만 바뀌고 독·보관함·스킬 설정·첨삭본 라벨은 옛 이름이 남았다.

독은 별도 Tauri 창이라(Tauri는 창을 여러 개 띄울 수 있고, 독은 main과 완전히 다른 창이다) 같은 창 안에서만 도는 CustomEvent를 받지 못한다. localStorage는 다른 창에서 바뀌면 storage 이벤트가 오므로 둘 다 듣는 구독 헬퍼를 만들었다. 하나만 들으면 독은 영원히 옛 이름을 보여준다. 나머지는 캐릭터 id를 그대로 그리고 있었다({character.toUpperCase()}). 전부 표시 이름을 거치게 바꿨다.

이름은 한 곳에서만 온다는 것을 소스 검사로 고정했다. 화면마다 따로 그리면 이름을 바꿔도 어딘가는 옛 이름이 남는데, 그게 어디인지는 유저가 발견하기 전까지 알 수 없다. 독을 옛 방식으로 되돌리는 변형으로 잡히는 것을 확인했다.

그런데도 하나 더 남아 있었다. 가드 테스트가 대문자만 보고 있어서 소문자 id를 못 잡았다 — 홈 화면이 {id}를 그대로 그려서 이름을 바꿔도 "nyx nova paws echo"가 남아 있었다. 가드를 대소문자 변환까지 보도록 넓혔다. 넓히자 9곳이 걸렸는데 대부분 key={id}·character={id} 같은 정상 사용이었다. 시끄러운 가드는 결국 꺼진다. JSX 텍스트 자리(>{id}<)만 보도록 좁히니 진짜 대상은 홈 화면 하나였다.

같이 리플로우도 잡았다. 헤더 배지가 상태마다 글자 길이가 달라("확인 중" → "Claude 연결됨") 바뀔 때마다 주변이 밀렸다 — 가장 긴 문구에 맞춰 폭을 고정했다. 확인 전에는 같은 크기의 자리표시자를 둔다. 비워두면 자리가 생기면서 밀리고, "연결하기"를 미리 보이면 이미 연결된 사람에게 그 버튼이 번쩍인다. Skeleton은 크기를 인자로 받지 않는다 — 기본 크기를 정하면 콘텐츠가 바뀔 때 조용히 어긋나므로, 쓰는 쪽이 실제 콘텐츠와 같은 클래스를 준다.

사용량 막대는 방향과 상태가 둘 다 틀려 있었다

"16% 남음"은 막대가 거의 비어 보이는데 실제로는 한도에 임박한 상태다. 가장 위험한 순간에 오히려 안심하게 만든다. 막대가 차오를수록 여유가 없다는 뜻이라야 직관에 맞으므로 방향을 뒤집었다 — "25% 사용". 70%·90%에서 색이 바뀐다. 숫자만으로는 위험이 안 읽힌다.

재설정 시각을 사람이 읽는 말로 적는다. "(수) 오후 03:00"은 그게 몇 시간 뒤인지 계산해야 알 수 있다. 남은 시간에 따라 세 구간으로 갈랐다.

남은 시간문구
1시간 안30분 후 재설정
하루 안3시간 20분 후 재설정
그 이상토요일 오후 6:00 재설정

마지막 구간에서 절대 시각을 쓰는 건 "68시간 후"라고 하면 감이 안 오기 때문이다. 시간 계산은 조용히 틀리기 쉬워서 순수 함수로 뺐다(lib/usageFormat.ts).

"0% 사용인데 확인 중"이라는 모순

이번 구간에 쓴 게 없으면 응답에 그 창이 아예 오지 않는다. 그걸 "아직 못 받음"과 같이 취급해서 "0% 사용"이라고 써놓고 동시에 "사용량 확인 중"이라고 하는 화면이 나왔다. 셋을 구분했다.

상태화면 문구
아직 못 받음사용량 확인 중
조회 실패사용량을 가져오지 못했습니다
응답에 없는 구간아직 사용 없음

쓴 건 있는데 초기화 시각만 모르는 경우도 갈랐다. 처음 고칠 때 이것까지 "아직 사용 없음"으로 떨어져서 거짓말이 됐다 — 테스트가 4개를 세면서 잡아냈다.

재설정 문구가 시간이 지나도 그대로인 문제도 같이 고쳤다. "30분 후 재설정"이 30분 뒤에도 같은 말이면 틀린 정보다. 30초마다 갱신한다. 렌더 중에 Date.now()를 부르면 순수하지 않으므로 상태로 들고 effect에서 채운다.

provider별 계정 감지와 사용량 엔드포인트

identity 문자열에 provider 이름이 들어 있어서, 전역 변수 하나로 기억하면 claude와 codex를 번갈아 조회하는 것만으로 값이 매번 뒤집혔다. 설정 화면의 refresh()가 두 provider를 한꺼번에 조회하므로 실제로 그렇게 됐고, 그때마다 세션을 폐기해 이전 대화가 통째로 사라졌다. 세션 폐기도 바뀐 쪽만 하도록 고쳤다 — claude 계정이 바뀌었다고 codex 대화까지 끊을 이유가 없다.

GET /auth/usage?provider=...가 5시간·주간 사용률과 리셋 시각을 돌려준다. 공급자별로 출처가 달라 usage.ts 한 곳에 모으고 결과는 캐시한다. 조회 실패는 503으로 내려 로그인 상태 조회(/auth/status)와 얽히지 않게 했다.

리플로우를 없애려 스켈레톤 시스템을 만들다

목록 화면들이 "불러오는 중…" 한 줄만 두고 있었다. 도착하는 순간 화면이 통째로 밀려서, 유저가 누르려던 것이 손가락 밑에서 움직인다. 화면마다 실제 항목 높이를 재서 줄 수를 정했다.

화면줄 수항목 높이
보관함3줄57px
파일 트리8줄26px
파일 미리보기6줄13px
스킬 선택3줄40px

높이는 실제 항목에 맞췄다. 대충 채우면 도착할 때 여전히 밀린다. SkeletonList는 크기를 인자로 받는다 — 여기서 기본값을 정하면 화면마다 다른 항목 높이와 조용히 어긋난다.

새 화면에서 이 처리가 빠지는 것을 소스 검사로 막는다. loading 분기에 자리표시자 없이 문구만 두면 실패한다 — 보관함을 옛 방식으로 되돌리는 변형으로 잡히는 것을 확인했다. 자리표시자는 aria-hidden으로 감춘다. 읽는 사람에게는 의미 없는 장식이다.

Echo 질문 기능을 하루 안에 두 번 갈아엎다

핀으로 고르고 나머지는 셔플

질문이 적다는 피드백에 상수만 올려 답하지 않았다. 핀으로 고정하고 나머지를 로컬 풀에서 섞게 해, 사용자가 자기 목록을 만드는 쪽으로 설계했다. 구현 전에 설계 문서 두 개를 먼저 적었다 — 하나는 큐레이션·모범 답변 설계, 하나는 "핀이 조용히 사라지는 경로 세 곳"(재진입 효과·selectedQuestion 정합·모범답변 캐시 무효화)이다. 셋 다 핀이나 바뀐 이력서를 조용히 버리는 자리라서, 구현 전에 규칙을 못박아 뒀다.

AI 생성을 32개 후보 풀로 늘리고 노출 12개 중 마음에 드는 것만 핀으로 고정, 나머지는 로컬에서 즉시 섞는다. 셔플이 API를 타면 반복이 끊겨서 큐레이션이 안 된다. 풀이 마르면 기존 질문을 프롬프트에 넘겨 겹치지 않는 16개를 이어 붙인다. fallback도 같은 풀을 쓰므로 자료 없이도 24개에서 뽑는다 — 종전엔 8개만 보였다.

모범 답변은 질문마다 접힌 섹션으로 두고 답변 전에도 열린다. 자료에 근거가 있으면 그 경험으로, 없으면 grounded:false로 자리표시자 구조 예시를 준다. 없는 경험을 지어내면 그대로 외워 가서 면접에서 무너진다.

설계했던 세 경로 모두 막았다 — 재진입은 핀 아닌 슬롯만 다시 뽑고 세션을 갈지 않는다. 셔플은 보고 있는 질문을 남긴다. 모범 답변 캐시는 이력서·공고·프로젝트가 바뀌면 버린다.

핀·셔플 도입으로 localStorage 키가 바뀌었다. 그대로 두면 이미 AI 질문을 만들어둔 사용자가 fallback 질문을 보는데 "맞춤 질문 N개를 만들었습니다" 안내만 남아 화면이 앞뒤가 안 맞는다. 옛 키를 읽어 풀과 노출 목록을 잇는 마이그레이션을 넣었다.

이걸 담아 0.2.1로 버전을 올렸다. 미리보기 DMG를 건네려면 파일명이 직전 0.2.0 생성물과 달라야 하고, 받는 사람도 어떤 버전인지 알 수 있어야 한다는 이유였다.

그런데 같은 날, 창 모델 자체를 버렸다

핀·셔플은 "12개 창에 무엇을 넣을지"를 고르게 하는 모델이었다. 카테고리 탭과 페이지네이션으로 전부 넘겨볼 수 있으면 창 자체가 없어져서, 고를 필요가 없다. 창이 없으면 핀도 섞기도 존재 이유가 없으므로 방금 만든 두 기능을 둘 다 걷어냈다.

페이지네이션 하단 고정은 레이아웃 제약이라 패널을 flex 컬럼으로 다시 세웠다. 헤더(탭)와 푸터(페이지)는 shrink-0, 목록만 스크롤한다. 바깥이 스크롤하면 푸터가 따라 올라간다.

목록과 상세를 한 컴포넌트가 분기로 들고 있으면 "루트 페이지"라는 구조가 코드에 드러나지 않는다. QuestionListPanel을 새로 두고 InterviewPracticePanel은 상세만 맡는다 — question prop이 non-null이 됐다.

상세의 이전/다음은 현재 탭의 전체 목록을 기준으로 넘기고, 페이지 경계를 넘으면 목록 페이지도 함께 옮긴다. 돌아왔을 때 방금 본 질문이 없는 페이지가 열리면 길을 잃는다.

죽은 localStorage 키(echo:pinned-questions, echo:shuffled-questions)는 마운트 시점에 지운다. 남겨두면 다음 사람이 살아 있는 상태로 오해한다.

그 위에서 계속 다듬다

성공 알림이 영속하고 있었다. 화면에 "섞기를 눌러 새 질문을 만나보세요"가 남아 있었다. 섞기는 지웠는데 echo:generation-noticelocalStorage에 있어서 이전 세션 문구가 그대로 떴다. 성공 알림이 세션을 넘어 사는 것 자체가 잘못이다 — 알림은 지나가는 것이지 상태가 아니다. 진행·오류만 박스로 남기고 성공은 6초 뒤 사라진다. 옛 키는 지웠다.

파괴적 재생성이 가장 큰 버튼이었다. 질문 56개와 모범 답변을 전부 버리는데 확인도, 무엇을 잃는지에 대한 안내도 없었다. 이 동작이 정당한 경우는 자료가 바뀌었을 때 하나뿐이므로:

  • 주 버튼은 안전한 "질문 더 만들기"
  • 전체 교체는 작은 보조 버튼 + 잃는 개수를 숫자로 보여주는 확인
  • 자료가 바뀌면(poolSignature ≠ 현재 서명) 배너로 재생성을 권하고, 이때는 확인을 생략한다 — 모범 답변 캐시는 이미 비워졌고 남은 질문은 옛 공고 기준이다

더 만들기가 뒤에 붙어서, 1페이지를 보던 사용자에게는 아무 일도 없어 보였다. 새 질문을 맨 앞에 넣고 NEW를 붙이고 1페이지 전체 탭으로 되돌렸다. 질문을 계속 추가하면 페이지 번호가 끝없이 늘어나므로 pageWindow로 처음·끝·현재 주변만 남긴다.

색이 세 가지나 쌓여 있었다. 사이드바에 보라·호박·인디고 박스가 세로로 쌓여서 어디를 봐야 할지 사라졌다. 색은 주 동작(인디고 버튼) 하나만 남기고 배너·확인·알림은 모두 중립 표면으로 내렸다. 종류는 아이콘 색으로만 구분한다 — 경고는 호박 아이콘, 오류는 빨강, 진행은 인디고. 성공/진행/오류 박스도 하나로 합쳤다. 알림이 창 밑단에 붙어 답답했던 것도 사이드바 하단 패딩을 p-5에서 pb-8로 벌려 고쳤다.

모범 답변을 답변 입력 위에 두면 답하기 전에 그것부터 보게 된다. 답변 섹션과 평가 결과 아래로 내렸다 — 열람 제한은 그대로 두고 배치로만 순서를 권한다.

NEW 배지도 곧 걷어냈다. 새 질문을 맨 앞에 넣으니 1페이지에서 이미 보인다. 배지는 그 위에 얹은 군더더기였고, 다음 추가까지 화면에 남아 오히려 시선을 끌었다. 추가됐다는 사실은 사라지는 토스트 한 번으로 충분하다. echo:new-question-texts 키도 죽은 키 정리 목록에 넣었다.

질문 생성과 연습이 같은 세션을 두고 부딪혔다. 사이드바는 상세와 같은 화면에 있는데 셋 다 useChat("echo")의 기본 레인을 썼다. 레인은 한 캐릭터 안에서 대화 세션을 여러 갈래로 나누는 키인데, 질문 생성 중에 모범 답변을 누르면 echo:main 세션에 --resume이 동시에 두 번 걸린다 — 사이드카가 주석으로 경고해둔 바로 그 상황이고, abortRef는 훅 인스턴스별이라 서로 취소하지도 않는다. questions / practice 레인으로 나눴다.

레인을 나눴으니 가드도 좁혔다. loading.echo는 레인들의 OR이라, 모범 답변 로딩 중에 질문 더 만들기를 누르면 조용히 무시됐다 — 버튼은 활성인데 클릭이 먹지 않았다. isLaneLoading으로 자기 레인만 보고, 연습 쪽 버튼은 레인이 바쁠 때 실제로 잠근다.

"더 만들기"에는 끝이 없어서 풀이 무한히 커졌다. 게다가 프롬프트에 기존 질문 전체를 실어 클릭할수록 요청이 커졌다. 상한 120개를 두고 도달하면 버튼을 잠그며 전체 재생성으로 안내한다. 프롬프트 표본은 최근 40개로 잘라 요청 크기를 상수로 묶었다. 레인 상수는 echoQuestions로 모았다 — 두 컴포넌트가 문자열을 따로 들고 있으면 어긋나는 순간 조용히 같은 세션을 쓰게 된다.

캐릭터가 진행·성공을 말풍선으로 말하게 하다

점 애니메이션부터 손봤다. 세 점이 고정으로 튀던 것을 ......로 세는 인터랙션으로 바꿨다. 점마다 키프레임을 따로 정의한 건 의도다 — 하나의 키프레임에 animation-delay를 주면 등장뿐 아니라 퇴장까지 밀려서 파도처럼 흐르고, 세는 느낌이 사라진다. 등장 시점만 다르고 사이클 끝에서 함께 리셋되어야 한다.

말풍선 자체도 통합했다. 말풍선이 두 개였다. 점(-top-14, 카드 밖)과 문구(top-4, 카드 안)가 위치도 색도 다른 채 같은 일을 하고, guide && !isLoading로 서로를 배제했다. 네 뷰가 모두 guide를 넘기고 있었는데 정작 작업 중에는 안 보였다. 하나로 합쳐 문구와 점을 같은 줄에 두고 꼬리를 붙였다. 카드 안쪽에 두는 것도 의도다 — 카드 밖은 사이드바 overflow에 잘린다.

스토어에 agentGuide를 뒀다. 텍스트만 갖는다 — kind를 같이 넣으면 "작업 중"을 아는 곳이 activity·loading·laneLoading에 이어 네 곳이 되어 어긋난다. 색은 activity에서 파생시킨다. SQLite로 나가지 않으므로 지나가는 말이 다음 세션에 되살아나지 않는다.

Echo를 먼저 옮겼다. 진행·성공은 말풍선이 짧게 말하고, 박스에는 조치가 필요한 것만 남는다. 진행 문구가 휘발성이 되었으므로 새로고침으로 끊긴 걸 알아채려면 플래그가 따로 필요해 echo:generation-inflight를 뒀다.

이 작업 도중 직전 작업의 실수도 함께 고쳤다. handleGenerateQuestions는 레인 치환이 빗나가 켜는 레인(main)과 끄는 레인(questions)이 어긋나 있었다. 그대로 두면 한 번 생성한 뒤 main 레인 플래그가 영구히 남아 질문 생성이 다시 안 되고 캐릭터가 계속 로딩으로 보인다. 네 곳 모두 QUESTION_LANE으로 맞췄다.

나머지 세 캐릭터로도 확장했다. Echo에만 있던 배선을 공용 훅(useNoticeGuide)으로 뽑아 Nyx까지 옮겼다. 진행 문구는 말풍선에 머물고, 성공 한마디는 6초 뒤 내려간다. 내려가면 각 뷰가 계산하는 정적 guide로 돌아가므로 "분석 끝났어요!" 뒤에 "오른쪽에서 점수와 개선점을 확인하세요"가 이어진다 — 긴 안내를 말풍선에 밀어넣지 않고도 정보가 남는다.

Nyx의 박스는 오류만 남긴다(로그인 버튼이 그 안에 있어 박스가 필요하다). 말풍선용으로 문구도 줄였다: "본문을 읽었습니다. AI가 문장과 근거를 분석하고 있어요." → "문장과 근거를 살펴보고 있어요". Nyx의 성공 알림도 영속에서 끊었다 — Echo에서 고친 것과 같은 종류다.

Nova는 옮길 것이 없었다. 알림 상태가 없고 guide가 진행·완료를 이미 덮고 있어서, 말풍선 통합만으로 동작한다. 대신 완료 상태를 캐릭터에 넘겨 배지가 뜨게 했다 — status를 안 넘겨서 지금까지 한 번도 안 떴다.

훅은 매 렌더 새 객체를 받아도 안전하다(kind·text만 의존). 내려간 성공 문구가 리렌더로 되살아나지 않는지 테스트로 잠갔다.

패키징된 앱에서만 캐릭터가 안 보이는 배포 구멍

0.2.1 dmg에서 캐릭터 네 마리가 한꺼번에 빈 화면이었다. dotlottie-web과 rive(둘 다 벡터 애니메이션 플레이어다)는 WASM을 jsdelivr/unpkg에서 런타임에 내려받는데, 패키징된 앱에서는 connect-src 'self' CSP에 그 요청이 막힌다. 개발에서는 통과하던 요청이 dmg에서만 막혔다.

animationRuntime.ts?url import로 받은 로컬 경로를 DotLottie.setWasmUrl / RuntimeLoader.setWasmUrl에 넣는다. 복사 스크립트를 두지 않은 건 의도다 — Vite가 패키지에서 직접 emit하므로 glue JS(WASM 모듈을 브라우저에 연결해주는 자바스크립트 래퍼)와 WASM 버전이 어긋날 수 없다. 각 엔트리 최상단에서 import하는 것도 의도다. 플레이어 인스턴스가 만들어지기 전에 실행돼야 한다.

CSP의 script-src'wasm-unsafe-eval'도 함께 넣는다. 로컬 WASM만으로는 부족하다 — WebKit은 이 키워드가 없으면 WebAssembly.instantiate 자체를 막는다. 둘 중 하나만 고치면 증상이 그대로다.

dotlottie-web과 @rive-app/canvas를 캐럿 없이 정확 버전으로 직접 의존성에 넣는다. react 래퍼가 내부적으로 쓰는 버전과 어긋나면 새 WASM에 옛 glue JS를 물려 지금 고치는 것과 똑같이 조용히 죽는다.

Wi-Fi를 끊은 상태에서의 확인은 아직 하지 않았다 — CDN이 닿아서 나온 것과 로컬 WASM으로 나온 것을 구분하려면 그 테스트가 필요하다.

창을 줄이면 UI가 깨졌다

반응형이 아니라서 창을 줄이면 UI가 깨졌다. main 창에 minWidth 1024, minHeight 700을 준다. 1024는 취향이 아니라 잘리는 지점에서 나왔다 — ResumeBuilderViewminmax(360px,...)_minmax(430px,...) 그리드가 790px 아래로 가면 오른쪽 컬럼이 부모의 overflow-hidden 밖으로 밀려 스크롤도 없이 잘린다. 캐릭터 뷰는 파일트리 276 + 좌패널 320으로 596을 먼저 떼가므로, 1024는 본문이 1열이 된다는 전제에서 성립하는 값이다. 세로는 하드 플로어가 없어서(전부 스크롤로 흡수) 700은 여유값이다.

minWidth는 tao(Tauri가 쓰는 Rust 창 관리 라이브러리)를 거쳐 NSWindow(macOS 네이티브 창 객체)의 minSize로 들어간다. tao가 콘텐츠 크기를 프레임 크기로 바꿔 setMinSize:를 부르고, 이 최소 크기 제약은 유저가 창 크기를 바꿀 때 적용된다. 1024는 1920의 절반인 960보다 크므로 좌우 분할이 기울어진다 — 의도된 교환이다.

최대화는 macOS 네이티브 풀스크린이 아니라 zoom이다. 풀스크린은 앱을 별도 Space로 옮겨서 항상 떠 있어야 하는 dock 창이 뒤에 가려진다.

toggleMaximize()는 쓰지 않는다. 이 창은 decorations: false라 스타일 마스크에 Titled가 없는데, tao 0.34.8의 set_maximized는 그 경우 네이티브 zoom() 대신 setFrame:display:NO animate:YES를 호출한다. 프레임은 애니메이션으로 움직이면서 그 동안 다시 그리지 말라고 지시하는 조합이라, 예전 크기의 화면이 커지는 프레임 좌상단에 붙어 있다가 끝에 스냅한다 — 화면이 좌측으로 밀렸다가 커지는 것처럼 보인다.

그래서 toggle_maximize_animated 커맨드가 NSAnimationContext(AppKit의 애니메이션 구간을 감싸는 API)로 duration 280ms와 easeInEaseOut을 직접 지정하고 animator().setFrame(_, display: true)로 그리면서 움직인다. 프런트에서 setSize+setPosition으로 애니메이션하지 않은 것도 의도다 — 둘이 원자적이지 않고 setContentSize가 Cocoa 좌표계의 좌하단 원점에서 자라서 매 프레임 상단이 Δh만큼 튄다. 한 번의 setFrame은 위치와 크기를 동시에 옮긴다.

판정 규칙은 tao의 is_zoomed()와 같게 유지한다(프레임 폭·높이를 visibleFrame과 각각 1px 이내로 비교). 다른 규칙을 쓰면 프런트의 isMaximized()와 어긋난다. 최대화 상태는 Rust 반환값을 쓴다 — 애니메이션 도중 frame()은 중간값이라 isMaximized()가 false를 주고, onResized마다 상태를 갱신하면 버튼 아이콘이 깜빡인다.

더블클릭 토글은 별도 onDoubleClick이 아니라 handleDrage.detail로 갈랐다. 분리하면 첫 mousedown에서 startDragging()이 OS에 마우스를 넘겨 두 번째 클릭이 오지 않는다.

헤더 가운데 칸을 560px 고정에서 auto로 바꿨다. 고정폭은 양방향으로 틀렸다 — 기본 이름에서 nav 콘텐츠는 약 460px이라 100px을 죽은 여백으로 붙잡아 왼쪽 AuthBadge(폭 124px)를 잘랐고, 캐릭터 이름을 MAX_NAME_LENGTH까지 늘리면 탭만 480px이 되어 560을 넘어 터졌다. 양쪽이 1fr이라 auto로도 가운데 정렬은 유지된다. 이름을 8자로 채웠을 때 다시 잘리는 문제는 남아 있다 — 좁을 때 라벨을 접는 점진적 축약이 필요하다.

이 세 결정(창 크기·최대화 방식·WASM 번들)은 숫자나 API 선택의 이유가 코드에 남지 않는 종류라 별도 설계 문서로 적어뒀다. JSON에는 주석을 달 수 없어서 minWidth 1024가 특히 위험했다 — 근거가 없으면 다음에 아무 값으로 바뀐다.

AuthBadge와 그리드는 컨테이너 폭을 봐야 했다

minWidth 1024를 정한 뒤 바로 두 가지가 걸렸다. 헤더 왼쪽 칸이 좁아지면 부모의 overflow-hidden이 "Claude 연결됨"의 끝을 글자 중간에서 그대로 잘라냈다. w-[124px]가 하한처럼 동작해서 부모보다 넓어도 줄어들지 않았기 때문이다. max-w-full로 부모까지 줄어들게 하고, 글자는 spantruncate를 걸어 말줄임으로 끝낸다. 아이콘에는 shrink-0을 줘서 글자만 줄어든다. 이 수정은 잘림을 막지 않고 읽을 수 있게 만든다 — 제대로 하려면 nav 폭을 측정해야 하고, 그건 이 엣지 케이스에 비해 과하다.

Echo 질문 카드가 좁은 2열에 눌려 한국어가 어절마다 줄바꿈되는 문제도 나왔다. 원인은 md:grid-cols-2였다. 뷰포트 768px 기준인데 창 최소가 1024이므로 이 브레이크포인트는 항상 켜져 있었다. 반응형이 아니라 하드코딩이었다. 문제의 그리드들은 창 안의 3분할 안쪽에 있어서, 창이 1024여도 본문 패널은 428px밖에 안 된다. 창 폭으로 열 수를 정하면 최대 596px 어긋난 값을 보고 판단하게 되므로, Tailwind v4의 @container로 바꿨다. 여섯 곳 모두 같은 오류였다.

임계값은 취향이 아니라 내용에서 나왔다 — QuestionListPanel 600은 카드 내부가 한 줄에 20자 이상 들어가려면 카드 ≥282px, 2열은 282×2 + gap 8 = 572다. word-break: keep-all은 건드리지 않았다 — 한국어에서 어절 단위로만 끊는 게 맞고, break-all로 바꾸면 어절 중간이 끊겨 가독성이 더 나빠진다. 고칠 대상은 컬럼 폭이었다.

@container를 얹을 조상은 하위에 absolute/fixed 요소가 없는 것만 골랐다. container-type: inline-size가 적용하는 건 style 컨테인먼트와 inline-size 컨테인먼트이고, 절대·고정 위치 요소의 컨테이닝 블록을 만드는 건 contain: layout 쪽이다.

같은 자리의 스켈레톤도 함께 봤다. 여섯 곳 모두 그리드를 미러링하는 로딩 UI가 없었다 — 스켈레톤이 2열인데 내용이 1열로 뜨면 로딩 끝에 레이아웃이 튀므로, 앞으로 이 규칙을 바꿀 때 함께 확인해야 한다. SettingsView와 AuthBadge의 고정폭 Skeleton(w-[180px] 등)은 컨테이너가 좁아지면 줄어들지 않는 문제가 남아 있다.

PDF는 어디로 들어와도 같은 추출기를 타야 한다

Paws·Echo의 드롭 입구 여섯 곳(브라우저 drop / FileTree 내부 드롭 / Tauri 네이티브 drop)이 file.text()·readFile(path)로 파일을 읽고 있었다. PDF를 떨어뜨리면 바이너리가 그대로 본문으로 들어가고, 텍스트 레이어가 없는 PDF에서 사이드카의 OCR 폴백은 아예 호출되지 않았다. 입구가 뷰마다 셋이라 한 곳만 고치면 반드시 빠지는 곳이 생긴다.

그래서 추출을 lib/fileText.ts 한 곳으로 모았다. PDF는 무조건 /parse-pdf를 타고, pptx는 /parse-presentation, 나머지는 텍스트로 읽는다. resumeText는 그 위에 이력서 슬롯용 형식 제한과 최소 길이만 얹는다. Nyx가 인라인으로 갖고 있던 중복 분기와 Nova·FileTree·ResumeSourcePicker의 각자 구현도 여기로 수렴시켰다.

테스트는 "PDF는 어떤 입구로 들어와도 parsePdf를 탄다"를 못박는다 — 파일·경로 양쪽에서 file.text()/readFile로 새지 않는지까지 본다.

Nyx의 accept에서 .doc/.docx도 뺐다. 고르면 항상 "PDF, TXT, MD만 지원"으로 튕겨서 고를 수 있다는 표시 자체가 거짓말이었다.

poppler가 없는 배포 환경에서 텍스트 PDF까지 실패했다

/parse-pdfpdftotext(poppler에 들어 있는 PDF 텍스트 추출 CLI)를 못 찾으면 503으로 끝내고 있었다. poppler는 앱에 번들되지 않는 홈브루 의존이라, 그 기계에서는 스캔본뿐 아니라 텍스트 PDF까지 전부 실패한다. 개발 기계에는 poppler가 깔려 있어 드러나지 않던 배포 구멍이다. OCR 헬퍼(이미지에서 글자를 인식하는 도구)는 번들되어 있으므로 pdftotext가 없으면 곧장 OCR로 내려간다.

종료 코드 실패도 같다. 암호가 걸렸거나 살짝 깨진 PDF는 pdftotext가 0이 아닌 코드로 끝나는데, 예전 코드는 거기서 throw해 OCR 시도조차 하지 않았다. 화면에 그려지기만 하면 OCR로는 읽히므로 실패는 폴백 사유일 뿐 중단 사유가 아니다.

순서는 그대로 pdftotext → OCR이다. 임베딩된 텍스트가 있으면 그게 정답이고, OCR은 느린 데다 글자를 그림에서 추측하느라 오탈자가 섞인다.

판단을 pdfExtract.ts로 떼어내 도구를 주입해 시험했다. poppler를 못 찾게 막고 빌드해도 텍스트 PDF·이미지 PDF 모두 method:"ocr"로 읽혔다(예전에는 둘 다 503).

브랜드 색을 실측값으로 맞추다

accent 연두(#d8ff75)는 로고와 무관한 색이라 화면에서 튀었다. 로고 색을 그대로 쓰면 canvas 대비가 1.65:1로 잠기므로, 색조만 가져오고 명도를 올려 7.9:1을 맞췄다. 포인트 그림자의 rgb는 여기 한 곳에만 적는다 — 컴포넌트에 흩어두면 색을 바꿀 때 반드시 몇 개를 빠뜨린다.

이 값을 적용하기 전, #d8ff75rgba(216,255,117,...)로 흩어져 있던 하드코딩을 먼저 accent/accent-ink/shadow-point 토큰으로 걷어내는 리팩터가 선행됐다 — 동작 변경 없이 색값만 토큰을 타게 한 뒤에야 값 하나를 바꾸는 것으로 전체가 갱신됐다.

한 가지 사실을 화면 여러 곳이 각자 그리고 있었다

하루 전체가 전날 만든 것을 되돌리는 데서 시작했다. 정책이 바뀌면 그 위에 쌓은 코드는 방향 자체가 반대가 된다 — 배포 게이트도, AuthBadge 숨김도, 인증 우회 로직도 전부 거꾸로다. 태그로 남겨두고 되돌린 판단은 맞았다고 본다. 지운 게 아니라 다른 조건에서 다시 쓸 수 있게 옮겨둔 것이다.

되돌리고 난 자리에서 "연동"이라는 개념 하나를 설치 → 로그인 → 설정 → 온보딩 → 헤더까지 다섯 번 오가며 다듬었다. 설정 화면 하나가 자리 잡기까지 다섯 번의 후속 수정이 들었던 것처럼, 이름 하나 바꾸는 기능도 네 곳에 흩어진 표시를 다 찾아 고치는 데 네 번을 오갔다. 한 가지 사실(이름, 연동 상태, 사용량)을 화면 여러 곳이 각자 그리면, 하나를 바꿔도 나머지는 남는다. 오늘 고친 것 대부분이 이 모양이었다.

Echo 질문 기능은 같은 날 안에 만들고 버렸다. 핀+셔플을 설계 문서까지 써가며 만들었는데, 완성하고 보니 "창 안에서 고르게 하는 모델" 자체가 불필요했다 — 탭과 페이지네이션으로 전부 보여주면 고를 이유가 없다. 설계를 미리 적어둔 덕에 핀이 사라지는 경로들을 놓치지 않고 정리할 수 있었지만, 그 설계가 겨냥한 기능 자체가 하루를 못 넘겼다. 빨리 만들고 써보고 버리는 쪽이, 오래 고민해서 한 번에 맞추는 쪽보다 결국 더 빨랐다.