Claude Code는 파일을 읽거나 명령을 실행하면 그 결과를 Anthropic 서버의 모델에게 보냅니다. 고객 명단이나 메일을 읽게 하면 그 안의 이메일과 전화번호도 함께 갑니다. 이것을 막는 방법은 어렵지 않습니다. Claude Code에 "개인정보를 미리 가리는 훅을 만들어 줘"라고 부탁하면 됩니다. 훅(hook)은 Claude Code가 정해진 순간마다 부르는 작은 프로그램이고, 결과가 서버로 가기 전에 내 PC에서 먼저 손볼 수 있습니다.
- 요청문 하나로 만들어졌습니다. 아래 요청문을 그대로 주자 Claude가 3분 41초 만에 훅을 만들고 가짜 값으로 시험까지 했습니다.
- 켜고 나니 원래 값이 하나도 넘어가지 않았습니다. 가짜 명단을 읽게 했을 때 훅이 없으면 이메일·전화번호·주민번호 7개가 그대로 갔고, 훅을 켜면 0개였습니다.
- 메시지도 보내기 전에 막습니다. 채팅창에 전화번호를 넣어 보내면 훅이 멈추고 알려 줍니다.
- 느려지지 않습니다. 훅 한 번에 약 54ms(0.05초)가 걸렸습니다.
- 못 가리는 것도 있습니다. Claude Code가 스스로 넣는 계정 이메일, 이미지 속 글자, 연결 도구(MCP)로 가져온 내용은 훅이 보지 못합니다.
시험은 모두 실제 사람이 아닌 가짜 값으로 했습니다. 이 블로그의 윈도우 PC에서 Claude Code 2.1.316으로 직접 돌렸고, 이 블로그는 10월 25일부터 같은 방식의 훅을 실제로 쓰고 있습니다.
이 글의 내용을 20초로 요약한 영상입니다(소리 없음).

시험용 가짜 명단으로 확인한 결과입니다.
이렇게 부탁하세요
Claude 데스크톱 앱의 Code 탭이나 터미널의 Claude Code 입력창에 아래 문장을 그대로 붙여 넣으면 됩니다.
개인정보 보호 훅을 만들어 줘. 내가 너에게 파일을 읽히거나 명령을 실행시키면,
그 결과가 너에게 가기 전에 내 PC에서 이메일, 휴대전화 번호, 주민등록번호를
먼저 찾아서 가려 줘. 가린 자리는 '[이메일_해시6자리]' 같은 표시로 바꾸고,
같은 값은 늘 같은 표시가 되게 해 줘. 내가 보내는 메시지에 개인정보가 있으면
보내기 전에 막아 줘. 무엇을 가렸는지는 내 화면에 알려 주고, 다 만들면
가짜 값으로 시험해서 보여 줘.
그다음은 Claude가 알아서 합니다. 해 주실 일은 세 가지입니다.
- 허락을 누릅니다. Claude가 훅 파일을 만들고 설정 파일(
settings.json)을 고쳐도 되는지 물으면 내용을 보고 허락합니다. 설정 파일은 Claude Code가 함부로 고치지 못하게 막혀 있어서, 이 단계는 꼭 사람이 확인합니다. - Claude Code를 다시 켭니다. 훅 설정은 새로 시작할 때 읽힙니다. 데스크톱 앱은 새 대화를 열면 됩니다.
- 가짜 값으로 확인합니다. "test@example.com, 010-0000-1111 이 적힌 시험 파일을 만들고 읽어 봐"처럼 시켜서, 아래 화면처럼 가렸다는 알림이 뜨는지 봅니다.
필요하면 요청문 끝에 이런 말을 더 붙이세요.
| 이런 게 필요하면 | 요청문에 붙일 말 |
|---|---|
| 내 이메일과 번호는 가리지 않기 | "내 이메일과 번호는 가리지 않게 허용 목록을 만들어 줘" |
| 어느 회사 메일인지는 보이게 | "이메일은 @ 뒤 도메인은 남겨 줘" |
| 원본 파일이 망가지지 않게 | "가린 표시가 파일이나 명령에 다시 쓰이면 막아 줘" |
| 일할 때 잠깐 끄기 | "잠깐 끄고 켜는 방법도 알려 줘" |
| 특정 폴더에서만 쓰기 | "이 폴더에만 적용해 줘" |
이 표가 뜻하는 것: 처음에는 기본 요청문만으로 충분하고, 쓰다가 불편한 점이 생기면 그때 한 줄씩 더 부탁하면 됩니다. 이 블로그도 그렇게 고쳐 왔습니다.
요청문 하나로 정말 만들어질까
글에 실을 요청문이 정말 동작하는지 빈 폴더에서 시험했습니다. 위 요청문에 "이 폴더에만 적용해 줘"만 붙여 Claude Code에 주었습니다. Claude는 3분 41초 동안 도구를 17번 쓰며 훅 파일(325줄)과 시험 파일을 만들고, 가짜 값으로 시험한 결과를 보여 줬습니다. 주문 번호나 날짜처럼 개인정보가 아닌 숫자는 그대로 두는 것도 스스로 확인했습니다.
이 시험은 묻지 않고 실행하는 방식(claude -p)이라 설정 파일 쓰기가 허락을 받지 못해 막혔습니다. Claude는 "설정 파일 쓰기가 막혀서 아직 켜지지 않았다"고 정확히 알려 주고, 넣을 설정을 따로 만들어 뒀습니다. 그 설정을 넣고 다시 시험한 결과입니다.
| 시험 | 결과 |
|---|---|
| 가짜 명단을 Read로 읽기 | 원래 값 0개(모두 표시로 바뀜) |
| 가짜 명단을 명령(cat)으로 보기 | 원래 값 0개 |
| 전화번호가 든 메시지 보내기 | 보내기 전에 막힘 |
이 표가 뜻하는 것: 기본 요청문만으로도 쓸 만한 훅이 나왔습니다. 다만 Claude가 만들 때마다 방식과 안내 문구는 조금씩 다릅니다. 이번 훅은 파일을 읽을 때 가린 사본을 대신 읽게 하는 방식이었고, 이 블로그가 쓰는 훅은 결과를 바꿔서 넘기는 방식입니다. 그래서 만든 뒤에 꼭 가짜 값으로 확인해야 합니다.
켜지면 화면에 이렇게 보인다
아래 화면들은 이 블로그가 만든 훅의 화면입니다. 요청문으로 만든 훅도 비슷한 알림을 띄웁니다.
데스크톱 앱(Code 탭). 파일을 읽은 도구 칸에 "수신 알림 1개"가 붙습니다. 눌러 펼치면 가려진 결과와 함께 무엇을 가렸는지 나옵니다. 앞뒤 일부만 남긴 값은 내 화면에만 나오고 모델에게는 가지 않습니다.

