한 줄로
카드 내역 분석을 Claude에게 맡겼지만 거래 원문은 보내지 않았다. 집계값만 보냈다. 보내기 직전에 사람 이름과 긴 숫자를 가리고 금지 표식 검사를 한 번 더 했다. Claude가 답에 쓴 숫자는 항목마다 붙인 이름표로 실제 집계값과 대조했다. 막상 리뷰에서 가장 큰 구멍으로 드러난 것은 프롬프트가 아니었다. 하위 프로세스에 딸려 가던 환경변수였다.
배경: 데이터를 맥 밖으로 내보내지 않는 레포
카드 내역 웹사이트(card-analysis)는 맥미니 안에서 도는 개인용 사이트다. 실제 데이터는 1,602건이다. 레포 규칙에는 “데이터는 맥 밖으로 내보내지 않는다”가 있다. 그래서 외부에서 접속하는 방법을 고를 때도 Cloudflare Tunnel은 쓰지 않았다. 카드 데이터가 Cloudflare 서버를 지나가게 되기 때문이다.
그런데 Claude 분석 리포트(/report)는 하는 일 자체가 데이터 일부를 밖으로 보내는 것이다. 그래서 이 기능은 규칙의 예외로 다뤘다. CLAUDE.md의 ‘밖으로 나가는 경로’ 항목에 예외로 적어 두었다. 예외를 만들었으니 다음 일은 무엇이 나가는지를 최대한 좁히는 것이었다. 이 글은 그 과정을 정리한 것이다.
구조: 구독 계정의 claude -p를 하위 프로세스로 부른다
web/llm.py는 구독 계정의 claude -p를 하위 프로세스(웹 서버가 직접 띄우는 별도 프로그램)로 부른다. 웹 서버가 집계를 만들어 프롬프트로 넘기고, 돌아온 응답을 검증한 뒤 화면에 보여 준다.
이 경로에서 막아야 할 것은 두 가지로 나뉜다.
- 프롬프트에 실리는 것: 카드 데이터 자체
- 프로세스에 딸려 가는 것: 환경변수, 사용자 훅, MCP
처음 구현은 앞의 것만 신경 썼고, 뒤의 것은 리뷰에서 드러났다.
1단계: 원문 대신 집계만 보낸다
보내는 것은 아래 집계뿐이다.
- 기간·월·카테고리별 합계
- 상위 가맹점 15개
- 이상 지출
- 고정지출
- 수입과 저축률
거래 원문, 카드번호, 승인번호, 메모는 보내지 않는다. 분석에 필요한 것은 ‘어디에 얼마를 썼는가’의 모양이고, 거래 한 건 한 건의 식별 정보는 필요 없다.
2단계: 사람 이름을 가린다
집계에도 사람 이름이 섞일 수 있다. 상위 가맹점 목록에 개인 이체 상대가 끼는 경우가 그렇다. 그래서 두 가지 규칙을 둔다.
- 개인 이체 상대는
(개인 이체)로 바꾼다. - 한글 2~4자로 된 사람 이름 꼴이면 카테고리와 상관없이 가린다.
두 번째 규칙은 카테고리 분류를 믿지 않는다. 개인 이체로 분류되지 않은 이름이라도 모양이 사람 이름이면 가린다.
3단계: 긴 숫자를 가린다
6자리 이상 숫자는 #으로 바꾼다. 카드번호나 승인번호 같은 식별 번호가 가맹점명 같은 문자열 속에 섞여 나가는 일을 막기 위해서다.
4단계: 보내기 직전에 한 번 더 검사한다
가리기를 마친 프롬프트도 그대로 믿지 않는다. 보내기 직전에 .sensitive-markers로 한 번 더 검사한다. 여기에 걸리면 보내지 않는다. 가리기 규칙에 빈틈이 있으면 리포트가 만들어지지 않는 쪽으로 실패하게 한 것이다. 민감한 문자열이 새는 쪽으로 실패하지 않게 했다.
5단계: 프로세스에 딸려 가는 것을 끊는다 — 리뷰에서 나온 구멍
리뷰에서 나온 문제는 프롬프트 내용과 상관이 없었다. 하위 프로세스는 따로 지정하지 않으면 부모 프로세스의 환경변수를 그대로 물려받는다. 이 웹 서버의 환경에는 .env가 로드돼 있었다. 그 결과 .env의 비밀값 73개가 Claude 프로세스로 넘어가고 있었다.
고친 내용은 세 가지다.
- Claude 프로세스에는 HOME·PATH·LANG과 Claude 토큰만 넘긴다.
- 사용자 훅과 MCP는 끈 채 실행한다.
- 이 경로를 CLAUDE.md의 ‘밖으로 나가는 경로’에 예외로 적었다.
1~4단계는 데이터가 새지 않게 막는 장치였다. 그런데 정작 비밀값은 데이터 경로가 아니라 실행 환경을 타고 넘어가고 있었다. 프롬프트만 검사해서는 이 구멍을 찾을 수 없다.
응답 쪽: Claude가 쓴 숫자를 실제 집계값과 대조한다
보내는 쪽만큼 받는 쪽도 문제다. 리포트에는 ‘전월 대비 몇 % 늘었다’ 같은 숫자가 들어간다. 이 숫자가 틀리면 리포트를 쓸 수 없다.
그래서 집계 항목마다 month.2026-09.total 같은 이름표를 붙여 보낸다. 응답에서 근거로 쓴 숫자를 원·%·건 단위별로 나누고, 이름표가 가리키는 실제 집계값과 하나씩 대조한다.
9월 리포트 실측
- 9월 지출은 3,557,409원으로 전월 대비 +102.3%였다.
- 응답에 나온 숫자 근거 37개가 모두 검증을 통과했다.
- 테스트는 백엔드 80개, E2E 5개에서 6개로 늘어난 상태로 모두 통과했다.
정리
| 경로 | 막는 방법 |
|---|---|
| 프롬프트 내용 | 집계만 보내기, 이름·긴 숫자 가리기, .sensitive-markers 최종 검사 |
| 프로세스 환경 | HOME·PATH·LANG·Claude 토큰만 전달, 사용자 훅·MCP 끄기 |
| 응답 숫자 | 이름표(month.2026-09.total)로 단위별 대조 |
LLM을 하위 프로세스로 부를 때는 프롬프트에 무엇을 실었는지와 함께 그 프로세스가 무엇을 물려받는지도 봐야 한다. 이번 작업에서 실제로 새고 있던 것은 뒤쪽이었다.