resumate 14. 개인정보 유출 가드와 계약의 lint 규칙화
자소서 프롬프트를 고치다가, 예시 문장 하나가 특정 사용자 이력서의 프로젝트명과 수치 그대로라는 걸 알아챘다. 프롬프트 문자열은 모든 사용자에게 그대로 실리는 값이었다.
실제 사용자 자료가 빌드 산출물까지 새어 나갔다
문제는 자소서 프롬프트(coverLetterFormat.ts)를 고치는 과정에서 생겼다. 실제 산출물을 보고 문제를 짚은 다음, 그 자리에서 예시로 옮겨 적었다. 그러면 그 고유명사가 남의 자소서 결과물에 섞여 들어간다.
이 사실을 알아채고, 실제로 빌드된 프런트 번들을 열어 그 문자열이 들어 있는 것까지 확인했다.
같은 자료가 이미 여러 곳에 퍼져 있었다.
- 테스트 픽스처(테스트가 입력으로 쓰는 고정 데이터.
coverLetterHtml.test.ts,resumeTemplate.test.ts,coverLetter.test.ts등) - 계획·스펙 문서(
docs/superpowers/plans/*.md) ARCHITECTURE.md의 절대 경로(옛 프로젝트명work-agents가 박힌 경로 포함)
전부 지어낸 이름(홍길동·가온컴퍼니·OO도구)과 <repo> 같은 자리표시자로 바꿨다. 한 번 청소로 끝날 문제가 아니라서, 재발을 막는 장치를 세 겹으로 세웠다.
flowchart TD
A["실제 사용자 이력서"] --> B["프롬프트 예시 (coverLetterFormat.ts)"]
A --> E["테스트 픽스처·계획 문서"]
B --> C["빌드 산출물 (dist)"]
C --> D["모든 사용자의 자소서 결과물"]
G1["소스 가드 (noPersonalData.test.ts)"] -.->|"막는다"| B
G1 -.->|"막는다"| E
G2["빌드 가드 (vite 플러그인)"] -.->|"막는다"| C
G3["CLAUDE.md 규칙"] -.->|"옮겨 적기 금지"| B
소스 가드
noPersonalData.test.ts가 src·sidecar·scripts·docs 아래 전체와 루트 문서를 훑어 연락처 형태와 실제 이력서 고유명사를 잡는다. 처음에는 검사 범위가 src·sidecar뿐이었는데, 실제로 새어 든 문서가 docs에도 있었던 걸 확인하고 범위를 넓혔다. 픽스처는 연락처 모양이 필요하므로 010-0000-0000·example.com 같은 자리표시자는 통과시킨다. 형태만 보고 전부 막으면 픽스처 자체를 못 쓰게 되고, 그러면 가드가 꺼진다.
패턴 목록도 처음엔 검사 코드 안에 박혀 있었다. 소스 가드와 빌드 가드가 각자 목록을 들고 있으면 한쪽에만 새 이름을 추가하고 잊어버리게 되므로, PERSONAL_DATA_PATTERNS·PLACEHOLDERS를 personalDataPatterns.ts 한 모듈로 뽑아 양쪽이 같은 것을 본다.
빌드 가드
소스가 깨끗해도 빌드 산출물에 남을 수 있다. vite.config.ts에 빌드가 끝난 뒤 산출물 폴더를 읽는 플러그인을 추가해, 텍스트 파일을 훑고 개인 자료가 남아 있으면 빌드를 세운다. 여기서 걸린 예외가 하나 있었다. 일부 라이브러리(rive)는 자기 package.json의 기여자 이메일을 번들에 그대로 싣는다. 이메일 패턴 자체를 끄면 진짜 유출도 같이 지나가므로, 명시적으로 나열한 라이브러리 소속 이메일만 통과시켰다.
문서까지
ARCHITECTURE.md에 실명이 박힌 절대 경로가 그대로 나가고 있었고, 계획 문서의 픽스처도 실제 이력서·공고 값을 담고 있었다. CLAUDE.md에도 이번 사고의 교훈을 규칙으로 못 박았다 — "프롬프트 예시에 사용자 자료를 쓰지 말 것", "실제 산출물에서 문제를 발견해 예시로 옮길 때 그 이력서의 프로젝트명·수치를 그대로 적으면 남의 자소서에 새어 든다."
픽스처 청소는 뒤이어 이어졌다
가드가 검사 범위를 넓힐 때마다 그 범위 안에서 걸리는 것들을 계속 찾아 고쳤다. pdftext.test.ts의 회사명 픽스처, compare-models.ts의 개인정보 픽스처가 뒤이어 지어낸 이름으로 바뀌었다. 한 번에 다 걸러내지 못했고, 가드 범위를 넓힐 때마다 새 위반이 나왔다. 처음부터 검사 범위를 src·sidecar보다 넓게 잡았다면 겪지 않을 일이었다.
CLAUDE.md의 계약을 lint 규칙으로 옮겼다
CLAUDE.md는 방대한 서술형 계약 문서인데, 그중 기계가 판정할 수 있는 것은 사람이 리뷰 때마다 다시 확인하는 대신 eslint가 매번 잡아주는 편이 낫다.
커스텀 규칙 넷을 처음 만들었다. 첫 규칙은 effect-cancel-flag-scope로, 취소 플래그(cancelled·mounted·alive 등)를 useEffect 콜백 밖에서 선언하면 에러를 낸다. 이 규칙의 배경은 실제로 겪은 회귀다. StrictMode의 마운트 → 언마운트 → 재마운트 사이클에서, effect 밖에 선언된 취소 플래그가 정리 함수로 false가 된 채 두 번째 마운트까지 남아 setState가 전부 무시됐다. 화면이 영원히 "확인 중"에 머무는 버그였다. const 선언은 정리 함수가 뒤집을 수 없으므로 검사에서 제외한다.
나머지 셋은 no-restricted-syntax 셀렉터로 구현했다.
| 규칙 | 막는 것 | 근거 |
|---|---|---|
| NO_CDN | jsdelivr/unpkg 등 외부 CDN 리터럴 | 우리가 tauri.conf.json에 설정한 connect-src 'self' CSP에 막힌다 |
| NO_PINNED_MODEL | claude-opus-4-... 같은 버전 핀 고정 | 모델은 별칭(opus/sonnet/haiku)으로만 지정, 목록은 sidecar/models.ts 소유 |
| NO_NATIVE_MAXIMIZE | toggleMaximize/setMaximized/setFullscreen 호출 | decorations:false 창에서 창 프레임을 다루는 Rust 라이브러리 tao의 set_maximized는 그리지 않고 프레임만 옮겨 화면이 튄다 |
주석에 "여기 있는 규칙은 모두 넣는 시점에 위반이 0건이었다 — 고칠 것을 만들지 않고 이미 지켜지는 것을 굳히는 자리다. 정당한 용례를 대량으로 때리는 규칙은 넣지 않는다. 시끄러운 가드는 결국 꺼진다"라고 스스로 원칙을 적어뒀다.
같은 25일 저녁에 하나를 더 추가했다. STORE_SELECTOR_REQUIRED는 useXxxStore를 인자 없이 부르면 에러를 낸다. 이 앱은 스트리밍 중 초당 여러 번 store를 갱신한다. 인자 없는 구독은 스토어 전체를 구독해서 안 쓰는 필드가 바뀌어도 다시 그리므로, 그 비용이 스트리밍 프레임마다 그대로 얹힌다. getState(멤버 표현식)와 subscribe는 이 정규식에 걸리지 않아 정상 동작한다. 이 규칙을 넣으면서 위반 자리(EchoView·NovaView·NyxView·PawsView·CharacterDock·ResumeBuilderView)를 먼저 selector 방식으로 고쳐 0건으로 만든 다음 규칙을 얹었다. 규칙을 넣기 전에 코드를 수렴시킨다는 원칙을 그대로 지킨 순서였다.
eslint-plugin-react도 같은 25일에 처음 붙였다. 버전 탐지(version: "detect")는 ESLint 10에서 없어진 context.getFilename을 불러 규칙 로딩 단계에서 죽으므로, 버전을 "19.2"로 직접 적어 그 경로를 피했다. JSX 규칙 중에는 no-danger·jsx-no-script-url처럼 사용자 자료가 그대로 들어오는 앱이라 위험한 패턴을 막는 것과, self-closing-comp처럼 모양을 하나로 고정하는 것을 골라 넣었다. 후자는 전부 --fix로 자동 정리됐다.
no-explicit-any도 warn에서 error로 올렸다. 남아 있던 위반 셋(pdfedit.ts·resumeHtml.ts·security.ts)은 타입 좁힘으로 먼저 대체했다.
결과 알림이 네 곳에서 각자 다른 모양이었다
CLAUDE.md에 이미 기록된 문제였다. ExportControls는 자체 하단 토스트, PawsView는 에이전트 아래 연두색 10px 글씨, ResumeBuilderView는 창 폭에 따라 헤더 인라인과 하단 토스트로 갈렸다. 같은 성공/실패 사건을 매번 다른 자리·다른 모양으로 찾아야 했고, 토스트 둘조차 위치(bottom-6 vs bottom-7)와 배경색, 닫기 버튼 유무가 어긋나 있었다.
Toast.tsx·useToast.ts로 통합했다. 핵심은 상태와 타이머를 컴포넌트가 아니라 모듈 레벨에 두는 것이다. 그 이유를 소스 주석에 남겼다.
/**
* 상태와 타이머는 toastStore가 앱 전체에 하나로 쥔다 — 예전에는 이 훅이
* 화면마다 자기 상태를 들고 있어서, <Activity>로 숨겼다 돌아오면 지나간
* 토스트가 다시 떴다.
*/App이 화면을 언마운트하지 않고 React <Activity>로 숨기기 때문이다. <Activity>는 React 19.2에 들어온 컴포넌트로, 자식을 숨긴 채 상태를 유지하고 다시 보일 때 effect를 재실행한다. 그래서 컴포넌트 레벨 상태는 숨김 → effect 정리(타이머 취소) → 복귀 → effect 재실행(타이머 재시작)을 거쳐, 지나간 토스트가 5초를 새로 사는 사고가 났었다. 타이머를 모듈 레벨에 두면 컴포넌트 수명과 끊어져 실제 흐른 시간대로 사라진다.
그리고 useToast는 toast 값 자체를 돌려주지 않는다. 그리는 곳은 App.tsx 한 군데뿐이라, 값을 돌려주면 각 화면이 또 Toast를 그려 같은 사건이 두 번 뜨게 된다. 지연도 하나(5초)로 통일했다. 예전엔 곳마다 setTimeout을 따로 걸어 4초와 5초로 갈려 있었다.
알림을 영속시키지 않는 것도 명시적인 선택이다. 예전에 자기소개서 결과가 usePersistentState에 담겨 있어서, 앱을 다시 켜면 지난번 성공 문구가 그대로 떠 있었다. 그 자리의 사건은 그 자리에서 끝나야 한다. showResult(result, onSuccess, failPrefix)처럼 API도 정리해, 호출부는 성공/실패 판정과 문구 조립을 반복하지 않고 결과 객체만 넘긴다.
팝오버·모달의 바깥 클릭 닫기를 두 훅으로 정리했다
LoginFlowModal·PdfPreviewModal·SkillSelector·UpdateModal이 각자 바깥 클릭 감지를 구현하고 있던 것을 useDismissOnOutsidePointer(팝오버용)와 useOverlayDismiss(모달 배경용) 두 훅으로 통합했다.
useDismissOnOutsidePointer는 여는 버튼까지 포함하는 요소를 ref로 받아야 한다는 제약이 있다. 버튼이 빠지면 열린 상태에서 버튼을 눌렀을 때 pointerdown이 먼저 닫고 이어지는 click이 다시 열어서, 사용자 눈에는 안 닫히는 것처럼 보인다.
useOverlayDismiss는 click 하나만 보지 않는다. 사용자가 모달 내용 안에서 텍스트를 드래그로 선택하다 배경 위에서 손을 떼면, click 이벤트는 공통 조상인 오버레이에서 발생해 눌러본 적 없는 배경 클릭으로 처리된다. 그래서 pointerdown이 배경에서 시작했는지를 기억해뒀다가, 시작 지점과 끝 지점이 둘 다 배경일 때만 닫는다.
flowchart TD
A["pointerdown 발생"] --> B{"배경에서 시작했나"}
B -->|아니오| C["무시"]
B -->|예| D{"click이 배경에서 끝났나"}
D -->|아니오| C
D -->|예| E["모달 닫음"]
면접 기록 PDF를 이력서와 같은 경로로 옮겼다
sidecar/pdf.ts의 좌표 기반 렌더는 이력서 PDF에서 이미 겪은 한계를 면접 기록에서도 그대로 반복하고 있었다. sidecar/interviewHtml.ts + templates/interview.html → htmlToPdf로 옮겨, exports.ts에서 좌표 렌더 코드 88줄이 줄었다. 빌드 스크립트에도 새 템플릿 파일을 복사하는 스텝을 추가했다. 안 하면 개발 모드에선 보이는데 패키징된 앱에서만 템플릿을 못 찾는 사고가 난다.
CLAUDE.md에 이미 적힌 서식 규칙이 그대로 적용됐다. 답변 하나를 통째로 break-inside: avoid로 묶지 않고 .field(라벨+본문)와 .meta(break-after: avoid)만 최소 단위로 묶는 것, 구분선을 밑줄이 아니라 .entry + .entry의 border-top으로 두는 것. 이력서 템플릿에서 이미 검증된 패턴을 재사용한 것이라 새로 겪은 시행착오는 없었다.
면접 답변의 정체성을 세션이 아니라 답변으로 바꿨다
interviewHistory.sessions의 한 항목은 질문지 한 벌이다. 질문지를 한 번 만들면 그게 한 세대가 된다. EchoView의 sessionId는 질문지를 새로 만들 때만 갱신되므로, 질문지를 안 바꾸면 몇 달치 답변이 한 세션에 쌓인다. 사용자가 인지하는 단위가 아니다.
이 문제를 두 겹으로 풀었다. 하나는 답변의 정체성을 answerId로 바꾼 것이다.
const isSameAnswer = (
item: InterviewAnswer,
answerId: string | undefined,
question: string,
) => (item.answerId ? item.answerId === answerId : item.question === question);질문 텍스트로 판정하면 같은 질문을 다시 답할 때마다 이전 기록이 덮여 사라져서, 되짚어보려던 답변이 다시 답할 때마다 없어지는 결과가 된다. answerId가 없는 옛 레코드만 질문 텍스트로 폴백한다. 마이그레이션은 넣지 않았다.
다른 하나는 세션 경계를 펴서 답변 단위 최신순 목록을 만드는 flattenAnswers를 별도 순수 함수로 뽑은 것이다. 이 함수는 "지난 답변" 패널과 면접 기록 PDF 양쪽 모두를 통과한다. 한쪽만 고치면 같은 자료가 두 순서로 보이는 사고가 나기 때문이다. 삭제(removeInterviewAnswerFromContext)는 두 가지를 함께 처리한다. 남은 답변으로 overallScore를 다시 계산하고, 마지막 답변을 지우면 세션 자체를 버린다. 점수를 다시 계산하지 않으면 화면과 PDF가 다른 평균을 말한다. 빈 세션을 남기면 PDF에 "기록된 질문이 없습니다"가 찍힌다. App.tsx의 빈 세션 정리는 앱 재시작에만 돌기 때문이다.
upsertAnswerInContext·removeInterviewAnswerFromContext·flattenAnswers를 store 밖의 순수 함수로 뽑은 이유는 둘이었다. agentStore.ts가 이미 700줄에 가까웠고, 무엇보다 "덮어쓸지 쌓을지, 지운 뒤 점수를 어떻게 다시 매길지" 같은 규칙은 store 없이 검증할 수 있어야 했다. removeResumeVersionFromContext가 먼저 이 길을 갔다.
자소서에 "무엇을 고민했나"를 요구하는 뼈대를 줬다
이번에 마주친 문제는 자소서 개선 작업 내내 반복된 패턴이었다. 지금까지 규칙이 전부 "쓰지 말 것"이었는데, 한쪽을 막을 때마다 모델이 반대쪽 극단으로 옮겨갔다. 기능 나열을 막으니 회사 일반론만 남았다. 금지만 있고 빈자리에 뭐가 와야 하는지 말하지 않으면, 모델은 아무 방향으로나 그 자리를 채운다.
그래서 "맡은 일 → 문제 → 고민 → 조치 → 결과(바뀌기 전 값과 함께)"를 뼈대로 준다. 가운데 "무엇을 고민했나"가 핵심이다. "A를 적용했습니다"는 누구나 쓸 수 있는 문장이고, "B도 후보였는데 ~라서 A로 갔습니다"는 실제로 판단한 사람만 쓸 수 있는 문장이다. 이 뼈대가 기존의 "기능 나열 금지", "일반론 시작 금지" 후반부를 대신하므로 그 규칙들은 걷어냈다.
같은 작업에서 프롬프트에 박혀 있던 특정 사용자의 프로젝트명·수치도 함께 지웠다. 앞서 다룬 개인정보 유출과 같은 뿌리다.
패키징된 빌드에서만 재현되던 사고 둘
두 버그 모두 원인이 같았다. 패키징된 앱에서는 process.env.HOME이 비어 있었다.
탐색기가 파일시스템 루트에서 시작했다
App.tsx가 앱을 켤 때마다 setRootDir("/")로 에이전트 작업 폴더를 최상위로 박고 있었다. 그래서 대화마다 scanWorkspace("/")가 파일시스템 전체를 훑어 최대 2000개 경로를 <workspace_files>로 실어 보냈고, "목록 밖은 읽지 말라"는 규칙까지 함께 붙어 에이전트가 이력서와 무관한 목록에 묶였다.
홈 폴백이 process.env.HOME에 기대고 있었던 게 원인이다. 개발 모드에서는 재현되지 않고 패키징된 앱에서만 탐색기가 /로, CLI 작업 폴더가 /tmp로 잡혔다. os.homedir()는 env가 비어 있으면 getpwuid를 대신 쓰므로, 보관함(archive.ts)이 이미 쓰던 것과 같은 기준으로 맞췄다.
캐릭터 애니메이션이 패키징 빌드에서만 안 보였다
CLAUDE.md에 이미 상세히 기록된 사고다(v0.2.1 dmg 증상). dotlottie-web과 rive가 기본적으로 WASM을 jsdelivr/unpkg에서 런타임에 내려받는데, 우리가 tauri.conf.json에 설정한 connect-src 'self' CSP에 그 요청이 막힌다.
이 수정을 적용했고, 같은 문제의 재발을 막기 위해 프론트엔드 번들에서 외부 WASM 경로를 검사하는 스크립트를 추가했다(check-frontend-bundle.mjs). eslint의 NO_CDN 규칙이 소스 레벨을, 이 스크립트가 빌드 산출물 레벨을 각각 담당한다.
동작한 적 없는 스킬 기능을 걷어냈다
캐릭터 스킬 선택 기능은 세 겹으로 죽어 있었다.
- 화면에서 여는 경로가 없었다.
setShowSkills(true)를 부르는 곳이 코드 어디에도 없었다. - 열렸어도 목록이 비었다.
listSkills가skills/안의.md파일을 찾는데, 실제 구조는 폴더마다SKILL.md였다. - 골랐어도 저장된 게 없었다.
character_skills테이블이 비어 있었다.
게다가 경로가 개발자 홈의 플러그인 캐시에 버전까지 박혀 있어서(superpowers/5.0.1), 살려도 그 기계에서만 도는 기능이었다. 목록에 뜰 스킬도 브레인스토밍·디버깅 같은 개발 도구용이라 애초에 취업준비생 대상 앱에 보여줄 게 없었다.
getSystemPrompt에서 skills 인자와 ## Active Skills 블록을 빼면서 scripts/compare-models.ts 호출부도 함께 고쳤다. 기존 설치본에 남는 빈 character_skills 테이블은 그대로 뒀다. 생성문만 지우고, 마이그레이션을 넣는 위험보다 빈 테이블 하나 남기는 쪽을 택했다. 문서에서도 엔드포인트 표의 GET /skills와 workspaceStore·db 설명을 실제로 남은 것에 맞춰 정리했다.
릴리스 노트와 업데이트 화면 손질
v0.2.5를 내보내면서 릴리스 노트 관련 손질을 함께 했다.
release-note:트레일러(커밋 본문 끝에키: 값형태로 붙이는 메타 줄)를 필수가 아니라 옵트인으로 전환했다.release-note: skip이면 노트에서 뺀다.- UpdateModal의 변경 내역 마크다운이 그대로 글자로 보이던 문제를 고쳤다.
###가 파싱 없이 그려지고 있었다.releaseNotes.ts가 제목·목록으로 파싱해 그리도록 분리했다. - 릴리스 노트 규약(사람이 커밋 본문에 적은 트레일러만 나간다, 커밋 제목은 절대 자동으로 쓰지 않는다)을
CLAUDE.md에 문서화했다.
구현 전에 루브릭 설계를 문서로 정리해뒀다
8월 26일에 한 일은 문서 하나다. Nyx 이력서 점수 루브릭의 스펙·계획 문서 1,052줄을 추가했다. 구현은 아직 시작하지 않았고 코드도 없다. 다음 작업의 설계를 먼저 문서로 정리해둔 상태다.
좋은 예시를 만들려는 노력이 유출 경로가 됐다
이번 기간에서 가장 무거웠던 건 개인정보 유출이었다. 문제를 고치려고 실제 산출물을 보고 그 자리에서 예시로 옮겨 적은 문장이, 정작 그 예시를 통해 모든 사용자에게 다시 새어 나가는 구조였다. 좋은 예시를 만들려는 노력 자체가 유출 경로가 될 수 있다는 걸 직접 겪었다. 가드를 세 겹으로 세운 것도 그래서다. 한 곳만 막으면 다른 경로로 또 샌다.
CLAUDE.md를 lint 규칙으로 옮기는 작업도 같은 맥락이다. 서술형 문서는 사람이 매번 다시 읽어야 지켜지고, 사람은 잊는다. 코드가 이미 수렴한 지점을 규칙으로 고정하면 그 뒤로는 잊을 걱정이 없다. 다만 "위반 0건일 때만 규칙을 넣는다"는 순서는 지켰다. 규칙 먼저 넣고 위반을 나중에 치우는 순서였다면 매번 급하게 리팩터링을 밀어넣었을 텐데, 반대로 갔다.
토스트·바깥 클릭 닫기·스킬 기능 제거는 결이 비슷하다. 화면마다 따로 구현된 것, 세 겹으로 죽어 있던 기능. 전부 누군가 한 번은 만들었는데 아무도 전체를 보지 않은 상태에서 나온 문제였다.