Claude 데스크톱 앱 화면입니다. 이 PC 에서 실제로 쓰는 훅이라 문구가 조금 다릅니다.
터미널(TUI). 파일을 읽으면 결과 아래에 훅이 남긴 알림이 보입니다. 모델은 표시만 받았기 때문에 "원래 값을 알려 드릴 수 없다"고 답합니다.

Claude Code 2.1.316 터미널 화면입니다. 요금제 이름은 흐리게 했습니다.
메시지에 전화번호를 넣어 보내면, 보내기 전에 막히고 이유가 나옵니다. 입력한 글은 입력창에 그대로 남아 있어서 지우고 다시 보낼 수 있습니다.

가짜 번호로 시험한 화면입니다.
훅이 없을 때와 있을 때
같은 가짜 명단을 넣은 폴더 두 개를 만들고 한쪽에만 훅을 걸어, 같은 일을 시켰습니다. 모델이 실제로 받은 내용은 Claude Code가 남기는 대화 기록 파일에서 확인했습니다.

claude -p, Claude Code 2.1.316. 값은 모두 가짜입니다.

대화 기록에 남은 도구 결과를 그대로 옮겼습니다.
Claude의 답도 달라졌습니다. 훅이 없는 폴더에서는 이메일과 전화번호를 그대로 읊었고, 훅이 있는 폴더에서는 가린 표시로만 보여서 알려 드릴 수 없다고 답했습니다.

훅이 없는 폴더입니다. 값은 가짜입니다.

훅을 켠 폴더입니다.
훅은 세 군데에 걸립니다. 따로 외울 필요는 없지만, 무엇을 하는지 알아 두면 Claude가 만든 결과를 확인하기 쉽습니다.

Claude Code 훅 문서의 이벤트 이름을 그대로 썼습니다.
이 블로그는 이렇게 만들었다
이 블로그의 훅은 10월 25일, 메일을 정리하던 대화에서 만들었습니다. 그날도 파일을 직접 만들지 않고 Claude에게 말로 부탁했습니다. 순서는 이랬습니다.
- "PC에서 개인정보가 들어 있는지 너한테 보내기 전에 검사하고, 그걸 치환해서 할 수 있게 하자"
- 사람을 특정할 수 있는 정보 전체가 아니라, 전화번호·이메일·주민등록번호 같은 개인정보만 가리기로 정했습니다. 이름까지 가리면 일을 할 수 없어서입니다.
- 가짜 개인정보를 넣은 시험 파일을 채팅창에 붙여서 막히는지 확인했습니다. 이때 붙인 파일도 보내기 전에 검사해야 한다는 것을 알게 됐습니다.
- "이 이메일과 전화번호는 내 것이니 통과시키고, 어떤 내용이 차단됐는지 화면에 나오면 좋겠다"
처음부터 완벽하게 부탁할 필요는 없습니다. 쓰면서 불편한 점을 그때그때 말하면 Claude가 고쳐 줍니다.
써 보며 알게 된 것
훅이 없으면 메시지 속 번호가 기억 파일에까지 남았습니다. 훅이 없는 폴더에서 "내 연락처는 010-0000-4444야, 기억해 줘"라고 보내자, Claude Code가 이 번호를 기억 파일에 적었습니다. 기억 파일은 다음 대화에도 다시 모델에게 들어갑니다. 훅을 켠 폴더에서는 메시지 자체가 가지 않았습니다.
모델은 가린 값을 볼 수 없으니, 결과는 직접 확인해야 합니다. "1번 사람의 이메일을 copy.txt에 써 달라"고 하자, 모델은 PowerShell로 원본에서 이메일을 뽑아 저장했고 확인까지 했다고 답했습니다. 그런데 실제로는 copy.txt가 없고, 이메일 이름의 파일 안에 "copy.txt"라는 글자가 들어 있었습니다. 가린 값을 볼 수 없으니 결과를 제대로 확인하지 못한 것입니다.
이 글을 쓰는 동안에도 훅이 두 번 막았습니다. 이 PC의 훅은 "가린 표시"가 파일에 쓰이는 것을 막습니다. 글에 쓸 그림 코드에 시험 결과의 표시를 그대로 적었다가 두 번 다 막혔습니다. 귀찮지만, 표시가 원본 문서에 섞여 들어가는 일은 이렇게 막힙니다.
꼭 필요할 때는 잠깐 끕니다. 연락처를 문서에 그대로 넣어야 하는 일에서는 훅을 잠깐 끄고, 끝나면 다시 켰습니다. 끄고 켜는 방법도 Claude에게 물어보면 알려 줍니다.
이 PC에서 12일 동안 써 보니
10월 25일부터 11월 6일 저녁(이번 시험 전)까지 이 PC의 훅이 남긴 기록을 셌습니다. 기록에는 값이 아니라 종류와 건수만 남습니다.
| 종류 | 건수 |
|---|---|
| 훅이 일한 횟수 | 185번(도구 결과 가리기 153번, 메시지 막기 24번, 표시 되쓰기 막기 4번) |
| 가린 이메일 | 892건 |
| 가린 전화번호 | 565건 |
| 그 밖에(주민·외국인등록번호, 카드·계좌번호 등) | 27건 |
이 표가 뜻하는 것: 메일 정리처럼 연락처가 많은 일을 맡기면 이메일과 전화번호가 수백 건씩 지나갑니다. 다만 이 숫자에는 개인정보가 아닌데 모양만 비슷해서 가린 것(주문 번호 같은 숫자)도 섞여 있습니다.
걸리는 시간도 재 봤습니다. 훅 한 번에 개인정보가 없는 결과는 52.4ms, 가짜 개인정보 400개가 든 결과는 53.9ms였습니다. 그중 25.6ms는 파이썬을 띄우는 시간입니다. 모델이 답을 만드는 데 몇 초씩 걸리는 것에 비하면 느끼기 어렵습니다.
훅이 못 가리는 것

훅은 읽은 내용을 가리는 것이고, 모든 개인정보를 막는 것은 아닙니다.
- Claude Code가 처음부터 넣는 정보: 141번 글에서 본 것처럼 Claude Code는 로그인 계정 이메일을 모델에게 줍니다. 이 값은 도구 결과가 아니라서 훅이 볼 수 없습니다.
- 이미지와 PDF: 스크린샷이나 스캔한 PDF 속 글자는 그림으로 넘어가서 가릴 수 없습니다.
- 연결 도구(MCP)와 WebFetch: Gmail, Notion 같은 연결 도구로 가져온 내용은 가리지 못하는 경우가 많습니다. 필요하면 Claude에게 그 도구도 넣어 달라고 하고, 따로 시험해야 합니다.
- 모양이 정해지지 않은 정보: 이름, 주소, 건강 정보는 가리지 않습니다.
- 보안 장치가 아니라 실수 막기입니다. 훅을 끄거나 설정을 바꾸면 그대로 지나갑니다. 회사에서 개인정보를 다룬다면 회사의 지침을 먼저 따라야 합니다.
이 글의 한계
- 시험은 윈도우 11, Claude Code 2.1.316에서만 했습니다. macOS·리눅스에서는 돌려 보지 않았습니다.
- 요청문 시험은 한 번만 했습니다. Claude가 만들 때마다 결과가 조금씩 다를 수 있습니다.
- 가리는 모양은 한국 휴대전화와 주민등록번호 형식 위주입니다. 지역번호 전화(02-…)와 외국 번호는 요청문에 따로 넣어야 합니다.
- 훅이 바꾼 결과가 서버로 간다는 것은 Claude Code 문서와 대화 기록 파일로 확인했습니다. 서버에 도착한 내용을 직접 본 것은 아닙니다.
- 이 글은 2026년 11월 6일 기준입니다.
함께 읽으면 좋은 글: Claude Code가 내 이메일을 웹사이트에 보냈을까, Claude Code 확장 2.1.313 변조 사건과 윈도우 점검, AI에게 내 브라우저를 빌려주면 네이버도 열릴까.
자주 묻는 질문
코딩을 몰라도 만들 수 있나요?
네. 요청문을 붙여 넣고, Claude가 설정을 고쳐도 되는지 물을 때 허락만 누르면 됩니다. 컴퓨터에 파이썬이 없으면 Claude가 설치가 필요하다고 알려 줍니다.
Claude 웹(claude.ai) 채팅에서도 되나요?
안 됩니다. 훅은 Claude Code(데스크톱 앱의 Code 탭, 터미널의 claude) 기능입니다.
훅을 켜면 Claude가 일을 못 하지 않나요?
연락처를 다루는 일에는 불편할 수 있습니다. 내 이메일은 허용 목록에 넣거나, 꼭 필요할 때만 잠깐 끄면 됩니다.
가린 표시를 다시 원래 값으로 바꿀 수 있나요?
표시만으로는 되돌릴 수 없습니다. 원래 값은 내 PC의 원본 파일에만 있습니다. 같은 값은 같은 표시가 되니, 모델은 "같은 사람"인지만 알 수 있습니다.
참고: 이 블로그가 시험에 쓴 코드
직접 보고 싶은 분을 위해, 이번 시험에서 비교용으로 쓴 짧은 판(116줄)을 붙여 둡니다. 따라 하기에는 위의 요청문으로 충분하고, 이 코드를 직접 설치할 필요는 없습니다.
pii_hook.py 펼쳐 보기"""Claude Code 훅: 읽은 내용 속 개인정보(이메일, 휴대전화 번호, 주민등록번호)를 PC 에서 먼저 가린다.
Claude Code 는 파일 내용이나 명령 결과를 모델(Anthropic 서버)에 보내기 전에 이 훅을 부른다.
훅이 바꾼 내용이 대신 넘어가므로, 원래 값은 PC 밖으로 나가지 않는다.
python pii_hook.py post PostToolUse: 도구 결과 속 개인정보를 [이메일_xxxxxx] 모양의 표시로 바꾼다(x 는 해시 6자리)
python pii_hook.py pre PreToolUse: 가린 표시가 파일이나 명령에 다시 쓰이면 막는다(원본이 망가지지 않게)
python pii_hook.py prompt UserPromptSubmit: 내가 보내는 메시지에 개인정보가 있으면 보내지 않는다
python pii_hook.py scan 시험용: 표준 입력의 글을 가려서 보여 준다
- 같은 값은 늘 같은 표시가 된다(이 PC 에만 있는 열쇠 secret.key 로 만든 해시). 그래서 Claude 가 "같은 사람"인지는 알 수 있다
- 이메일은 앞부분만 가리고 도메인은 남긴다. 어느 회사 메일인지는 일에 필요할 때가 많다
- 가리지 않을 내 이메일·번호는 같은 폴더의 allow.txt 에 한 줄에 하나씩 적는다
- 잠깐 끄려면 같은 폴더에 DISABLED 라는 빈 파일을 만든다
- 메시지 맨 앞에 #pii-ok 를 붙이면 그 메시지는 검사하지 않고 보낸다
가리지 못하는 것: 이미지·PDF 속 글자, 이름·주소처럼 모양이 정해지지 않은 정보, 연결 도구(MCP)·WebFetch 가 가져온 내용,
그리고 Claude Code 가 스스로 모델에게 주는 정보(로그인 계정 이메일 등).
"""
import hashlib, hmac, json, re, secrets, sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
KEY_FILE, ALLOW_FILE, DISABLED = HERE / "secret.key", HERE / "allow.txt", HERE / "DISABLED"
EMAIL = re.compile(r"\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})\b")
PHONE = re.compile(r"(?<!\d)(?:\+82[ -]?|0)1[016789][ -.]?\d{3,4}[ -.]?\d{4}(?!\d)")
RRN = re.compile(r"(?<!\d)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[ -]?[1-8]\d{6}(?!\d)") # 주민·외국인등록번호
MARK = re.compile(r"\[(?:이메일|전화번호|주민번호)_[0-9a-f]{6}\]")
def key():
if not KEY_FILE.exists():
KEY_FILE.write_text(secrets.token_hex(16), encoding="utf-8")
return KEY_FILE.read_text(encoding="utf-8").strip().encode()
def digits(s):
d = re.sub(r"\D", "", s)
return "0" + d[2:] if d.startswith("82") else d
ALLOW = set()
if ALLOW_FILE.exists():
for line in ALLOW_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip().lower()
if line and not line.startswith("#"):
ALLOW.add(digits(line) if re.fullmatch(r"[\d +.-]+", line) else line)
class Masker:
def __init__(self):
self.k, self.counts = key(), {}
def tag(self, kind, value):
self.counts[kind] = self.counts.get(kind, 0) + 1
return f"[{kind}_{hmac.new(self.k, value.encode(), hashlib.sha256).hexdigest()[:6]}]"
def text(self, s):
s = EMAIL.sub(lambda m: m.group() if m.group().lower() in ALLOW else self.tag("이메일", m.group().lower()) + "@" + m.group(2), s)
s = PHONE.sub(lambda m: m.group() if digits(m.group()) in ALLOW else self.tag("전화번호", digits(m.group())), s)
return RRN.sub(lambda m: self.tag("주민번호", digits(m.group())), s)
def walk(self, v):
# 도구 결과는 글자, 목록, 사전이 섞여 있다. 안쪽 글자까지 모두 가린다
if isinstance(v, str):
return self.text(v)
if isinstance(v, list):
return [self.walk(x) for x in v]
if isinstance(v, dict):
return {k: (x if k in ("base64", "file_path", "filePath") else self.walk(x)) for k, x in v.items()}
return v
def summary(counts):
return ", ".join(f"{k} {n}건" for k, n in counts.items())
def out(obj):
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode("utf-8"))
def post(data):
m = Masker()
masked = m.walk(data.get("tool_response"))
if not m.counts:
return
out({"systemMessage": f"개인정보를 가렸습니다: {summary(m.counts)}", # 내 화면에만 나온다
"hookSpecificOutput": {"hookEventName": "PostToolUse", "updatedToolOutput": masked,
"additionalContext": "이 도구 결과의 개인정보는 [종류_해시] 표시로 가려져 있다. 이 표시를 파일이나 명령에 쓰지 마라."}})
def pre(data):
if MARK.search(json.dumps(data.get("tool_input", {}), ensure_ascii=False)):
out({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": "가린 표시가 들어 있다. 원래 값을 모르니 이대로 쓰면 원본이 망가진다. 그 부분을 빼고 다시 하라."}})
def prompt(data):
p = data.get("prompt", "")
if p.lstrip().startswith("#pii-ok"):
return
m = Masker()
m.text(p)
if m.counts:
out({"decision": "block", "reason": f"메시지에 개인정보로 보이는 값이 있어 보내지 않았습니다({summary(m.counts)}). "
"지우고 다시 보내거나, 꼭 보내야 하면 맨 앞에 #pii-ok 를 붙이세요."})
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else ""
raw = sys.stdin.buffer.read().decode("utf-8", errors="replace")
if mode == "scan":
m = Masker()
print(m.text(raw), "\n#", summary(m.counts) or "없음")
elif not DISABLED.exists():
{"post": post, "pre": pre, "prompt": prompt}[mode](json.loads(raw))
출처
- Claude Code 문서: Hooks reference (PostToolUse, PreToolUse, UserPromptSubmit, systemMessage)
- 이 글의 시험 코드와 결과: 이 블로그가 2026년 11월 6일 직접 시험(가짜 값)
댓글 0개