<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>k_0joon.log</title>
        <link>https://velog.io/</link>
        <description>개발의 신이 될거다</description>
        <lastBuildDate>Wed, 15 Jul 2026 12:39:37 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>k_0joon.log</title>
            <url>https://velog.velcdn.com/images/k_joon_/profile/3a240d33-1a23-4f75-9dd5-1c7b46e605f9/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. k_0joon.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/k_joon_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[하네스 설계 (2) — 모든 결정의 "왜"]]></title>
            <link>https://velog.io/@k_joon_/%ED%95%98%EB%84%A4%EC%8A%A4-%EC%84%A4%EA%B3%84-2-%EB%AA%A8%EB%93%A0-%EA%B2%B0%EC%A0%95%EC%9D%98-%EC%99%9C</link>
            <guid>https://velog.io/@k_joon_/%ED%95%98%EB%84%A4%EC%8A%A4-%EC%84%A4%EA%B3%84-2-%EB%AA%A8%EB%93%A0-%EA%B2%B0%EC%A0%95%EC%9D%98-%EC%99%9C</guid>
            <pubDate>Wed, 15 Jul 2026 12:39:37 GMT</pubDate>
            <description><![CDATA[<p>이전 글에서는 개인 하네스가 <strong>무엇을</strong> 하는지 정리했습니다. 이번 글에서는 그 결정들이 <strong>왜</strong> 그렇게 내려졌는지 작성합니다.</p>
<p>&quot;블랙리스트로 간다&quot;, &quot;토큰은 한 턴만 유효하다&quot;, &quot;보호 브랜치는 토큰보다 상위다&quot; — 전부 결론입니다. 하지만 정작 중요한 건 &quot;안 그러면 어떻게 되는데?&quot;에 대한 답이고, 그건 v1.1부터 v1.7까지 직접 깨져보면서 얻은 것들이에요.</p>
<p>이 글은 그 과정을 순서대로 되짚어 봅니다.</p>
<hr>
<h2 id="1-전제-하네스는-왜-성립할까요">1. 전제: 하네스는 왜 성립할까요</h2>
<h3 id="11-클로드의-머리와-손은-각각-다른-컴퓨터에-있습니다">1.1 클로드의 머리와 손은 각각 다른 컴퓨터에 있습니다</h3>
<p>터미널에 <code>claude</code>를 치면 내 컴퓨터에 프로그램 하나가 뜹니다. 이게 Claude Code예요. 내 노트북에서 도는 프로그램이라 파일을 읽고 쓸 수 있죠.</p>
<p>그런데 이 프로그램은 &quot;덧셈 코드 만들어줘&quot;가 무슨 뜻인지 모릅니다. 그냥 프로그램이니까요. 그래서 인터넷 너머 Anthropic 서버의 Claude에게 물어봅니다.</p>
<p>여기서 결정적인 사실이 나옵니다. Claude는 내 컴퓨터에 없습니다. 내 프로젝트 폴더를 볼 수도, 파일을 쓸 수도 없어요.</p>
<table>
<thead>
<tr>
<th></th>
<th>어디 있나</th>
<th>이해하나</th>
<th>손이 있나</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Claude Code</strong> (프로그램)</td>
<td>내 컴퓨터</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td><strong>Claude</strong> (모델)</td>
<td>인터넷 너머 서버</td>
<td>✅</td>
<td>❌</td>
</tr>
</tbody></table>
<p>클로드의 머리와 손은 물리적으로 분리되어 있습니다. 하네스의 모든 것이 이 사실 하나에서 출발해요.</p>
<h3 id="12-도구-목록은-손을-빌려주는-계약입니다">1.2 도구 목록은 손을 빌려주는 계약입니다</h3>
<p>머리와 손이 떨어져 있으니, 손이 있는 쪽이 계약을 제안합니다. Claude Code는 매 요청마다 이런 쪽지를 붙여서 보내요.</p>
<blockquote>
<p>&quot;나는 손이 있어. 네가 못 하는 걸 대신 해줄게. 해줄 수 있는 건 이것들이야.
<code>Read(file_path)</code>, <code>Write(file_path, content)</code>, <code>Edit(file_path, old, new)</code>, <code>Bash(command)</code>
필요하면 이름이랑 값을 정확히 적어서 말해.&quot;</p>
</blockquote>
<p>이게 도구 목록입니다. Claude는 파일을 쓰고 싶어도 못 씁니다. 대신 &quot;Write 좀 해줘, file_path는 add.py, content는 이거&quot;라고 말할 수는 있죠. 말하는 건 텍스트니까 손이 없어도 되니까요.</p>
<h3 id="13-tool_use는-행동이-아니라-서류입니다">1.3 tool_use는 행동이 아니라 서류입니다</h3>
<p>&quot;모델이 Write 도구를 호출한다&quot;는 표현은 반쯤 비유예요. 모델이 실제로 만들어내는 건 이런 데이터입니다.</p>
<pre><code class="language-json">{
  &quot;type&quot;: &quot;tool_use&quot;,
  &quot;name&quot;: &quot;Write&quot;,
  &quot;input&quot;: { &quot;file_path&quot;: &quot;add.py&quot;, &quot;content&quot;: &quot;def add(a, b): ...&quot; }
}</code></pre>
<p>행동이 아니라 신청서죠. 모델이 할 수 있는 건 여기까지고, 이 서류를 읽고 실제로 디스크에 쓰는 건 Claude Code입니다.</p>
<p>그래서 서류가 접수되고 처리되기까지의 사이가 물리적으로 존재합니다. 하네스는 그 틈에 들어갑니다. 만약 모델이 직접 파일을 썼다면 끼어들 자리가 없었을 거예요.</p>
<h3 id="14-서버는-나를-기억하지-않습니다">1.4 서버는 나를 기억하지 않습니다</h3>
<p>Claude 서버는 전 세계를 동시에 상대합니다. 방금 <code>Read(main.py)</code>를 요청한 게 나였다는 걸 기억하지 못해요. 매 요청이 완전히 처음입니다.</p>
<p>그래서 Claude Code는 매 왕복마다 처음부터 전부 다시 보냅니다.</p>
<pre><code>1번째 왕복:
  [시스템 프롬프트] [도구 목록] [사용자: &quot;덧셈 코드 만들어줘&quot;]

2번째 왕복:
  [시스템 프롬프트] [도구 목록] [사용자: &quot;덧셈 코드 만들어줘&quot;]
  [Claude: &quot;Read(main.py) 해줘&quot;] [결과: &quot;여기 내용이야&quot;]        ← 이만큼 늘어남

3번째 왕복:
  [시스템 프롬프트] [도구 목록] [사용자: &quot;덧셈 코드 만들어줘&quot;]
  [Claude: &quot;Read(main.py) 해줘&quot;] [결과: &quot;여기 내용이야&quot;]
  [Claude: &quot;Write(add.py) 해줘&quot;] [결과: &quot;썼어&quot;]                 ← 또 늘어남</code></pre><p>Claude가 기억하는 것처럼 보이는 건 매번 전체 대화록을 다시 읽고 있기 때문입니다. 기억력이 아니라 재독인 셈이죠.</p>
<p>이 사실에서 두 가지가 파생됩니다. 뒤에서 다시 나와요.</p>
<ul>
<li>매번 다시 보내는 뭉치에 <strong>없던 텍스트를 끼워넣을 수 있습니다.</strong></li>
<li>차단당해도 <strong>원래 요청이 사라지지 않습니다.</strong></li>
</ul>
<hr>
<h2 id="2-하네스가-없으면-무슨-일이-일어날까요">2. 하네스가 없으면 무슨 일이 일어날까요</h2>
<p><code>feature/add</code> 브랜치에서 &quot;덧셈 코드 만들어줘&quot;라고 쳤을 때, 맨 상태의 Claude Code에서 벌어지는 일입니다.</p>
<h3 id="21-계획-단계가-없습니다">2.1 계획 단계가 없습니다</h3>
<p>&quot;덧셈 코드 만들어줘&quot;는 모델 입장에서 분석할 게 없는 요청이에요. 그래서 대개 이런 응답이 나옵니다.</p>
<pre><code>text:      &quot;add.py에 덧셈 함수를 만들겠습니다.&quot;
tool_use:  Write(file_path=&quot;add.py&quot;, content=&quot;def add(a, b):\n    return a + b\n&quot;)</code></pre><p>첫 턴에 바로 tool_use가 나옵니다. 아무도 &quot;먼저 계획을 세워라&quot;라고 시키지 않았으니까요. OMC의 <code>/plan</code>은 호출했을 때만 실행됩니다.</p>
<h3 id="22-루프는-즉흥적으로-돕니다">2.2 루프는 즉흥적으로 돕니다</h3>
<pre><code>모델 → tool_use → 실행 → tool_result → 모델 → tool_use → ... → 모델 → text만
                                                                        ↑ 턴 종료</code></pre><p>tool_use 없이 text만 나오면 그 턴이 끝납니다.</p>
<p>덧셈 코드 정도면 Write 한 번에 끝나죠. 하지만 &quot;댓글 기능 삭제해줘&quot; 같은 요청이면 이 루프가 30바퀴를 돕니다. 그리고 그 30개가 전부 아무 계획 없이 즉흥적으로 결정돼요.</p>
<p><code>Read(main.py)</code>를 요청한 시점에는 그 다음에 뭘 할지 아직 안 정한 상태입니다. 파일 내용을 받아보고 나서 &quot;그럼 Write 해야겠네&quot;를 그때 정하죠. 매 왕복마다 다음 한 수를 그 자리에서 두는 겁니다.</p>
<h3 id="23-훅-자리는-비어-있습니다">2.3 훅 자리는 비어 있습니다</h3>
<p>Claude Code가 <code>Write</code> 호출을 처리하는 순서는 이렇습니다.</p>
<pre><code>1. PreToolUse hook 실행     ← 하네스가 없으면 비어 있음
2. 빌트인 권한 체크          ← &quot;Do you want to proceed?&quot;
3. 실제 파일 쓰기
4. PostToolUse hook 실행    ← 하네스가 없으면 비어 있음
5. tool_result를 모델에게 반환</code></pre><p>1번과 4번이 비어 있다는 게 &quot;하네스 없음&quot;의 정의입니다. 훅 자리는 항상 있어요. 아무도 안 앉아 있을 뿐이죠.</p>
<p>2번의 권한 다이얼로그가 있긴 합니다. 하지만 이건 도구 단위로 물어봐요. 지금 뭘 하려는 건지, 총 몇 개 작업 중 몇 번째인지, 왜 하는지는 알려주지 않습니다. 그래서 3번쯤부터 그냥 <code>y</code>를 누르게 되죠. 그럼 게이트가 아닙니다.</p>
<h3 id="24-턴이-끝나면-남는-것">2.4 턴이 끝나면 남는 것</h3>
<pre><code>add.py 파일 하나.</code></pre><ul>
<li>무엇을 시켰는지 → 어디에도 남지 않음</li>
<li>어떤 파일이 바뀌었는지 → <code>git diff</code>로만</li>
<li>왜 바뀌었는지 → 모름</li>
<li>어느 브랜치에 쌓였는지 → 있던 브랜치. main이면 main에.</li>
</ul>
<p>그래서 하네스가 메우는 건 새로 만든 기능이 아니라, 원래 비어 있던 네 자리입니다.</p>
<table>
<thead>
<tr>
<th>시점</th>
<th>맨 상태</th>
<th>하네스</th>
</tr>
</thead>
<tbody><tr>
<td>사용자 입력 직후</td>
<td>없음</td>
<td>UserPromptSubmit이 정책을 컨텍스트에 주입</td>
</tr>
<tr>
<td>모델이 tool_use 뱉음</td>
<td>그냥 실행</td>
<td>PreToolUse가 가로채서 판정</td>
</tr>
<tr>
<td>실행 직후</td>
<td>없음</td>
<td>PostToolUse가 diff 캡처 + 로그 + 토큰 발행</td>
</tr>
<tr>
<td>턴 종료</td>
<td>없음</td>
<td>Stop이 토큰 폐기</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-두-개의-채널-소프트와-하드">3. 두 개의 채널: 소프트와 하드</h2>
<h3 id="31-hook은-그릇이-아니라-시점입니다">3.1 hook은 그릇이 아니라 시점입니다</h3>
<p>먼저 구조를 정확히 잡고 가야 합니다. 네 개의 hook은 서로 안에 들어있는 게 아니라 서로 다른 시간에 실행되는 별개의 스크립트예요.</p>
<pre><code>        내 컴퓨터                        │    인터넷 너머
─────────────────────────────────────────┼──────────────────
사용자: &quot;덧셈 만들어줘&quot; ⏎                  │
    ↓                                    │
[UserPromptSubmit hook]  ← ①             │
    ↓ (정책 끼워넣음)                      │
    뭉치 전송 ───────────────────────────→ [Claude]
                                         │     ↓
    ←──────────────────────────────────── &quot;Write(add.py) 해줘&quot;
    ↓                                    │
[PreToolUse hook]  ← ②                   │
    ├─ exit 2 → 실행 안 함. 차단 메시지만 ──→ [Claude] (다시 생각)
    └─ exit 0                            │
        ↓                                │
    실제로 파일 씀  ← ★ 여기서만 일이 일어남   │
        ↓                                │
[PostToolUse hook]  ← ③                  │
        ↓ (기록)                          │
    결과 전송 ───────────────────────────→ [Claude]
                                         │     ↓
    ←──────────────────────────────────── &quot;다 했습니다&quot; (도구 없음)
    ↓                                    │
[Stop hook]  ← ④                         │</code></pre><p>hook 네 개가 전부 왼쪽에 있습니다. 오른쪽으로 넘어가는 게 하나도 없어요. Claude는 왼쪽에서 뭐가 도는지 전혀 모릅니다. hook이 존재하는지도 몰라요. Claude 눈에는 &quot;Write 해줘&quot; 했더니 &quot;❌ 차단됨&quot;이라는 답이 온 것뿐이죠.</p>
<table>
<thead>
<tr>
<th>hook</th>
<th>실행 시점</th>
<th>턴당 횟수</th>
<th>역할</th>
<th>막을 수 있나</th>
</tr>
</thead>
<tbody><tr>
<td>UserPromptSubmit</td>
<td>사용자 입력 직후</td>
<td>1번</td>
<td>정책 주입 + 명령 기록</td>
<td>❌</td>
</tr>
<tr>
<td><strong>PreToolUse</strong></td>
<td><strong>도구 실행 직전</strong></td>
<td><strong>도구 수만큼</strong></td>
<td><strong>차단 판정</strong></td>
<td><strong>✅</strong></td>
</tr>
<tr>
<td>PostToolUse</td>
<td>도구 실행 직후</td>
<td>도구 수만큼</td>
<td>기록 + diff + 토큰 발행</td>
<td>❌</td>
</tr>
<tr>
<td>Stop</td>
<td>턴 끝</td>
<td>1번</td>
<td>토큰 삭제 + 턴 종료 기록</td>
<td>❌</td>
</tr>
</tbody></table>
<p>여기서 중요한 건 다른 hook들이 차단을 안 하는 게 아니라 못 한다는 점입니다.</p>
<pre><code>PostToolUse가 rm을 보고 &quot;이거 위험한데?&quot; 하면?
   → 이미 지워졌습니다. 늦었죠.</code></pre><p>PreToolUse만 실행 전에 있습니다. 나머지는 전부 실행 후예요. 물리적으로 막을 수 있는 자리가 거기 하나뿐입니다. &quot;게이트는 한 곳&quot;이라는 원칙은 설계 취향이 아니라 물리적 제약이었어요.</p>
<h3 id="32-소프트-컨텍스트에-텍스트를-끼워넣습니다">3.2 소프트: 컨텍스트에 텍스트를 끼워넣습니다</h3>
<p>1.4에서 본 사실 — 매 왕복마다 전부 다시 보낸다 — 을 이용합니다.</p>
<p><code>UserPromptSubmit</code> hook이 stdout으로 출력한 텍스트는 모델 컨텍스트에 추가로 주입됩니다.</p>
<pre><code>[시스템 프롬프트]
[도구 목록]
[사용자: &quot;덧셈 코드 만들어줘&quot;]
[스크립트가 뱉은 텍스트]  ← 사용자가 쓰지 않은 글인데 들어감</code></pre><p>정책 내용은 이렇습니다.</p>
<blockquote>
<p>&quot;코드를 변경하는 요청이면, 첫 도구 호출은 반드시 EnterPlanMode여야 한다.
지금 브랜치가 main이면 계획 첫 단계에 <code>git checkout -b feature/&lt;설명&gt;</code>을 넣어라.&quot;</p>
</blockquote>
<p>Claude는 이걸 읽습니다. 그리고 이게 사용자가 쓴 건지, 시스템 프롬프트에 원래 있던 건지, 방금 스크립트가 끼워넣은 건지 구분하지 못해요.</p>
<h3 id="33-소프트만으로는-안-되는-이유">3.3 소프트만으로는 안 되는 이유</h3>
<p>끼워넣은 건 결국 글입니다. 명령이 아니라 부탁이죠. &quot;계획부터 세워라&quot;라고 적어놔도 무시하고 <code>Write</code>를 요청해버리면 그만이에요. 강제력이 0입니다.</p>
<p>그리고 실제로 무시합니다. 규칙이 길면 흘려보고, 요청이 급해 보이면 건너뛰어요. AI에게 텍스트로 부탁하는 건 신뢰할 수 있는 안전장치가 아닙니다.</p>
<h3 id="34-하드-손을-안-빌려줍니다">3.4 하드: 손을 안 빌려줍니다</h3>
<p>Claude는 파일을 못 씁니다. &quot;Write 해줘&quot;라고 말할 수 있을 뿐이고, 실제로 쓰는 건 내 컴퓨터의 Claude Code죠. 그러니까 내 쪽에서 거절하면 Claude는 아무것도 못 합니다.</p>
<p>부탁은 무시할 수 있어도, 손을 안 빌려주는 건 무시할 방법이 없어요.</p>
<p>PreToolUse hook은 서류를 통째로 받습니다.</p>
<pre><code class="language-json">{
  &quot;tool_name&quot;: &quot;Write&quot;,
  &quot;tool_input&quot;: { &quot;file_path&quot;: &quot;add.py&quot;, &quot;content&quot;: &quot;def add...&quot; },
  &quot;cwd&quot;: &quot;/home/user/myproject&quot;,
  &quot;session_id&quot;: &quot;abc123&quot;
}</code></pre>
<p>그리고 딱 두 가지 중 하나로 대답합니다.</p>
<pre><code>exit 0  →  통과  → Claude Code가 실제로 파일 씀
exit 2  →  차단  → Claude Code가 파일 안 씀 + stderr가 모델에게 전달됨</code></pre><p>exit code 하나가 전부입니다. 그리고 여기가 핵심이에요. Claude는 이 스크립트를 설득할 수 없습니다.</p>
<p>아무리 &quot;이건 꼭 해야 합니다&quot;라고 말해도 스크립트는 그 말을 읽지도 않아요. 도구 이름, 인자, 브랜치만 보고 숫자를 뱉습니다. 대화 상대가 아니라 기계니까요.</p>
<h3 id="35-그럼-하드만-있으면-되지-않나요">3.5 그럼 하드만 있으면 되지 않나요</h3>
<p>됩니다. 안전은 지켜져요. 다만 쓰기 싫은 도구가 됩니다.</p>
<pre><code>사용자: &quot;덧셈 코드 만들어줘&quot;
Claude: &quot;Write(add.py) 해줘&quot;
하네스: 차단 ❌
Claude: (왜 막혔지) &quot;아 계획부터 세워야 하나 보다&quot;
Claude: EnterPlanMode
... 그제서야 정상 경로 시작</code></pre><p>매번 벽에 한 번 박고 나서야 돌아갑니다. 왕복이 통째로 낭비되죠. 하루에 50번 요청하면 50번 박습니다.</p>
<p>그리고 진짜 문제는 따로 있습니다. 벽에 박은 AI는 엉뚱한 짓을 해요.</p>
<p>v1.2에서 실제로 겪은 일입니다. 막히니까 <code>OMC_SKIP_HOOKS=pre_tool_use</code> 같은, 존재하지도 않는 환경변수를 지어내서 추천했습니다. Write가 막히면 Bash로 우회하려 들고요.</p>
<p>AI 입장에서 차단은 &quot;규칙&quot;이 아니라 &quot;장애물&quot;입니다. 규칙인지 버그인지 모르니 일단 우회를 시도하죠. 벽에 박은 다음에 &quot;이건 규칙이야&quot;를 이해시키는 건 늦습니다.</p>
<h3 id="36-소프트와-차단-메시지는-강약이-아니라-타이밍이-다릅니다">3.6 소프트와 차단 메시지는 강약이 아니라 타이밍이 다릅니다</h3>
<p>여기서 흔히 오해하는 게 있어요. &quot;차단 메시지가 더 강력하니까 그걸로 유도하면 되지 않나?&quot;</p>
<p>둘 다 그냥 글입니다. 강약이 없어요. 차이는 언제 오느냐입니다.</p>
<ul>
<li><strong>소프트</strong>: 일 시작 전에 옵니다. 안 급하니까 흘려볼 수 있어요.</li>
<li><strong>차단 메시지</strong>: 이미 막힌 다음에 옵니다. 지금 손이 묶여 있으니 안 읽을 수가 없죠.</li>
</ul>
<p>그리고 중요한 게 하나 있습니다. 차단 메시지를 보는 상황 자체가 이미 실패예요. 소프트가 제대로 일하면 차단 메시지는 볼 일이 없습니다. 하드 게이트가 발동 안 하는 게 정상 상태죠.</p>
<p>에어백이 핸들보다 강력한가요? 질문이 성립하지 않습니다. 에어백이 터졌다는 건 이미 사고가 난 거니까요.</p>
<table>
<thead>
<tr>
<th></th>
<th>작동 빈도</th>
<th>강제력</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>소프트</strong> (정책 주입)</td>
<td>자주</td>
<td>없음</td>
<td>사고가 안 나게</td>
</tr>
<tr>
<td><strong>하드</strong> (PreToolUse 차단)</td>
<td>거의 없음</td>
<td>있음</td>
<td>사고가 나도 안 터지게</td>
</tr>
</tbody></table>
<p>한편 AI가 소프트를 무시하고 막혔을 때 복귀시키는 건 소프트가 아닙니다. 이미 읽고도 무시한 글이라 다시 읽는다고 달라지지 않아요. 복귀시키는 건 차단 메시지 자체입니다. 그래서 exit 2를 할 때 stderr에 회복 경로를 반드시 적어줍니다.</p>
<blockquote>
<p>❌ 차단됨: 현재 main 브랜치입니다. 보호 브랜치에서는 파일 변경이 불가능합니다.
→ EnterPlanMode를 호출하고, 계획 첫 단계에 <code>git checkout -b feature/&lt;설명&gt;</code>을 넣으세요.</p>
</blockquote>
<p>차단 메시지는 거절이 아니라 회복 지시서인 셈이죠.</p>
<hr>
<h2 id="4-차단-게이트">4. 차단 게이트</h2>
<h3 id="41-bash가-열려-있으면-게이트는-없는-것과-같습니다">4.1 Bash가 열려 있으면 게이트는 없는 것과 같습니다</h3>
<p><code>Write</code>를 막았습니다. 하지만 이렇게 하면 그만이에요.</p>
<pre><code class="language-bash">echo &#39;def add(a, b): return a + b&#39; &gt; add.py
sed -i &#39;s/old/new/g&#39; auth.py        # 파일 일부 수정 = Edit
cat &gt; config.py &lt;&lt; EOF               # 파일 통째 쓰기 = Write
...
EOF
cp template.py add.py                # 복사로 생성</code></pre>
<p><code>Bash</code>는 도구 하나처럼 생겼지만 실제로는 &quot;아무 프로그램이나 실행해줘&quot;입니다. 터미널로 할 수 있는 모든 게 그 안에 들어있어요. Write는 문이고, Bash는 뚫린 벽입니다.</p>
<p>여기서 문제가 하나 생깁니다. Write는 &quot;Write냐 아니냐&quot;만 보면 되는데, Bash는 명령어 문자열을 읽고 판단해야 해요.</p>
<p><code>Bash(command=&quot;ls -la&quot;)</code>는 안전합니다. <code>Bash(command=&quot;rm -rf src/&quot;)</code>는 위험하죠. 같은 도구인데 어떤 건 통과, 어떤 건 차단입니다. 도구 이름만으로는 못 정해요.</p>
<h3 id="42-화이트리스트는-안전-문제가-아니라-일이-안-돼서-실패했습니다">4.2 화이트리스트는 안전 문제가 아니라 &quot;일이 안 돼서&quot; 실패했습니다</h3>
<p>선택지는 둘입니다.</p>
<ul>
<li><strong>화이트리스트</strong>: 허용할 것만 적고 나머지 전부 차단</li>
<li><strong>블랙리스트</strong>: 차단할 것만 적고 나머지 전부 통과</li>
</ul>
<p>보안 교과서는 화이트리스트라고 가르칩니다. 이유는 명백해요. 블랙리스트는 내가 모르는 위험을 못 막습니다. 리스트에 없는 위험한 명령이 하나라도 있으면 그냥 통과하죠.</p>
<p>논리적으로는 화이트리스트가 이깁니다. 그런데 v1.1에서 화이트리스트로 갔다가 v1.3에서 버렸어요. &quot;안전한 명령&quot;이 무한하기 때문입니다.</p>
<p>위험한 명령은 카테고리가 뻔합니다. 지우기(rm), 옮기기(mv), 리다이렉션(<code>&gt;</code>), 인플레이스 편집(sed -i), 권한(chmod)... 손에 꼽혀요.</p>
<p>안전한 명령은 어떨까요?</p>
<pre><code>ls, cat, grep, find, wc, head, tail, diff, tree, awk, jq, curl,
git status, git log, git diff, git branch, pytest, npm test,
./gradlew build, mvn, docker ps, kubectl get, python -c &quot;print(1)&quot; ...</code></pre><p>끝나지 않습니다. 그리고 실제로 겪은 건 도구 쪽에서였어요. <code>always_allowed_tools</code>에 안전 도구 리스트를 뒀는데, Claude Code가 새 도구(Agent / ToolSearch / Skill)를 추가하자 전부 막혔고, 매번 손으로 리스트에 추가해야 했습니다.</p>
<p>화이트리스트는 안전 문제로 실패한 게 아니라, 일이 안 돼서 실패한 거죠. 그래서 이렇게 판단했습니다. 완벽하지만 아무도 안 쓰는 게이트보다, 구멍이 있지만 실제로 켜져 있는 게이트가 낫다고요.</p>
<p>블랙리스트에는 진짜로 구멍이 있습니다. 모르는 위험한 명령은 통과해요. 그걸 알면서 골랐습니다. 화이트리스트로 가면 하루에 열 번씩 막히고, 결국 하네스를 꺼버릴 테니까요. 꺼진 게이트의 안전성은 0입니다.</p>
<p><code>config/blacklist.txt</code>의 카테고리는 이렇습니다.</p>
<ul>
<li><strong>파일 시스템 변경</strong>: rm, rmdir, unlink, mv, cp, mkdir, touch, chmod, chown, ln</li>
<li><strong>출력 리다이렉션</strong>: <code>&gt;</code>, <code>&gt;&gt;</code>, tee, heredoc</li>
<li><strong>인플레이스 편집기</strong>: sed -i, perl -i, gawk -i, ed, ex</li>
<li><strong>Git 변경</strong>: git commit, git push, git reset --hard, git checkout -B, git clean -f, git rebase, git merge, git branch -D, git filter-branch</li>
<li><strong>패키지 설치/제거</strong>: npm, yarn, pnpm, pip, poetry, cargo, go install, apt, brew, gem</li>
<li><strong>DB / 마이그레이션</strong>: alembic, prisma migrate, manage.py migrate, psql/mysql -c 안의 INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/TRUNCATE</li>
<li><strong>시스템 파괴 / 권한 상승</strong>: sudo, su -, dd, mkfs, shred, format</li>
<li><strong>환경 변수 영구 변경</strong>: export, unset</li>
</ul>
<h3 id="43-왜-첫-단어만-검사할까요">4.3 왜 첫 단어만 검사할까요</h3>
<p>블랙리스트 검사를 제일 자연스럽게 짜면 이렇게 됩니다.</p>
<pre><code class="language-python">first_word = command.split()[0]
if first_word in blacklist:
    block()</code></pre>
<p>&quot;왜 첫 단어만 보나, 문자열 전체에서 <code>rm</code>을 찾으면 되지 않나&quot;라는 반문이 나올 수 있어요. 오탐 때문입니다.</p>
<pre><code class="language-bash">ls firm/                      # &quot;firm&quot;에 rm 있음
npm run format                # &quot;format&quot;에 rm 있음
git commit -m &quot;confirm fix&quot;   # &quot;confirm&quot;에 rm 있음</code></pre>
<p>전부 안전한데 전부 차단됩니다. 하네스가 쓸모없어지죠.</p>
<p>셸에서 첫 단어는 실행되는 프로그램 이름이고 나머지는 인자입니다. <code>rm -rf src/</code>에서 도는 프로그램은 <code>rm</code>이고 <code>-rf</code>, <code>src/</code>는 인자예요. 그러니까 &quot;무슨 프로그램이 도는가&quot;를 알려면 첫 단어를 봐야 합니다. 멍청한 게 아니라 정확한 위치를 보는 거죠.</p>
<h3 id="44-우회-1-명령-체이닝--게이트는-셸처럼-읽어야-합니다">4.4 우회 1: 명령 체이닝 — 게이트는 셸처럼 읽어야 합니다</h3>
<pre><code class="language-bash">git status &amp;&amp; rm -rf src/</code></pre>
<p>첫 단어는 <code>git</code>입니다. 블랙리스트에 없어요. 통과. 그리고 <code>rm -rf src/</code>가 실행됩니다.</p>
<p>문제는 이걸 명령 하나로 봤다는 거예요. 셸은 둘로 봅니다.</p>
<pre><code>git status  &amp;&amp;  rm -rf src/
└─ 1번 ─┘      └── 2번 ──┘</code></pre><p>셸에는 접착제가 여러 개 있습니다.</p>
<pre><code>;    무조건 다음 것도
&amp;&amp;   성공하면 다음 것도
||   실패하면 다음 것도
|    앞의 출력을 뒤로 넘김</code></pre><p>해결은 검사 전에 쪼개는 겁니다.</p>
<pre><code class="language-python">segments = re.split(r&#39;;|&amp;&amp;|\|\||\|&#39;, command)
# [&quot;git status&quot;, &quot; rm -rf src/&quot;]

for seg in segments:
    if 블랙리스트에_걸림(seg):
        block()</code></pre>
<p>로직을 바꾼 게 아니라 로직을 적용할 대상을 제대로 자른 거예요. 게이트는 셸이 명령을 이해하는 방식과 똑같이 이해해야 합니다. 게이트가 보는 명령과 셸이 실행하는 명령이 다르면, 그 차이가 곧 구멍이 되니까요.</p>
<h3 id="45-우회-2-동적-실행--판단할-수-없으면-판단하지-않고-막습니다">4.5 우회 2: 동적 실행 — 판단할 수 없으면 판단하지 않고 막습니다</h3>
<p>여기서 이름이 겹쳐서 헷갈리는 지점이 있습니다. <code>Bash</code>가 두 개예요.</p>
<pre><code>Bash(command=&quot;ls -la&quot;)
└┬─┘         └──┬───┘
 │              └ 셸 명령어 (소문자 bash 세계)
 └ Claude Code의 도구 (대문자 Bash)</code></pre><p>내 컴퓨터의 명령어들은 전부 그냥 프로그램 파일입니다. <code>/bin/ls</code>, <code>/bin/rm</code>, <code>/bin/cat</code> — 그리고 <code>/bin/bash</code>도 그 중 하나죠. 그리고 이 프로그램이 하는 일은 &quot;문자열을 받아서 명령으로 실행해주는 것&quot;입니다.</p>
<p>그러니까 셸 안에서 셸을 또 열 수 있어요.</p>
<pre><code class="language-bash">bash -c &quot;rm -rf src/&quot;</code></pre>
<p>&quot;bash 프로그램을 새로 켜고, 걔한테 <code>rm -rf src/</code>를 시켜라&quot;라는 뜻입니다. 게이트에는 이렇게 보여요.</p>
<pre><code>Bash(command=&quot;rm -rf src/&quot;)
             └┬┘
              └ 첫 단어 = rm  →  블랙리스트  →  차단 ❌

Bash(command=&#39;bash -c &quot;rm -rf src/&quot;&#39;)
             └─┬┘    └──── 인자 ────┘
               └ 첫 단어 = bash  →  블랙리스트에 없음  →  통과 ✅
                                       그리고 rm이 실행됨</code></pre><p>둘 다 결과는 똑같이 <code>rm</code>이 도는데, 게이트에는 다르게 보입니다. 왼쪽은 rm이 명령어 자리에 있고, 오른쪽은 따옴표 안 글자로 있죠.</p>
<p>같은 부류가 여럿입니다.</p>
<pre><code class="language-bash">eval &quot;rm -rf src/&quot;                    # 문자열을 명령으로 실행
sh -c &quot;rm -rf src/&quot;
echo &quot;rm -rf src/&quot; | bash             # 파이프로 흘려넣기
$(echo rm) -rf src/                   # 명령 치환으로 조립
`echo rm` -rf src/                    # 백틱
source evil.sh                        # 파일에 적어놓고 부르기</code></pre>
<p>공통점은 전부 &quot;문자열을 명령으로 바꾸는&quot; 도구라는 겁니다.</p>
<p>&quot;그럼 인자 안까지 들어가서 검사하면 되지 않나?&quot; 안 됩니다.</p>
<pre><code class="language-bash">bash -c &quot;$(echo cm0gLXJmIHNyYy8= | base64 -d)&quot;   # base64로 인코딩된 rm
X=r; Y=m; $X$Y -rf src/                            # 변수 조립
bash -c &quot;bash -c \&quot;rm ...\&quot;&quot;                       # 무한 중첩</code></pre>
<p>실행해보기 전에는 뭐가 될지 알 수 없어요. 구현력의 문제가 아니라 원리적으로 불가능합니다. 문자열이 뭐가 될지 알려면 실행해야 하는데, 실행하면 이미 늦었으니까요.</p>
<p>그래서 판단할 수 없으면 판단하지 않고 그냥 막기로 했습니다. <code>bash -c</code>, <code>sh -c</code>, <code>eval</code>, <code>exec</code>, <code>source</code>, <code>$(...)</code>, 백틱, <code>| bash</code> — 이 키워드가 들어있다는 사실 자체로 차단합니다. 안에 뭐가 있는지 보지 않아요. 볼 수가 없으니까요.</p>
<p>여기서 중요한 오해를 짚고 가야 합니다. <code>Bash</code> 도구를 막은 게 아니에요.</p>
<pre><code>Bash(command=&quot;ls -la&quot;)              → 통과 ✅
Bash(command=&quot;git status&quot;)          → 통과 ✅
Bash(command=&quot;pytest&quot;)              → 통과 ✅
Bash(command=&quot;rm -rf src/&quot;)         → 차단 ❌  (rm이 블랙리스트)
Bash(command=&#39;bash -c &quot;rm -rf /&quot;&#39;)  → 차단 ❌  (bash -c가 블랙리스트)
Bash(command=&#39;bash -c &quot;ls&quot;&#39;)        → 차단 ❌  (안전하지만 형태 때문에)</code></pre><p>Bash 도구는 계속 열려 있습니다. 막으면 하네스를 못 써요. 테스트도 못 돌리고 git 상태도 못 봅니다. 차단하는 건 &quot;문자열을 명령으로 바꾸는 형태&quot;뿐이에요. 맨 아래 줄이 차단된 이유는 <code>ls</code>가 위험해서가 아니라, 껍데기를 씌워 안을 못 보게 만든 형태 자체 때문입니다.</p>
<p><code>bash -c &quot;ls&quot;</code> 같은 멀쩡한 것도 같이 막힙니다. 알면서 막는 거예요. 안전한 몇 개를 잃는 대신 뚫릴 구멍을 없앴습니다. 애초에 <code>bash -c</code>를 굳이 쓸 상황 자체가 드물기도 하고요.</p>
<hr>
<h2 id="5-승인-채널">5. 승인 채널</h2>
<h3 id="51-자연어를-승인-신호로-쓰면-반드시-깨집니다">5.1 자연어를 승인 신호로 쓰면 반드시 깨집니다</h3>
<p>가장 먼저 떠오르는 설계는 이렇습니다.</p>
<pre><code class="language-python">if &quot;진행해&quot; in 사용자_메시지:
    토큰 발행</code></pre>
<p>깔끔해 보이지만 화이트리스트보다 훨씬 심하게 깨져요.</p>
<p><strong>변이형</strong> — 리스트에 없으면 승인이 안 됩니다.</p>
<pre><code>&quot;ㅇㅋㅋ&quot;, &quot;ㄱㄱ&quot;, &quot;Sure&quot;, &quot;좋아 그렇게 해&quot;</code></pre><p><strong>언어 전환</strong> — 러시아어, 히브리어로 답하면 끝입니다.</p>
<p><strong>오탐</strong> — 리스트를 늘리면 정반대 의미가 승인됩니다.</p>
<pre><code>&quot;진행하기 전에 한 번 더 확인해줘&quot;
   → &quot;진행&quot; 매칭 → 토큰 발행 💀</code></pre><p><strong>AI 인젝션</strong> — 모델 출력을 인용했을 뿐인데 승인됩니다.</p>
<pre><code>Claude: &quot;...이 부분은 approve 후에 진행하겠습니다.&quot;
사용자: &quot;approve 후에 진행한다는 게 무슨 뜻이야?&quot;
   → &quot;approve&quot; 매칭 → 승인됨 💀</code></pre><p><code>/approve</code> 같은 슬래시 커맨드는 명시적이라 오탐이 없죠. 하지만 여기서 결정적인 역설이 있습니다. <code>/approve</code>를 꼬박꼬박 칠 사람은 어차피 알아서 계획 세우고 일하는 사람이에요. 정작 막아야 할 대상은 &quot;@@해줘&quot; 한 줄 던지고 결과만 기다리는 습관인데, 그 사람은 <code>/approve</code>를 안 칩니다.</p>
<p>안전장치가 안전한 사람에게만 작동하는 거죠. 그럼 있으나 마나입니다.</p>
<p>근본 원인은 하나입니다. 자연어의 겉모습을 보고 속뜻을 추론하는 규칙은 본질적으로 깨져요. &quot;진행&quot;이라는 글자가 승인인지, 질문인지, 인용인지 — 글자만 봐서는 알 수 없으니까요.</p>
<h3 id="52-신호-위치-이동-사용자-텍스트-→-모델의-tool_use">5.2 신호 위치 이동: 사용자 텍스트 → 모델의 tool_use</h3>
<p>한계를 인정하면 결론은 명확합니다. 승인 의도를 사용자 텍스트에서 찾지 말고 다른 데서 찾으면 돼요.</p>
<pre><code>&quot;덧셈 코드 만들어줘&quot;
&quot;Add me some addition code&quot;
&quot;добавь код сложения&quot;
&quot;덧셈 ㄱㄱ&quot;</code></pre><p>네 개 다 다른 글자입니다. 그런데 실제로 코드를 만들려면 어떻게 해야 할까요?</p>
<pre><code>Write(file_path=&quot;add.py&quot;, content=&quot;...&quot;)</code></pre><p>넷 다 여기로 수렴합니다. 무조건이에요. 파일을 만들려면 Write를 호출하는 것 말고는 방법이 없으니까요. 손이 그것뿐이잖아요.</p>
<p><code>tool_use</code>는 자연어가 아니라 구조체입니다.</p>
<pre><code class="language-json">{ &quot;name&quot;: &quot;Write&quot;, &quot;input&quot;: { &quot;file_path&quot;: &quot;add.py&quot; } }</code></pre>
<p>언어 의존성 0. 변이형 0. 오탐 0. <code>rm</code>은 중국어 버전이 없어요. 러시아에서도 <code>rm</code>입니다. 셸 명령어와 도구 이름은 자연어가 아니라 기계어라서 언어 문제가 애초에 없어요.</p>
<p><strong>원칙 1. 신호는 사용자 텍스트가 아니라 모델의 tool_use에서 받는다.</strong></p>
<h3 id="53-승인은-강제하는-게-아니라-다른-길을-막는-겁니다">5.3 승인은 강제하는 게 아니라 다른 길을 막는 겁니다</h3>
<p>차단은 tool_use로 되는데 승인은 문제가 남습니다. 승인은 사용자가 하는 건데, 사용자는 자연어로 말하잖아요. tool_use는 모델이 만드는 것이지 사용자가 만드는 게 아니니까요.</p>
<p>여기서 발상을 바꿨습니다. 강제할 필요가 없어요. 다른 길을 다 막으면 됩니다.</p>
<p>Write가 막혔습니다. Edit도 막혔고요. Bash 우회도 막혔습니다. Claude가 코드를 바꿀 방법이 하나도 없어요. 그럼 Claude는 어떻게든 일을 하려고 남은 길을 찾습니다. 그때 딱 하나 열려 있는 문이 있으면 거기로 갈 수밖에 없죠. 밀어서 보내는 게 아니라 거기밖에 안 남아서 가는 겁니다.</p>
<p>그리고 그 문은 Claude Code에 이미 있었어요.</p>
<pre><code>EnterPlanMode()          ← &quot;계획 모드 들어감&quot;
ExitPlanMode(plan=&quot;...&quot;) ← &quot;계획 다 짰음. 여기 있음.&quot;</code></pre><p>도구 목록에 원래 있던 도구 두 개입니다. Read, Write, Bash 옆에 나란히요.</p>
<p><code>EnterPlanMode</code>를 호출하면 Claude 스스로 손이 묶입니다. Read/Glob/Grep만 쓸 수 있고 Write/Edit/Bash는 Claude Code가 받아주지 않아요.</p>
<p>그리고 <code>ExitPlanMode(plan=&quot;...&quot;)</code>를 호출하는 순간 승인 다이얼로그가 뜹니다.</p>
<pre><code>┌────────────────────────────────┐
│  Claude가 다음 계획을 제안합니다:  │
│  1. add.py 생성                 │
│  2. def add(a, b) 작성          │
│     [ Accept ]   [ Reject ]    │
└────────────────────────────────┘</code></pre><p>여기서 뭐가 달라졌는지 보이시나요? 사용자는 글자를 쓰지 않습니다. 버튼을 누릅니다.</p>
<p>&quot;진행해&quot;도 아니고 &quot;ㅇㅋ&quot;도 아니고 &quot;approve&quot;도 아니에요. 클릭입니다. 클릭에는 언어가 없죠. 변이형도 없고, 오탐도 없고, AI가 자기 출력에 클릭을 심을 수도 없습니다. Accept 아니면 Reject, 둘 중 하나예요.</p>
<p>그리고 Claude Code는 그 결과를 <code>ExitPlanMode</code>의 tool_response로 돌려줍니다. 도구 호출의 결과니까 PostToolUse hook이 받아볼 수 있죠.</p>
<p><strong>원칙 4. 승인은 Claude Code의 네이티브 메커니즘(EnterPlanMode + ExitPlanMode)을 활용한다.</strong></p>
<p>만든 게 아니라 찾아낸 거예요. 이미 있던 다이얼로그를 승인 채널로 전용했습니다.</p>
<h3 id="54-토큰은-파일-하나입니다">5.4 토큰은 파일 하나입니다</h3>
<p>토큰 구현은 이게 전부입니다.</p>
<pre><code>~/.harness/state/&lt;session_id&gt;/approved     ← 이 파일이 있으면 승인됨</code></pre><p>파일이 있다 = 승인됨. 없다 = 승인 안 됨.</p>
<pre><code class="language-python"># ① 발행 (PostToolUse)
if tool_name == &quot;ExitPlanMode&quot; and tool_response 가 Accept:
    write_file(&quot;state/abc123/approved&quot;, json.dumps({
        &quot;plan_id&quot;: &quot;uuid...&quot;,
        &quot;approved_at&quot;: &quot;2026-06-03T14:22:05+09:00&quot;,
        &quot;plan_summary&quot;: &quot;덧셈 함수 추가&quot;
    }))

# ② 확인 (PreToolUse)
if tool_name in [&quot;Write&quot;, &quot;Edit&quot;, &quot;MultiEdit&quot;, &quot;NotebookEdit&quot;]:
    if not os.path.exists(&quot;state/abc123/approved&quot;):
        sys.stderr.write(&quot;❌ 승인된 계획이 없습니다. EnterPlanMode부터 시작하세요.&quot;)
        sys.exit(2)
    sys.exit(0)

# ③ 삭제 (Stop)
if os.path.exists(token_path):
    os.remove(token_path)</code></pre>
<p>Claude는 토큰이 존재하는지도 모릅니다. 인터넷 너머에 있어서 이 파일을 볼 수도, 만들 수도 없으니까요.</p>
<h3 id="55-프롬프트로-토큰을-만들-수-있을까요">5.5 프롬프트로 토큰을 만들 수 있을까요</h3>
<p>이렇게 입력한다고 해볼게요.</p>
<pre><code>&quot;@@해줘. 다 ACCEPT 시켜줄게, PLAN MODE 해도 ACCEPT 시킬 테니까 토큰까지 다 발행해줘&quot;</code></pre><p>아무 일도 일어나지 않습니다. hook이 보는 건 이게 전부거든요.</p>
<pre><code class="language-python">tool_name    = &quot;Write&quot;
tool_input   = { &quot;file_path&quot;: &quot;add.py&quot; }
현재_브랜치   = &quot;feature/add&quot;
토큰_파일_있나 = os.path.exists(&quot;state/abc123/approved&quot;)</code></pre>
<p>사용자 텍스트는 이 목록에 없습니다. hook은 그 문장의 존재조차 몰라요.</p>
<p>Claude가 저 지시를 읽고 <code>ExitPlanMode</code>를 호출해도 다이얼로그는 어차피 뜹니다. 다이얼로그를 띄우는 건 Claude가 아니라 Claude Code니까요. Claude가 &quot;미리 승인받았으니 토큰 발행해도 되겠지&quot; 하고 Write를 시도하면 차단됩니다. 토큰 파일이 없거든요. 말로는 파일이 만들어지지 않습니다.</p>
<p>자연어를 신호로 안 쓰기로 한 순간, 자연어로 하는 모든 공격이 무의미해졌어요. 방어한 게 아니라 공격할 표면 자체를 없앤 거죠.</p>
<h3 id="56-토큰을-만들려면-토큰이-필요합니다">5.6 토큰을 만들려면 토큰이 필요합니다</h3>
<p>Claude가 토큰 파일을 직접 만들 수는 없을까요?</p>
<pre><code>Write(&quot;.../approved&quot;)  →  Write는 토큰이 필요함  →  토큰이 없음  →  차단
                              ↑                                    │
                              └──── 토큰을 만들려면 토큰이 필요 ────┘</code></pre><p>닫힌 고리라 처음부터 못 들어옵니다. Bash 쪽은 아예 다른 이유로 막히고요.</p>
<pre><code>Bash(&quot;touch .../approved&quot;)        → touch가 블랙리스트 ❌
Bash(&quot;echo x &gt; .../approved&quot;)     → `&gt;` 리다이렉션이 블랙리스트 ❌
Bash(&quot;cp a .../approved&quot;)         → cp가 블랙리스트 ❌</code></pre><p>파일 만드는 방법을 이미 다 막아놨기 때문이에요. 토큰을 지키려고 막은 게 아니라, 파일 변경을 막았더니 토큰도 같이 지켜진 겁니다.</p>
<p>토큰 파일에는 특별한 보호가 하나도 없습니다. 암호도 없고, 권한 설정도 안 했고, 경로도 뻔해요. 그런데 못 만듭니다. 파일을 만드는 능력 자체가 없으니까요. 지켜야 할 걸 지킨 게 아니라, 지킬 필요가 없는 구조를 만든 셈이죠.</p>
<h3 id="57-토큰-수명-한-턴">5.7 토큰 수명: 한 턴</h3>
<p>v1 초반에 실제로 터진 문제입니다.</p>
<pre><code>&quot;덧셈 코드 만들어줘&quot; → plan → Accept → 토큰 발행 → 구현 완료
&quot;곱셈 코드도 만들어줘&quot; → Write → 토큰 있음 → 그냥 통과 💀
&quot;댓글 기능 다 지워줘&quot;  → rm → 토큰 있음 → 그냥 지움 💀💀</code></pre><p>한 번 승인이 영구 승인이 됐습니다. 첫 번째 계획만 봤는데 그 뒤로는 뭘 하든 프리패스예요. 게이트가 사실상 꺼진 상태죠.</p>
<p>해결은 <code>Stop</code> hook입니다. Claude가 도구 요청 없는 텍스트만 뱉는 순간이 턴 끝이고, 그때 토큰을 삭제합니다.</p>
<pre><code>&quot;덧셈 만들어줘&quot;  → plan → Accept → 토큰 → 구현 → 턴 끝 → 토큰 삭제
&quot;곱셈도 만들어줘&quot; → Write → 토큰 없음 → 차단 → 새 plan → 새 Accept ✅</code></pre><p><strong>원칙 5. 승인 토큰은 한 턴에만 유효하다.</strong></p>
<p>이게 자연스러운 이유는, 승인한 게 &quot;Claude를 믿는다&quot;가 아니라 &quot;이 계획을 믿는다&quot;이기 때문이에요. 계획이 끝났으면 승인도 끝나는 게 맞죠.</p>
<h3 id="58-무조건-지우는-게-더-안전합니다">5.8 무조건 지우는 게 더 안전합니다</h3>
<p>Stop hook은 매 턴 끝날 때마다 돕니다. &quot;코드 리뷰해줘&quot; 같은 읽기 전용 턴에도 돌아요. 지울 게 없으면 아무 일도 안 하고 끝납니다.</p>
<p>똑똑하게 짰다면 이렇게 됐을 거예요.</p>
<pre><code class="language-python"># 이렇게 안 함
if 이번_턴에_토큰_발행했었나:      # ← 이걸 어디에 기록하지?
    os.remove(token_path)</code></pre>
<p>&quot;발행했었나&quot;를 알려면 또 어딘가에 기록해야 합니다. 상태가 하나 더 늘죠. 그리고 그 기록이 깨지면 토큰이 살아남습니다. 5.7의 프리패스 상황이 되는 거예요.</p>
<p>안전장치는 &quot;실패하면 어느 쪽으로 실패하는가&quot;로 설계해야 합니다.</p>
<ul>
<li>지우는 걸 실수로 한 번 더 해도 → 계획 한 번 더 승인. 귀찮을 뿐입니다.</li>
<li>안 지우는 걸 실수로 한 번 하면 → 게이트 없이 파일 삭제.</li>
</ul>
<p>두 실수의 무게가 완전히 다르죠. 그래서 의심스러우면 지우는 쪽으로 짭니다. <code>SessionEnd</code> hook에서 한 번 더 지우는 것도 같은 이유예요. 중복은 공짜고, 누락은 사고니까요.</p>
<hr>
<h2 id="6-보호-브랜치">6. 보호 브랜치</h2>
<h3 id="61-브랜치-정책이-토큰보다-상위인-이유">6.1 브랜치 정책이 토큰보다 상위인 이유</h3>
<p><code>main</code>에 있고, 계획을 세웠고, Accept까지 눌러서 토큰이 있습니다. <code>Write(add.py)</code>가 왔어요. 차단됩니다.</p>
<pre><code>Write(add.py) 검사:
  토큰 있나?      → 있음 ✅
  보호 브랜치인가?  → main ❌   ← 여기서 걸림</code></pre><p><strong>원칙 10. 보호 브랜치 정책은 토큰보다 상위 게이트다.</strong></p>
<p>이 순서인 이유는, Accept를 눌렀다는 게 &quot;이 계획이 맞다&quot;는 뜻이지 &quot;main에 해도 된다&quot;는 뜻이 아니기 때문입니다. 승인할 때 어느 브랜치인지 신경도 안 썼을 수 있잖아요. 승인은 내용에 대한 것이고, 브랜치는 승인 대상이 아닙니다.</p>
<p>게이트는 이렇게 세 겹이 됩니다.</p>
<pre><code>Write(add.py) 도착
   │
   ├─ ① 블랙리스트?     → rm, bash -c ...        → 차단
   ├─ ② 보호 브랜치?     → main/master/develop    → 차단  ★ 토큰 있어도
   └─ ③ 토큰 있나?       → 없으면                 → 차단
        └─ 셋 다 통과 → 실행</code></pre><h3 id="62-벽으로-막을-거면-문은-열어둬야-합니다">6.2 벽으로 막을 거면 문은 열어둬야 합니다</h3>
<pre><code>Bash(&quot;git checkout -b feature/add&quot;)   → 통과 ✅  항상. 토큰과 무관하게.
Bash(&quot;git switch -c feature/add&quot;)     → 통과 ✅  항상.</code></pre><p>일부러 뚫어놓은 예외입니다. 없으면 이렇게 돼요.</p>
<pre><code>main에서 Write → 차단. &quot;브랜치 바꾸세요&quot;
Claude: git checkout -b → 차단.       ← 만약 이랬다면
       💀 나갈 방법이 없음. 영원히 갇힘.</code></pre><p>차단 메시지가 &quot;브랜치를 바꾸라&quot;고 안내하는데 브랜치 바꾸는 것도 막으면, 그건 안내가 아니라 조롱이죠.</p>
<h3 id="63-두-경로--소프트가-살아있는지가-여기서-드러납니다">6.3 두 경로 — 소프트가 살아있는지가 여기서 드러납니다</h3>
<p><strong>정상 경로</strong> (소프트 작동)</p>
<pre><code>사용자: &quot;덧셈 만들어줘&quot; [main]
정책 주입: &quot;main이면 plan 첫 단계에 checkout -b를 넣어라&quot;
Claude: EnterPlanMode → 계획:
        1. git checkout -b feature/add    ← 계획에 이미 들어있음
        2. add.py 생성
사용자: Accept ★
Claude: git checkout -b feature/add  → 통과
Claude: Write(add.py)                → 통과
                          ↑ 차단 한 번도 안 일어남</code></pre><p><strong>예외 경로</strong> (Claude가 정책을 흘려봄)</p>
<pre><code>사용자: Accept ★
Claude: Write(add.py)                → 차단 ❌ &quot;main입니다&quot;
Claude: git checkout -b feature/add  → 통과   (차단 메시지 보고)
Claude: Write(add.py)                → 통과   (토큰 살아있음 + 브랜치 안전)
                          ↑ 차단 한 번. 왕복 하나 낭비.</code></pre><p>차이는 왕복 하나뿐이고 결과는 같습니다. 그리고 재승인은 없어요. 토큰은 차단당했다고 죽는 게 아니라 턴이 끝나야 죽으니까요.</p>
<h3 id="64-차단은-요청을-취소하지-않습니다">6.4 차단은 요청을 취소하지 않습니다</h3>
<p>여기서 1.4의 사실이 다시 등장합니다. 차단당한 시점의 뭉치는 이렇게 생겼어요.</p>
<pre><code>[시스템 프롬프트]
[도구 목록]
[정책 텍스트]
[사용자: &quot;덧셈 코드 만들어줘&quot;]              ← ★ 안 사라짐. 맨 앞에 그대로.
[Claude: EnterPlanMode()]        [결과: OK]
[Claude: ExitPlanMode(plan=...)] [결과: Accepted]
[Claude: Write(add.py)]          [결과: ❌ 차단됨: main 브랜치입니다...]
                                              ↑ 방금 추가된 줄</code></pre><p>Claude가 이걸 통째로 다시 읽습니다. &quot;덧셈 코드를 만들어야 하는데 main이라서 막혔구나&quot;가 한눈에 보이죠.</p>
<p>차단은 요청을 취소한 게 아니라 대화에 줄 하나 추가한 것뿐입니다.</p>
<p>사람은 거절당하면 원래 하려던 걸 까먹기도 하잖아요. 하지만 Claude는 기억하는 게 아니라 매번 다시 읽는 것이라서, 대화록에 있는 한 잊지 않습니다. 기억력이 없어서 오히려 안 까먹는 거죠.</p>
<h3 id="65-부수-효과-자동-동기화가-단순해집니다">6.5 부수 효과: 자동 동기화가 단순해집니다</h3>
<p>main에 직접 커밋이 불가능하니 로컬 main은 항상 origin/main의 ancestor입니다. 갈라질 수가 없어요.</p>
<p>덕분에 <code>SessionStart</code> hook의 동기화 로직이 단순해집니다.</p>
<pre><code>1. cwd가 git repo인지 확인 → 아니면 조용히 스킵
2. origin remote / main 브랜치 자동 감지
3. git fetch origin main (timeout 10초)
4. 현재 main이면 git merge --ff-only origin/main
   다른 브랜치면 git branch -f main origin/main</code></pre><p>두 케이스 모두 항상 안전합니다. 충돌할 일이 없거든요. 안전장치 하나가 다른 기능의 전제조건을 만들어준 경우예요.</p>
<hr>
<h2 id="7-로그">7. 로그</h2>
<h3 id="71-git도-transcript도-안-되는-이유">7.1 git도 transcript도 안 되는 이유</h3>
<p>필요한 건 &quot;명령 ↔ 변경&quot;의 연결입니다. &quot;지난달 11일에 뭘 시켰고 어디까지 바뀌었나&quot;를 알고 싶은 거죠.</p>
<p><strong>git</strong>은 뭐가 바뀌었는지 완벽하게 알려줍니다. 그런데 왜 바뀌었는지가 없어요. 무엇을 시켰는지 git은 모릅니다. 커밋 메시지가 있긴 하지만 AI가 지어낸 것이고, 커밋 단위와 명령 단위가 안 맞아요. 커밋 안 한 변경은 아예 안 남고요.</p>
<p><strong>transcript</strong>는 hook에 들어오는 JSON의 <code>transcript_path</code>에 다 들어있습니다. 명령도, 도구 호출도, 결과도요.</p>
<pre><code>{&quot;type&quot;:&quot;message&quot;,&quot;role&quot;:&quot;assistant&quot;,&quot;content&quot;:[{&quot;type&quot;:&quot;text&quot;,&quot;text&quot;:&quot;...&quot;},{&quot;type&quot;:&quot;tool_use&quot;,&quot;id&quot;:&quot;toolu_01A9...&quot;,&quot;name&quot;:&quot;Read&quot;,&quot;input&quot;:{&quot;file_path&quot;:&quot;/home/...&quot;}}],&quot;usage&quot;:{&quot;input_tokens&quot;:15234,...</code></pre><p>사람이 못 읽습니다. 지난달 11일에 뭘 했는지 찾으려고 이걸 열면 그냥 포기하게 되죠.</p>
<p>그러니까 이런 상황이에요. git은 변경은 있는데 명령이 없고, transcript는 다 있는데 읽을 수가 없습니다.</p>
<h3 id="72-정보는-이미-hook을-지나가고-있었습니다">7.2 정보는 이미 hook을 지나가고 있었습니다</h3>
<pre><code>UserPromptSubmit  →  무엇을 시켰는지            ← 명령
PostToolUse       →  어떤 도구가 뭘 바꿨는지 + 결과  ← 변경
Stop              →  턴이 여기서 끝났음          ← 경계</code></pre><p>게이트를 만들려고 꽂은 hook인데, 로그에 필요한 정보가 전부 거기를 지나갑니다. 로그를 위해 새로 만든 게 없어요. 이미 지나가고 있던 걸 받아적었을 뿐입니다.</p>
<p>모든 게 한 곳을 지나가게 만들면 막을 수도 있고 적을 수도 있는 거죠.</p>
<h3 id="73-왜-두-형식으로-중복해서-적을까요">7.3 왜 두 형식으로 중복해서 적을까요</h3>
<pre><code>events.jsonl      ← 기계가 읽는 형식
conversation.md   ← 사람이 읽는 형식</code></pre><p>중복입니다. 용량도 두 배고, 하나 고치면 둘 다 고쳐야 해요. 개발자 본능으로는 &quot;하나로 합쳐&quot;가 맞습니다.</p>
<p>읽는 주체가 다르고 요구가 정반대이기 때문이에요.</p>
<table>
<thead>
<tr>
<th>기계가 원하는 것</th>
<th></th>
<th>사람이 원하는 것</th>
</tr>
</thead>
<tbody><tr>
<td>전부 다 있어야 함</td>
<td>↔</td>
<td>다 있으면 못 읽음</td>
</tr>
<tr>
<td>스키마 고정</td>
<td>↔</td>
<td>읽기 좋으면 됨</td>
</tr>
<tr>
<td>한 줄 = 한 이벤트</td>
<td>↔</td>
<td>덩어리로 묶여야 함</td>
</tr>
<tr>
<td>grep, jq로 처리</td>
<td>↔</td>
<td>눈으로 훑음</td>
</tr>
</tbody></table>
<p>하나로 합치면 반드시 둘 중 하나가 죽습니다.</p>
<ul>
<li>전부 다 넣으면 → transcript를 다시 만든 겁니다. 못 읽어요.</li>
<li>읽기 좋게 줄이면 → 기계가 쓸 정보가 사라집니다.</li>
</ul>
<p>그리고 중복 걱정은 안 해도 됩니다.</p>
<pre><code class="language-python"># post_tool_use.py — 같은 자리에서 같은 데이터로 둘 다 씀
event = { &quot;ts&quot;: now(), &quot;type&quot;: &quot;file_changed&quot;, &quot;path&quot;: &quot;add.py&quot;, ... }

append(&quot;events.jsonl&quot;, json.dumps(event))
append(&quot;conversation.md&quot;, f&quot;### {now()} 파일 변경\n- add.py\n&quot;)</code></pre>
<p>소스가 하나예요. 어긋날 수가 없습니다. 두 번 적는 게 아니라 한 사건을 두 가지로 그리는 거죠. 사진 한 장을 원본과 썸네일로 저장하는 것과 같습니다.</p>
<p><strong>원칙 6. 로그는 두 형태로 동시에 남긴다. JSONL과 사람이 읽기 좋은 마크다운.</strong></p>
<h3 id="74-indexmd--입구가-없으면-원본은-없는-것과-같습니다">7.4 index.md — 입구가 없으면 원본은 없는 것과 같습니다</h3>
<pre><code>~/.harness/logs/
├── 2026-06-11/
│   └── abc123/
│       ├── events.jsonl       # 모든 이벤트(타임스탬프)
│       ├── conversation.md    # 사람이 읽는 대화 + plan + diff 요약
│       ├── changes.json       # {파일경로: [변경 시각, hash, 줄수 변화]}
│       └── diffs/&lt;n&gt;.patch    # PostToolUse에서 떨어지는 diff
├── 2026-06-12/
│   └── def456/
└── index.md          ★</code></pre><p>날짜는 폴더 이름에 이미 있습니다. 그런데 날짜를 모를 때가 문제죠. &quot;auth 관련 뭘 건드렸는데... 언제였지?&quot;</p>
<p>실제로 검색할 때 머릿속에 있는 건 &quot;auth&quot;, &quot;댓글&quot;, &quot;validation&quot; 같은 것들입니다. 그럼 그게 한 줄에 있어야 해요.</p>
<pre><code class="language-markdown"># index.md
- 2026-06-11 14:21 / 덧셈 함수 추가 / add.py, tests/test_add.py / logs/2026-06-11/abc123/
- 2026-06-12 09:03 / 회원가입 validation 수정 / auth/validators.py / logs/2026-06-12/def456/
- 2026-06-12 16:40 / 댓글 도메인 삭제 / comment/*.java (12개) / logs/2026-06-12/ghi789/</code></pre>
<pre><code>언제 / 무엇을 시켰나 / 어떤 파일이 바뀌었나 / 어디를 보면 되나
        └─ 검색 키워드 ─┘</code></pre><pre><code class="language-bash">$ grep auth ~/.harness/logs/index.md
- 2026-06-12 09:03 / 회원가입 validation 수정 / auth/validators.py / logs/2026-06-12/def456/</code></pre>
<p>한 방에 나옵니다.</p>
<p>한 줄이어야 하는 이유는 grep이 줄 단위로 찾기 때문이에요. 한 줄에 다 있으면 검색 결과가 곧 답입니다. 두 줄로 나누면 반쪽만 나오죠.</p>
<p>한 파일이어야 하는 이유도 있습니다. 폴더마다 흩어놓으면 <code>grep -r</code>을 해야 하고, 결과가 지저분하고, 시간순 정렬도 안 돼요. 모여 있어야 훑을 수 있습니다.</p>
<p>이 줄은 <code>SessionEnd</code> hook이 씁니다. 세션이 끝날 때 전체를 요약해서 한 줄 붙여요.</p>
<p>로그 전체는 층을 이룹니다.</p>
<pre><code>index.md          →  한 줄.    &quot;언제 뭘 시켰나&quot;       ← 여기서 찾는다
conversation.md   →  한 페이지. &quot;어떻게 흘러갔나&quot;      ← 여기서 읽는다
diffs/*.patch     →  실제 변경. &quot;정확히 뭐가 바뀌었나&quot;  ← 여기서 확인한다
events.jsonl      →  전부.     기계용</code></pre><p>위로 갈수록 요약, 아래로 갈수록 원본입니다. 위에서 찾아서 아래로 파고들어요.</p>
<p>transcript는 맨 아래 층만 있었던 겁니다. 원본은 있는데 입구가 없었던 거죠.</p>
<hr>
<h2 id="8-권한-시스템-통합--exit-0은-통과가-아니라-기권입니다">8. 권한 시스템 통합 — exit 0은 통과가 아니라 기권입니다</h2>
<h3 id="81-승인했는데-또-묻습니다">8.1 승인했는데 또 묻습니다</h3>
<p>계획을 세우고 Accept를 눌러서 토큰이 발행됐습니다. 그런데요.</p>
<pre><code>Claude: Bash(&quot;mkdir comment_backup&quot;)
   → PreToolUse: 토큰 있음 → exit 0 → 통과 ✅
   → 🔔 &quot;Do you want to proceed?&quot;     ← ???

Claude: Bash(&quot;rm comment/Controller.java&quot;)  → 🔔 &quot;Do you want to proceed?&quot;
Claude: Bash(&quot;rm comment/Service.java&quot;)     → 🔔 &quot;Do you want to proceed?&quot;
... 30번</code></pre><p>댓글 도메인 삭제 한 번에 30번 물었습니다. 그럼 어떻게 될까요? y y y y y y. 안 읽고 누릅니다. 30번째쯤 되면 뭘 승인하는지 보지도 않아요.</p>
<p>게이트가 있는데 사람이 눈 감고 통과시키는 셈이죠.</p>
<h3 id="82-원인-게이트가-두-겹이고-서로를-모릅니다">8.2 원인: 게이트가 두 겹이고 서로를 모릅니다</h3>
<pre><code>Claude: Bash(&quot;rm ...&quot;)
   ↓
┌──────────────────────────┐
│ ① PreToolUse hook (하네스) │  → 토큰 확인 → exit 0 통과 ✅
└──────────────────────────┘
   ↓
┌──────────────────────────┐
│ ② Claude Code 내장 권한    │  → &quot;Bash? 위험한데?&quot; → 🔔 물어봄
└──────────────────────────┘</code></pre><p>②는 Anthropic이 만든 내장 기능입니다. hook이 아니에요. 대부분의 사용자는 hook을 안 짜니까, 아무 설정 없이도 최소한의 안전이 되도록 Bash 같은 도구는 기본으로 묻게 되어 있습니다.</p>
<pre><code>hook            = Anthropic이 뚫어놓은 콘센트. 사용자가 플러그를 꽂습니다.
내장 권한 시스템  = 프로그램에 박혀 있는 기능 자체. 손댈 수 없습니다.</code></pre><p>②는 ①이 뭘 했는지 모릅니다. 30분 걸려 계획을 세우고 승인받고 토큰을 발행했다는 걸 전혀 몰라요. 걔 입장에선 그냥 &quot;Bash가 하나 왔네? 물어봐야지&quot;입니다.</p>
<p>그리고 여기가 핵심이에요. <code>exit 0</code>의 의미가 뭐였을까요? &quot;통과시켜&quot;가 아니라 &quot;나는 반대 안 해&quot;입니다.</p>
<p>기권이었던 거죠. hook이 반대하지 않았으니 다음 단계로 넘어가고, 다음 단계는 자기 일을 합니다. ①에게는 거부권만 있었고 허가권이 없었어요.</p>
<h3 id="83-해결-pretooluse-json-출력">8.3 해결: PreToolUse JSON 출력</h3>
<pre><code class="language-python"># pre_tool_use.py
if 토큰_있음:
    print(json.dumps({
      &quot;hookSpecificOutput&quot;: {
        &quot;hookEventName&quot;: &quot;PreToolUse&quot;,
        &quot;permissionDecision&quot;: &quot;allow&quot;,
        &quot;permissionDecisionReason&quot;: &quot;Harness: token_present&quot;
      }
    }))
sys.exit(0)</code></pre>
<p>이 JSON을 뱉으면 Claude Code가 ②를 건너뜁니다.</p>
<pre><code>exit 0              → &quot;나는 반대 안 해&quot;      → ②가 또 물음
JSON allow + exit 0 → &quot;내가 책임지고 허가함&quot;  → ②를 스킵 ★</code></pre><p>기권에서 결정으로 바뀐 겁니다. 계획 전체를 이미 승인받았으니 하네스가 Claude Code보다 더 많이 알고 있잖아요. 그러니 판단을 가져오는 게 맞습니다. 그리고 이 JSON은 토큰이 있을 때만 뱉어요. 토큰 없으면 exit 2로 막습니다. 안전망은 그대로예요.</p>
<p>두 층으로 정리하면 이렇습니다.</p>
<pre><code>[기계 층]  하네스 JSON allow  →  Claude Code 내장 프롬프트 무효화
[사람 층]  승인 31번           →  1번으로 압축</code></pre><pre><code>전:  계획 승인 1번  +  도구마다 승인 30번  =  31번
후:  계획 승인 1번                        =  1번</code></pre><p>30번이 사라진 게 아니라 1번으로 합쳐졌습니다. 여전히 승인해요. 다만 <code>rm Controller.java</code> 하나를 보고 승인하는 게 아니라 계획 전체를 보고 승인합니다.</p>
<p>그리고 이게 오히려 나은 승인이에요. <code>rm Controller.java</code> 한 줄만 보고 이게 맞는 삭제인지 판단할 수 있나요? 없습니다. 30번의 무의미한 승인보다 1번의 의미 있는 승인이 낫죠. 30번짜리는 어차피 y y y y니까요.</p>
<p><strong>원칙 9. 하나의 plan 승인 = 턴 전체의 실행 위임.</strong></p>
<hr>
<h2 id="9-plan-가시성--형식은-통과했는데-내용이-없었습니다">9. Plan 가시성 — 형식은 통과했는데 내용이 없었습니다</h2>
<h3 id="91-계획이-한-줄로-왔습니다">9.1 계획이 한 줄로 왔습니다</h3>
<pre><code>┌────────────────────────────────┐
│  Claude가 다음 계획을 제안합니다:  │
│  comment 도메인을 삭제합니다.     │
│     [ Accept ]   [ Reject ]    │
└────────────────────────────────┘</code></pre><p>계획은 봤습니다. 그런데 판단이 되나요?</p>
<ul>
<li>파일 몇 개가 지워지나요?</li>
<li>다른 데서 comment를 참조하고 있으면요?</li>
<li>DB 테이블도 지우나요?</li>
<li>잘못됐으면 어떻게 되돌리죠?</li>
</ul>
<p>하나도 모릅니다. v1.6에서 실제로 터진 문제예요. 계획도 있었고 승인도 있었고 게이트도 통과했습니다. 내용이 없었을 뿐이죠.</p>
<p>게이트를 통과했는데 아무것도 막지 못한 겁니다.</p>
<h3 id="92-계획-형식을-강제합니다">9.2 계획 형식을 강제합니다</h3>
<p><code>POLICY_TEXT</code>에 4섹션을 못 박았습니다.</p>
<pre><code>## What will change
- comment/CommentController.java 삭제 (전체)
- comment/CommentService.java 삭제 (전체)
- post/PostService.java:45-52  getComments() 메서드 제거
- post/PostService.java:8      import comment.CommentService 제거
- Bash: rm -rf src/main/java/com/app/comment/
- SQL: DROP TABLE comments;

## WARNING: DESTRUCTIVE
- comments 테이블 DROP — 기존 댓글 데이터 전부 소실
- 파일 12개 삭제

## Risks
- PostService가 comment를 참조 중. 컴파일 에러 가능
- 프론트엔드 /api/comments 호출이 404

## Rollback
- git checkout -b 이전 브랜치로 복귀
- DB: 백업 없음. 복구 불가.</code></pre><p>파일별로 몇 번째 줄인지, Bash 명령은 그대로, SQL 본문도 그대로 적으라고 강제해요.</p>
<p>이제 <code>DROP TABLE comments;</code>를 직접 눈으로 봅니다. Rollback에 &quot;복구 불가&quot;라고 적혀 있고요. 여기서 Reject를 누를 수 있죠.</p>
<p>승인의 질은 계획의 구체성으로 결정됩니다. 추상적인 계획에 대한 승인은 승인이 아니라 서명일 뿐이에요.</p>
<h3 id="93-못-고치는-부품에-의존하지-않습니다">9.3 못 고치는 부품에 의존하지 않습니다</h3>
<p>문제가 하나 더 있습니다. 저 긴 계획이 다이얼로그에 다 들어갈까요?</p>
<p>안 들어갑니다. 잘리거나, 스크롤이 안 되거나, 앞부분만 보이거나 — 환경마다, 터미널 크기마다 달라요.</p>
<pre><code>┌────────────────────────────────┐
│  ## What will change           │
│  - comment/CommentController...│
│  - post/PostService.java:45... │
│  ...                           │   ← 여기서 잘림
│     [ Accept ]   [ Reject ]    │
└────────────────────────────────┘</code></pre><p><code>## WARNING: DESTRUCTIVE</code>가 안 보입니다. <code>DROP TABLE comments;</code>도 안 보이고요. 제일 중요한 게 잘렸죠.</p>
<p>그리고 저 창은 Claude Code가 그립니다. hook이 아니에요. 손댈 수가 없습니다.</p>
<p>그래서 창을 고치는 걸 포기했어요. 대신 정책에 이걸 넣었습니다.</p>
<blockquote>
<p>&quot;ExitPlanMode를 호출하기 직전에, 계획 전문을 채팅 텍스트로 먼저 출력하라.&quot;</p>
</blockquote>
<p>화면은 이렇게 됩니다.</p>
<pre><code>사용자: 댓글 기능 삭제해줘

Claude: 계획을 세웠습니다.

  ## What will change
  - comment/CommentController.java 삭제
  - SQL: DROP TABLE comments;
  ## WARNING: DESTRUCTIVE
  - comments 테이블 DROP — 댓글 데이터 전부 소실
  ## Rollback
  - DB 백업 없음. 복구 불가.
                          ↑ ① 그냥 채팅 글. 여기서 읽습니다.

  ┌────────────────────────────────┐
  │  Claude가 계획을 제안합니다:      │
  │  comment 도메인 삭제...          │  ← ② 잘려도 상관없습니다
  │     [ Accept ]   [ Reject ]    │
  └────────────────────────────────┘
                          ↑ ② 여기서 누릅니다</code></pre><p>①에서 읽고 ②에서 누릅니다. 역할이 나뉜 거죠.</p>
<pre><code>채팅 글    →  보여주는 용도   (전문이 다 보임)
다이얼로그  →  승인받는 용도   (버튼만 있으면 됨)</code></pre><p>승인은 여전히 다이얼로그로만 받습니다. 이건 안 바꿔요. 만약 &quot;채팅에서 그냥 답하게 하자&quot;고 하면 5.1의 자연어 지옥으로 되돌아가니까요.</p>
<p>정보는 채팅으로, 신호는 버튼으로 받습니다. 읽는 건 사람이 하는 거니까 사람이 읽기 좋은 곳으로 보내고, 승인은 기계가 판정해야 하니 기계가 확실히 아는 곳으로 받아요. 섞으면 둘 다 망가집니다.</p>
<p><strong>원칙 8. 사용자에게 보여줄 plan은 채팅에 직접 출력하도록 강제한다.</strong></p>
<p>못 고치는 부품에 의존하지 말고, 고칠 수 있는 걸로 대체한다는 이야기이기도 하죠.</p>
<hr>
<h2 id="10-사일런트-실패--정책이-한-번도-도달하지-않았습니다">10. 사일런트 실패 — 정책이 한 번도 도달하지 않았습니다</h2>
<p>v1.5에서 발견한, 가장 잡기 어려웠던 버그입니다.</p>
<p>PowerShell 5.x와 <code>python script.py</code>로 실행되는 hook 프로세스의 <code>sys.stdout</code>/<code>sys.stderr</code>는 기본적으로 시스템 코드페이지(한국어 Windows에서는 cp949)로 설정됩니다. 정책 텍스트에 있는 <code>★</code>, <code>↓</code>, em-dash(<code>—</code>), 한글 같은 cp949에 없는 문자를 출력하면 <code>UnicodeEncodeError</code>가 나요.</p>
<p>문제는 hook의 try/except가 이 예외를 조용히 삼키고 exit 0을 반환한다는 겁니다.</p>
<pre><code>stdout: 0 바이트
stderr: 0 바이트
exit:   0</code></pre><p>겉보기에 완벽하게 정상입니다. 그런데 정책 텍스트는 모델에 도달하지 않아요.</p>
<p>이 상태가 정확히 &quot;소프트가 없는 상태&quot;입니다. 3.5에서 본 그것이죠. 안전은 지켜지는데 매 요청마다 벽에 한 번씩 박고 있었던 거예요. 그리고 아무도 그걸 몰랐습니다. 하드 게이트가 조용히 일을 잘 해주고 있었으니까요.</p>
<pre><code class="language-python">import io
try:
    sys.stdout.reconfigure(encoding=&quot;utf-8&quot;)
except Exception:
    try:
        sys.stdout = io.TextIOWrapper(
            sys.stdout.buffer, encoding=&quot;utf-8&quot;,
            errors=&quot;replace&quot;, line_buffering=True,
        )
    except Exception:
        pass
# stderr도 동일하게</code></pre>
<p>모든 hook 스크립트 상단에 이 부트스트랩을 둡니다. install 스크립트(.ps1) 자체도 PowerShell 5.x가 BOM 없는 UTF-8을 cp949로 잘못 읽으니, UTF-8 BOM으로 저장하고 <code>[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()</code> + <code>chcp 65001</code>을 상단에서 실행합니다.</p>
<p>여기서 이중 안전망의 부작용이 하나 드러납니다. 하나가 죽어도 티가 안 나요. 하드가 소프트의 죽음을 덮어버렸으니까요. 안전망을 겹칠 때는 각 층이 살아있는지 따로 확인할 방법도 같이 만들어야 합니다.</p>
<hr>
<h2 id="11-마지막-한-칸">11. 마지막 한 칸</h2>
<p>지금까지 만든 것을 다시 볼게요.</p>
<pre><code>블랙리스트         →  계획을 세우게 하려고
토큰              →  승인을 받으려고
4섹션 포맷         →  계획을 읽을 만하게 하려고
채팅에 전문 출력    →  계획이 잘려서 안 보이는 걸 막으려고</code></pre><p>전부 &quot;사용자가 계획을 읽는다&quot;는 전제 위에 서 있습니다. 그 전제가 깨지면 나머지가 통째로 의미를 잃어요.</p>
<p>그리고 hook은 이걸 막을 수가 없습니다.</p>
<pre><code>3초 만에 Accept       → PostToolUse: &quot;Accept 왔네&quot; → 토큰 발행
5분 정독하고 Accept    → PostToolUse: &quot;Accept 왔네&quot; → 토큰 발행</code></pre><p>구분이 안 됩니다. hook은 사람의 눈이 어디를 봤는지 모르니까요. 코드로 해결할 수 있는 문제가 아니에요.</p>
<p>그래서 이건 기술이 아니라 수칙으로 남겼습니다.</p>
<blockquote>
<p>&quot;plan 본문이 안 보이거나 부실하면 무조건 No.&quot;</p>
</blockquote>
<p>하네스가 할 수 있는 건 읽을 수 있게 만드는 것까지입니다. 읽는 건 사람이 해요.</p>
<p>자동화의 마지막 한 칸은 늘 사람입니다. 그 칸까지 자동화하려고 하면, 자동화가 아니라 자동 승인이 되죠.</p>
<hr>
<h2 id="12-정리--결정과-이유">12. 정리 — 결정과 이유</h2>
<table>
<thead>
<tr>
<th>결정</th>
<th>왜</th>
</tr>
</thead>
<tbody><tr>
<td>신호를 tool_use에서 받는다</td>
<td>자연어는 언어·변이형·오탐·인젝션으로 반드시 깨집니다. tool_use는 구조체라 그 문제가 없어요</td>
</tr>
<tr>
<td>게이트는 PreToolUse 한 곳</td>
<td>실행 <strong>전</strong>에 있는 hook이 거기 하나뿐입니다. 나머지는 물리적으로 못 막아요</td>
</tr>
<tr>
<td>블랙리스트</td>
<td>화이트리스트는 안전 문제가 아니라 일이 안 돼서 실패합니다. 꺼진 게이트의 안전성은 0이고요</td>
</tr>
<tr>
<td>첫 단어만 검사</td>
<td>통짜 검색은 <code>firm</code>, <code>format</code>, <code>confirm</code>에 오탐이 납니다. 셸에서 첫 단어가 명령어 자리예요</td>
</tr>
<tr>
<td>명령 체이닝 분해</td>
<td>게이트가 보는 명령과 셸이 실행하는 명령이 다르면 그 차이가 곧 구멍입니다</td>
</tr>
<tr>
<td>동적 실행은 형태로 차단</td>
<td>문자열이 뭐가 될지는 실행 전엔 원리적으로 알 수 없어요. 판단 불가면 판단하지 않고 막습니다</td>
</tr>
<tr>
<td>승인은 ExitPlanMode 다이얼로그</td>
<td>클릭에는 언어가 없습니다. 이미 있던 네이티브 채널을 전용했어요</td>
</tr>
<tr>
<td>토큰은 파일 하나</td>
<td>위조할 상대가 없으니 암호가 필요 없습니다. 지킬 필요 없는 구조를 만든 거죠</td>
</tr>
<tr>
<td>토큰은 한 턴만</td>
<td>승인한 건 &quot;Claude&quot;가 아니라 &quot;이 계획&quot;입니다. 계획이 끝나면 승인도 끝나요</td>
</tr>
<tr>
<td>Stop은 무조건 삭제</td>
<td>상태를 추적하면 그 추적이 깨질 때 토큰이 살아남습니다. 중복은 공짜, 누락은 사고예요</td>
</tr>
<tr>
<td>보호 브랜치 &gt; 토큰</td>
<td>승인은 내용에 대한 것이지 브랜치에 대한 게 아닙니다</td>
</tr>
<tr>
<td>checkout -b는 항상 통과</td>
<td>벽으로 막을 거면 문은 열어둬야 합니다. 아니면 데드락이에요</td>
</tr>
<tr>
<td>로그는 두 형식</td>
<td>기계와 사람의 요구가 정반대입니다. 합치면 둘 중 하나가 죽어요</td>
</tr>
<tr>
<td>index.md 한 줄</td>
<td>grep은 줄 단위로 찾습니다. 입구 없는 원본은 없는 것과 같고요</td>
</tr>
<tr>
<td>JSON permissionDecision</td>
<td><code>exit 0</code>은 통과가 아니라 기권이었습니다. 거부권만 있고 허가권이 없었어요</td>
</tr>
<tr>
<td>plan 4섹션 강제</td>
<td>추상적인 계획에 대한 승인은 승인이 아니라 서명입니다</td>
</tr>
<tr>
<td>plan을 채팅에 출력</td>
<td>다이얼로그는 손댈 수 없는 부품이에요. 고칠 수 있는 걸로 대체합니다</td>
</tr>
<tr>
<td>하드 + 소프트</td>
<td>소프트만 있으면 안전하지 않고, 하드만 있으면 쓸 수가 없습니다</td>
</tr>
</tbody></table>
<p>이 모든 게 하나의 사실 위에 서 있습니다. 클로드의 머리와 손은 각각 다른 컴퓨터에 있어요. 그래서 둘 사이에 틈이 있고, 하네스는 그 틈에서만 존재할 수 있습니다.</p>
<p>v2는 이 기반 위에서 시작합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[하네스 설계 (+ omc)]]></title>
            <link>https://velog.io/@k_joon_/%ED%95%98%EB%84%A4%EC%8A%A4-%EC%84%A4%EA%B3%84-omc</link>
            <guid>https://velog.io/@k_joon_/%ED%95%98%EB%84%A4%EC%8A%A4-%EC%84%A4%EA%B3%84-omc</guid>
            <pubDate>Wed, 03 Jun 2026 07:29:25 GMT</pubDate>
            <description><![CDATA[<h1 id="개인-하네스-설계">개인 하네스 설계</h1>
<hr>
<h2 id="1-개념">1. 개념</h2>
<h3 id="11-배경-및-전체-구조">1.1 배경 및 전체 구조</h3>
<p>나는 Claude Code를 단순한 코드 생성 도구가 아니라, OMC(oh-my-claudecode)와 결합된 AI 개발 워크플로우의 일부로 사용한다.</p>
<ul>
<li><strong>Claude Code</strong>: 코드 읽기, 파일 수정, 명령 실행, hook, transcript 등 AI 개발 런타임 제공</li>
<li><strong>OMC</strong>: 위 런타임 위에서 planner, architect, executor, reviewer, verifier 같은 역할별 agent와 <code>/plan</code>, <code>/team</code>, <code>/ralph</code>, <code>/ultraqa</code> 같은 workflow를 제공하는 multi-agent orchestration layer</li>
<li><strong>개인 하네스</strong>: OMC가 강제하지 않는 계획 승인 / 추적 가능한 로그 / 안전한 git 워크플로우를 강제로 보장하는 제어 계층</li>
</ul>
<p>전체 흐름은 다음과 같다.</p>
<pre><code>요청 → Claude Code → OMC → 개인 하네스 → 서버 구현 결과</code></pre><h3 id="12-claude-code-동작-메커니즘">1.2 Claude Code 동작 메커니즘</h3>
<p>하네스 설계의 모든 결정은 Claude Code의 실제 동작 메커니즘을 정확히 이해한 위에서 내려졌다.</p>
<h4 id="121-모델-응답의-구성">1.2.1 모델 응답의 구성</h4>
<p>Claude(모델)는 매 턴마다 다음을 입력으로 받는다.</p>
<ul>
<li>시스템 프롬프트</li>
<li>지금까지의 대화 전체</li>
<li>사용 가능한 도구 목록(스키마 포함)</li>
<li>사용자의 새 메시지</li>
</ul>
<p>모델은 이걸 보고 응답 스트림을 만든다. 응답은 두 종류 블록으로 구성된다.</p>
<ul>
<li><strong>text 블록</strong>: 채팅에 보이는 글</li>
<li><strong>tool_use 블록</strong>: &quot;Edit 도구를 이런 인자로 호출해 달라&quot;는 함수 호출</li>
</ul>
<h4 id="122-도구-호출-흐름">1.2.2 도구 호출 흐름</h4>
<p>모델이 <code>Edit(file_path=..., old_string=..., new_string=...)</code> 같은 tool_use를 내뱉으면, Claude Code 런타임이 가로채서 다음 순서로 처리한다.</p>
<ol>
<li><strong>PreToolUse hook 실행</strong> — JSON으로 <code>{tool_name, tool_input, session_id, transcript_path, cwd}</code> 전달<ul>
<li>exit 0 → 통과</li>
<li>exit 2 + stderr 메시지 → 차단 + 그 메시지를 모델에게 tool_result로 돌려줌</li>
<li>JSON 출력으로 입력 수정/우회/권한 결정 가능</li>
</ul>
</li>
<li>통과되면 실제 도구 실행</li>
<li><strong>PostToolUse hook 실행</strong> — <code>tool_response</code>까지 포함된 JSON 받음</li>
<li>결과를 모델에게 tool_result로 반환</li>
</ol>
<p>tool_use가 없는 순수 text 응답이 나오면 턴 종료. 그 시점에 <strong>Stop hook 실행</strong>.</p>
<p>또한 사용자 입력이 들어올 때마다 <strong>UserPromptSubmit hook</strong>이 실행된다. 이 hook이 stdout으로 출력한 내용은 <strong>모델 컨텍스트에 추가 주입</strong>된다. 매 턴 정책 텍스트를 모델에 주입하는 채널.</p>
<p>세션이 시작될 때는 <strong>SessionStart hook</strong>이 실행된다. Claude Code를 새로 켤 때 자동으로 git 동기화 같은 초기 작업을 수행하는 채널.</p>
<h4 id="123-주요-도구-분류">1.2.3 주요 도구 분류</h4>
<table>
<thead>
<tr>
<th>도구</th>
<th>하는 일</th>
<th>위험도</th>
</tr>
</thead>
<tbody><tr>
<td>Read</td>
<td>파일 읽기</td>
<td>안전</td>
</tr>
<tr>
<td>Glob</td>
<td>파일 경로 패턴 검색</td>
<td>안전</td>
</tr>
<tr>
<td>Grep</td>
<td>파일 내용 검색</td>
<td>안전</td>
</tr>
<tr>
<td>Edit</td>
<td>파일 일부 수정</td>
<td>위험</td>
</tr>
<tr>
<td>Write</td>
<td>파일 통째 쓰기</td>
<td>위험</td>
</tr>
<tr>
<td>MultiEdit</td>
<td>한 파일 여러 군데 수정</td>
<td>위험</td>
</tr>
<tr>
<td>NotebookEdit</td>
<td>주피터 노트북 셀 수정</td>
<td>위험</td>
</tr>
<tr>
<td>Bash</td>
<td>셸 명령 실행</td>
<td>가변</td>
</tr>
<tr>
<td>WebFetch / WebSearch</td>
<td>외부 자료 가져오기</td>
<td>안전</td>
</tr>
<tr>
<td>Task / Agent</td>
<td>서브에이전트 호출</td>
<td>가변</td>
</tr>
<tr>
<td>Skill / ToolSearch</td>
<td>메타 (스킬 호출, 도구 검색)</td>
<td>안전</td>
</tr>
<tr>
<td>TodoWrite</td>
<td>자기 할 일 관리</td>
<td>안전</td>
</tr>
</tbody></table>
<h4 id="124-plan-mode-enterplanmode-와-exitplanmode">1.2.4 Plan Mode: EnterPlanMode 와 ExitPlanMode</h4>
<p>Claude Code에는 빌트인 <strong>plan mode</strong>가 있다. plan mode는 두 개의 도구로 제어된다.</p>
<ul>
<li><strong><code>EnterPlanMode</code></strong>: 모델이 plan mode로 진입할 때 호출. 인자 없음. 호출 후 모델은 read-only 도구와 <code>ExitPlanMode</code> 외에는 호출하지 못한다.</li>
<li><strong><code>ExitPlanMode(plan=&quot;...&quot;)</code></strong>: 모델이 계획을 인자로 넣어 호출하면, Claude Code UI가 <strong>승인 다이얼로그</strong>를 띄운다.<ul>
<li>Accept → plan mode 해제, Edit/Write 등 가능</li>
<li>Reject → plan mode 유지</li>
</ul>
</li>
</ul>
<p>이 두 도구는 자연어 분류 없이 명시적으로 승인을 받을 수 있는 네이티브 채널이다.</p>
<h3 id="13-문제-인식">1.3 문제 인식</h3>
<p>OMC와 Claude Code 기본 기능만으로는 다음 여섯 가지가 보장되지 않는다.</p>
<h4 id="131-계획-단계의-비강제성">1.3.1 계획 단계의 비강제성</h4>
<p>OMC의 <code>/plan</code>은 호출했을 때만 실행된다. &quot;AI에게 코드 변경 전 무조건 계획을 세우라&quot;는 게이트는 없다. 팀원이 &quot;@@ 기능 구현해줘&quot;라고 한 줄 던지면 AI가 곧바로 코드를 수정한다.</p>
<h4 id="132-도구-호출-우회-가능성-bash">1.3.2 도구 호출 우회 가능성 (Bash)</h4>
<p><code>Edit</code>/<code>Write</code>만 막아도 모델은 <code>Bash</code>로 우회해 파일을 변경할 수 있다.</p>
<pre><code class="language-bash">echo &#39;new content&#39; &gt; auth.py
sed -i &#39;s/old/new/g&#39; *.py
cat &gt; config.py &lt;&lt; EOF ... EOF</code></pre>
<p>따라서 Bash도 함께 통제해야 코드 수정 차단이 의미 있다.</p>
<h4 id="133-자연어-승인-신호의-한계">1.3.3 자연어 승인 신호의 한계</h4>
<p>&quot;진행해&quot;, &quot;ㅇㅋ&quot;, &quot;approve&quot; 같은 자연어를 승인 신호로 쓰면 다음 문제가 생긴다.</p>
<ul>
<li>언어 의존성 (다른 언어 사용 시 깨짐)</li>
<li>오탐 (의도와 다른 문장 매칭)</li>
<li>AI 인젝션 위험 (AI 출력 텍스트에 &quot;approve&quot; 포함 가능)</li>
<li>모호성 (&quot;좋아 그렇게 해&quot; 같은 판단 불가 표현)</li>
</ul>
<p><code>/approve</code> 같은 슬래시 커맨드는 명시적이지만 <strong>그걸 자발적으로 칠 사람은 어차피 계획 세울 사람이다</strong>. 정작 막아야 할 대상은 &quot;@@해줘&quot; 한 줄 던지고 결과만 기다리는 습관이라, 이 사람은 <code>/approve</code>를 안 친다.</p>
<h4 id="134-로그의-추적성-부족">1.3.4 로그의 추적성 부족</h4>
<p>OMC의 <code>.omc/sessions</code>, <code>.omc/state</code>는 agent lifecycle 중심이라 &quot;내가 언제 뭘 시켰고 어떤 파일이 바뀌었나&quot;를 시간순으로 빠르게 보기 어렵다. Claude Code의 <code>transcript_path</code>는 모든 정보가 들어있지만 사람이 읽기엔 부담스럽다.</p>
<h4 id="135-권한-시스템-이중-프롬프트">1.3.5 권한 시스템 이중 프롬프트</h4>
<p>Claude Code는 Bash 같은 위험 도구에 매번 별도의 권한 다이얼로그를 띄운다. 우리가 plan을 승인했어도 Claude Code의 built-in 권한 시스템은 그걸 모르고 또 묻는다. plan 한 번 승인했는데 작업 30개 중 매번 &quot;Do you want to proceed?&quot;가 뜨면 plan 승인의 의미가 깎인다.</p>
<h4 id="136-main-브랜치-직접-변경">1.3.6 main 브랜치 직접 변경</h4>
<p>git workflow에서 main에 직접 commit/push는 금기다. 하지만 AI는 그 규칙을 모르고 그냥 main에서 작업한다. 사용자가 PR 워크플로우를 쓰는데 AI가 main을 오염시키면 곤란하다.</p>
<h3 id="14-핵심-설계-원칙">1.4 핵심 설계 원칙</h3>
<ol>
<li><strong>신호는 사용자 텍스트가 아니라 모델의 tool_use에서 받는다.</strong> 자연어 분류는 깨진다.</li>
<li><strong>게이트는 PreToolUse hook 한 곳에 집중한다.</strong></li>
<li><strong>차단 정책은 블랙리스트로 간다.</strong></li>
<li><strong>승인은 Claude Code의 네이티브 메커니즘(EnterPlanMode + ExitPlanMode)을 활용한다.</strong></li>
<li><strong>승인 토큰은 한 턴에만 유효하다.</strong></li>
<li><strong>로그는 두 형태로 동시에 남긴다.</strong> JSONL과 사람이 읽기 좋은 마크다운.</li>
<li><strong>하드 게이트(차단)와 소프트 유도(정책 주입)를 결합한다.</strong></li>
<li><strong>사용자에게 보여줄 plan은 채팅에 직접 출력하도록 강제한다.</strong></li>
<li><strong>하나의 plan 승인 = 턴 전체의 실행 위임.</strong> 토큰 발행 후엔 Claude Code의 네이티브 권한 프롬프트까지 우회한다.</li>
<li><strong>보호 브랜치 정책은 토큰보다 상위 게이트다.</strong> main / master / develop에선 토큰이 있어도 코드 변경을 차단한다.</li>
</ol>
<hr>
<h2 id="2-v1-구현">2. v1 구현</h2>
<h3 id="21-설계-결정의-출발점-하드코딩이-아닌-강제">2.1 설계 결정의 출발점: 하드코딩이 아닌 강제</h3>
<p>이 절은 v1의 모든 구현 결정 위에 깔린 <strong>출발점</strong>이다. 처음에는 &quot;특정 단어를 승인 신호로 본다&quot;는 자연어 규칙 기반 접근을 검토했지만, 그것이 본질적으로 깨지는 방식이라는 인식에서 v1의 방향이 정해졌다.</p>
<h4 id="211-자연어-키워드-매칭의-한계">2.1.1 자연어 키워드 매칭의 한계</h4>
<p>초기 검토안: 사용자가 채팅에 <code>진행해</code>, <code>approve</code>, <code>ㅇㅋ</code>, <code>좋아</code> 같은 미리 정한 문구를 보내면 hook이 그 문구를 보고 승인 토큰을 발행한다.</p>
<p>이 방식은 <strong>하드코딩된 규칙</strong>이다. 그리고 하드코딩은 다음 시나리오에서 즉시 무너진다.</p>
<ul>
<li><strong>언어 전환</strong>: 작업자가 갑자기 러시아어, 히브리어로 답하면 키워드 리스트에 없으니 승인 안 됨.</li>
<li><strong>변이형</strong>: &quot;ㅇㅋㅋ&quot;, &quot;ㄱㄱ&quot;, &quot;Sure&quot; 등 — 모든 표현을 다 잡으려면 리스트가 폭발함.</li>
<li><strong>오탐</strong>: &quot;진행하기 전에 한 번 더 확인해줘&quot;처럼 의도와 다른 문장에 <code>진행</code>이 들어가면 잘못 승인됨.</li>
<li><strong>AI 인젝션</strong>: 모델이 출력한 텍스트에 <code>approve</code>가 포함된 경우, 사용자가 그걸 인용하면 의도와 무관하게 승인으로 처리됨.</li>
</ul>
<p><strong>자연어 표면을 보고 의미를 추론하는 규칙은 본질적으로 깨진다.</strong></p>
<h4 id="212-신호-위치-이동-사용자-텍스트-→-모델의-tool_use">2.1.2 신호 위치 이동: 사용자 텍스트 → 모델의 tool_use</h4>
<p>위 한계를 인정하면 결론은 명확하다. <strong>&quot;승인 의도&quot;를 사용자 텍스트에서 찾지 말고, 행동의 결과인 모델의 tool_use에서 찾자.</strong></p>
<ul>
<li>사용자가 어떤 언어로 말하든 → AI는 결국 <code>Edit</code>, <code>Write</code>, <code>Bash</code> 같은 도구 호출을 생성해야 코드를 바꿀 수 있음.</li>
<li>그 도구 호출은 <strong>구조화된 신호</strong>다. 언어 의존성 0, 변이형 0, 오탐 0.</li>
</ul>
<h4 id="213-결론-도구-호출-단에서-강제">2.1.3 결론: 도구 호출 단에서 강제</h4>
<ul>
<li><strong>PreToolUse hook</strong>이 모든 tool_use를 가로챈다 (matcher <code>&quot;*&quot;</code> 등록).</li>
<li>hook은 자연어를 절대 해석하지 않는다. 도구 이름, 입력, 토큰 파일, 현재 git 브랜치만 본다.</li>
<li>승인 신호도 <code>ExitPlanMode</code>의 결과에서 받는다.</li>
<li>토큰은 한 턴 동안만 유효하고 턴이 끝나면 자동 폐기된다.</li>
</ul>
<h3 id="22-차단-게이트">2.2 차단 게이트</h3>
<h4 id="221-pretooluse-hook-단일-게이트">2.2.1 PreToolUse hook 단일 게이트</h4>
<ul>
<li>매처는 <code>&quot;*&quot;</code>로 등록 — AI가 어떤 도구를 호출하든 무조건 hook 경유.</li>
<li>차단 시 exit 2 + stderr 메시지 → 모델에게 차단 사유와 다음 행동을 알림.</li>
</ul>
<h4 id="222-차단-대상-분류--순수-블랙리스트">2.2.2 차단 대상 분류 — 순수 블랙리스트</h4>
<p>v1은 화이트리스트를 두지 않는다. 화이트리스트로 가면 새 도구가 나올 때마다 패치해야 하는 지옥에 빠진다.</p>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>도구</th>
<th>정책</th>
</tr>
</thead>
<tbody><tr>
<td>변경 도구</td>
<td>Edit, Write, MultiEdit, NotebookEdit</td>
<td>보호 브랜치 아니고 토큰 있으면 통과</td>
</tr>
<tr>
<td>Bash</td>
<td>Bash</td>
<td>명령어 분류 후 분기 (2.3 참조)</td>
</tr>
<tr>
<td>그 외 모두</td>
<td>Read, Glob, Grep, Agent, Skill 등</td>
<td>기본 통과</td>
</tr>
</tbody></table>
<h3 id="23-bash-차단-정책-블랙리스트">2.3 Bash 차단 정책 (블랙리스트)</h3>
<h4 id="231-카테고리별-차단-패턴">2.3.1 카테고리별 차단 패턴</h4>
<p><code>config/blacklist.txt</code>에 카테고리별 정규식.</p>
<ul>
<li><strong>파일 시스템 변경</strong>: rm, rmdir, unlink, mv, cp, mkdir, touch, chmod, chown, ln</li>
<li><strong>출력 리다이렉션 (Edit/Write 우회로)</strong>: <code>&gt;</code>, <code>&gt;&gt;</code>, tee, heredoc</li>
<li><strong>인플레이스 편집기</strong>: sed -i, perl -i, gawk -i, ed, ex</li>
<li><strong>Git 변경</strong>: git commit, git push, git reset --hard, git checkout -B, git checkout ., git clean -f, git rebase, git merge, git branch -D, git tag -d, git stash drop/clear, git filter-branch, git update-ref</li>
<li><strong>패키지 설치/제거</strong>: npm, yarn, pnpm, pip, poetry, cargo, go install, apt, brew, gem</li>
<li><strong>DB / 마이그레이션</strong>: alembic, prisma migrate, knex migrate, manage.py migrate, psql/mysql -c 안에 INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/TRUNCATE</li>
<li><strong>시스템 파괴 / 권한 상승</strong>: sudo, su -, dd, mkfs, shred, format</li>
<li><strong>환경 변수 영구 변경</strong>: export, unset</li>
</ul>
<h4 id="232-우회-방지-명령-체이닝-분해">2.3.2 우회 방지: 명령 체이닝 분해</h4>
<p><code>git status &amp;&amp; rm important.py</code> 같은 명령은 첫 단어만 보면 <code>git status</code>라 통과되어버린다. 해결: <code>;</code>, <code>&amp;&amp;</code>, <code>||</code>, <code>|</code> 기준으로 segment 분해 후 각 segment를 따로 매칭.</p>
<h4 id="233-우회-방지-동적-실행-패턴-차단">2.3.3 우회 방지: 동적 실행 패턴 차단</h4>
<p><code>bash -c &quot;rm file&quot;</code> 한 줄로 블랙리스트가 무력화될 수 있다. 정적 분석 불가능 → 포함 자체를 차단.</p>
<ul>
<li>eval, exec, source, <code>. file</code></li>
<li>bash -c, sh -c, zsh -c</li>
<li><code>$(...)</code> 명령 치환, 백틱 명령 치환</li>
<li><code>... | bash</code>, <code>... | sh</code>, <code>... | zsh</code></li>
</ul>
<h3 id="24-승인-채널-enterplanmode--exitplanmode">2.4 승인 채널: EnterPlanMode + ExitPlanMode</h3>
<h4 id="241-흐름">2.4.1 흐름</h4>
<ol>
<li>사용자가 변경 의도를 담은 요청을 보냄 (언어 무관)</li>
<li>UserPromptSubmit hook이 정책 텍스트를 stdout으로 출력 → 모델 컨텍스트 주입</li>
<li>AI가 정책을 보고 <code>EnterPlanMode()</code> 호출 → 통과</li>
<li>plan mode 안에서 Read/Glob/Grep으로 분석</li>
<li>AI가 plan 본문을 채팅 텍스트로 출력</li>
<li>AI가 <code>ExitPlanMode(plan=&quot;...&quot;)</code> 호출 → Claude Code가 승인 다이얼로그 표시</li>
<li>사용자가 plan을 채팅에서 읽고 Accept</li>
<li>PostToolUse hook이 토큰 발행</li>
<li>AI가 Edit/Write/Bash 실행 → 토큰 확인 → 통과</li>
<li>Stop hook이 토큰 삭제</li>
</ol>
<p>만약 AI가 정책 무시하고 곧장 Edit 시도하면 → PreToolUse가 차단 → AI가 회복 경로로 EnterPlanMode부터 다시 시작. 정책 주입(소프트)과 PreToolUse 차단(하드)이 이중 안전망.</p>
<h4 id="242-자연어-분류-제거의-결과">2.4.2 자연어 분류 제거의 결과</h4>
<p>hook은 어떤 사용자 텍스트도 해석하지 않는다. 사용자가 한국어, 영어, 러시아어, 이모지로 답하든 영향 없음.</p>
<h3 id="25-정책-주입-userpromptsubmit-hook">2.5 정책 주입 (UserPromptSubmit hook)</h3>
<p>차단만으로는 충분하지 않다. AI는 차단된 후 회복 경로를 모르거나, 채팅에서 명료화 질문부터 던지면서 plan mode를 한참 우회한다. 그래서 매 턴 모델에게 정책을 주입하는 <strong>소프트 유도</strong> 채널을 둔다.</p>
<h4 id="251-메커니즘">2.5.1 메커니즘</h4>
<p><code>UserPromptSubmit</code> hook이 stdout으로 출력한 텍스트는 Claude Code가 모델 컨텍스트에 추가 주입한다.</p>
<h4 id="252-정책-내용">2.5.2 정책 내용</h4>
<ul>
<li>코드 수정 요청이면 첫 도구 호출이 반드시 <code>EnterPlanMode</code></li>
<li>ExitPlanMode 호출 전 plan 본문을 채팅 텍스트로 출력 (구조 강제)</li>
<li>사용자가 다이얼로그를 Accept하면 그 후 모든 도구 호출이 추가 질문 없이 통과</li>
<li>채팅에서 명료화 질문 던지지 말고 plan 본문에 포함</li>
<li>가짜 환경변수(<code>OMC_SKIP_HOOKS</code> 등)는 존재하지 않으니 제안 금지</li>
<li>보호 브랜치(main/master/develop)에선 plan 첫 단계로 <code>git checkout -b feature/&lt;설명&gt;</code>을 포함</li>
<li>막혔을 때 해답은 항상 EnterPlanMode</li>
</ul>
<h4 id="253-하드소프트-이중-안전망">2.5.3 하드/소프트 이중 안전망</h4>
<ul>
<li><strong>하드 (PreToolUse 차단)</strong>: AI가 정책을 무시해도 변경 시도가 막힌다.</li>
<li><strong>소프트 (UserPromptSubmit 정책 주입)</strong>: AI가 차단 만나기 전에 능동적으로 plan mode로 들어가게 유도한다.</li>
</ul>
<h3 id="26-plan-가시성-강제">2.6 Plan 가시성 강제</h3>
<p>ExitPlanMode 다이얼로그가 plan 본문을 충분히 표시하지 않는 환경에서 사용자는 plan을 못 보고 Accept하는 위험이 있다. 그래서 정책에 다음을 명시한다.</p>
<blockquote>
<p><strong>ExitPlanMode 호출 직전에 plan 전문을 채팅 텍스트로 먼저 출력하라.</strong></p>
</blockquote>
<h4 id="261-plan-본문-구조-강제">2.6.1 plan 본문 구조 (강제)</h4>
<ul>
<li><strong>## What will change</strong> — 파일별 정확한 라인/메서드/import 단위 + Bash 명령 그대로 + SQL 본문 그대로</li>
<li><strong>## WARNING: DESTRUCTIVE</strong> — rm/delete/drop/truncate 같은 파괴적 작업</li>
<li><strong>## Risks</strong> — 구체적 위험</li>
<li><strong>## Rollback</strong> — 구체적 복구 명령</li>
</ul>
<h4 id="262-효과">2.6.2 효과</h4>
<ul>
<li>사용자가 다이얼로그가 뜨기 전에 plan을 읽을 수 있다</li>
<li>DESTRUCTIVE 섹션이 위험 작업을 한눈에 보여준다</li>
<li>Rollback 전략이 있어 문제가 생겨도 대응 가능</li>
</ul>
<h3 id="27-토큰-생명주기-턴-단위">2.7 토큰 생명주기: 턴 단위</h3>
<ul>
<li><strong>생성</strong>: ExitPlanMode Accept 순간 (PostToolUse hook)</li>
<li><strong>만료</strong>: Stop hook 발사 시점 (AI가 응답 끝낼 때마다)</li>
<li>세션 종료 시 SessionEnd hook이 이중 폐기</li>
</ul>
<p>시나리오:</p>
<ol>
<li>&quot;@@ 기능 구현해줘&quot; → plan → 승인 → 구현 → 턴 종료 (<strong>토큰 삭제</strong>)</li>
<li>&quot;@@ 코드 리뷰해줘&quot; → 새 턴, 토큰 없음. Read만 사용하므로 영향 없음</li>
<li>&quot;$$ 다른 기능 구현해줘&quot; → 새 턴, 차단 → 새 plan → 새 토큰 → 구현</li>
</ol>
<h3 id="28-권한-시스템-통합-claude-code-네이티브-프롬프트-우회">2.8 권한 시스템 통합 (Claude Code 네이티브 프롬프트 우회)</h3>
<p>v1 초반엔 사용자가 plan을 승인해도 Claude Code가 매 Bash 명령마다 &quot;Do you want to proceed?&quot; 다이얼로그를 띄웠다. 댓글 도메인 삭제 같은 작업은 Bash 호출이 30개 넘게 줄지어 나오기 때문에 사용자가 한 번에 끝까지 갈 수 없었다.</p>
<p>원인은 Claude Code 안에 권한 시스템이 두 겹이라는 것.</p>
<ol>
<li>PreToolUse hook (우리 하네스) — 토큰 발행 = 통과</li>
<li>Claude Code built-in — 매 Bash 명령마다 별도 확인</li>
</ol>
<p>문제는 2번이 1번의 결과를 모른다.</p>
<h4 id="281-해결-pretooluse-json-출력">2.8.1 해결: PreToolUse JSON 출력</h4>
<p>PreToolUse hook이 stdout으로 다음 JSON을 출력하면 Claude Code가 네이티브 프롬프트를 건너뛴다.</p>
<pre><code class="language-json">{
  &quot;hookSpecificOutput&quot;: {
    &quot;hookEventName&quot;: &quot;PreToolUse&quot;,
    &quot;permissionDecision&quot;: &quot;allow&quot;,
    &quot;permissionDecisionReason&quot;: &quot;Harness: token_present&quot;
  }
}</code></pre>
<p>토큰 있을 때만 이 JSON을 출력하므로 안전망은 유지된다.</p>
<h4 id="282-효과">2.8.2 효과</h4>
<p>&quot;test_v16_dir 만들고 hello.txt 쓰고 rm으로 지워줘&quot; 한 줄 요청 → plan 한 번 승인 후 mkdir → Write → rm 세 작업이 <strong>사용자 추가 입력 없이</strong> 연속 실행됨.</p>
<h3 id="29-보호-브랜치-정책">2.9 보호 브랜치 정책</h3>
<p>코드 변경을 main / master / develop 같은 보호 브랜치에 직접 하면 안 되는 게 일반적인 git 워크플로우 규칙이다. 하지만 AI는 그걸 모르고 main에서 직접 commit/push한다.</p>
<h4 id="291-규칙">2.9.1 규칙</h4>
<p><code>protected_branches</code> (기본값: main, master, develop)에 있는 브랜치에서:</p>
<ul>
<li>Edit / Write / MultiEdit / NotebookEdit 시도 → <strong>토큰 있어도 차단</strong></li>
<li>변경 카테고리 Bash → <strong>토큰 있어도 차단</strong></li>
<li>단, <code>git checkout -b</code> / <code>git switch -c</code> → <strong>항상 통과</strong></li>
</ul>
<h4 id="292-흐름">2.9.2 흐름</h4>
<pre><code>사용자: &quot;X 기능 추가해줘&quot; (현재 main 브랜치)
   |
   v
AI: EnterPlanMode → plan 작성
   - 첫 단계: git checkout -b feature/add-x   ← 정책이 강제
   - 그 다음: 실제 코드 변경
   |
   v
사용자: Accept
   |
   v
AI: Bash(git checkout -b feature/add-x) → 통과 (브랜치 생성은 safe)
   |
   v
이제 feature 브랜치 위
   |
   v
AI: Edit / Write → 통과 (토큰 + 보호 브랜치 벗어남)</code></pre><p>만약 AI가 정책 무시하고 main에서 곧장 Edit 시도하면 → hook이 차단 메시지로 회복 경로 안내 → AI가 메시지 보고 브랜치 만들고 재시도.</p>
<h4 id="293-부수-효과-자동-동기화-단순화">2.9.3 부수 효과: 자동 동기화 단순화</h4>
<p>main에 직접 commit 못 하니 <strong>로컬 main은 항상 origin/main의 ancestor</strong>다. 이 보장 덕에 자동 동기화의 sync 로직이 단순해진다.</p>
<h3 id="210-sessionstart-자동-동기화">2.10 SessionStart 자동 동기화</h3>
<p>매번 Claude Code 켤 때 사용자가 <code>git pull</code> 잊으면 옛날 main 위에서 작업하다 충돌난다. 이걸 SessionStart hook으로 자동화.</p>
<h4 id="2101-동작">2.10.1 동작</h4>
<p>Claude Code 세션 시작 시 자동으로:</p>
<ol>
<li>cwd가 git repo인지 확인 → 아니면 조용히 스킵</li>
<li>origin remote / main 브랜치 자동 감지</li>
<li><code>git fetch origin main</code> (timeout 10초)</li>
<li>현재 main이면 <code>git merge --ff-only origin/main</code>, 다른 브랜치면 <code>git branch -f main origin/main</code></li>
</ol>
<p>보호 브랜치 정책 덕에 두 케이스 모두 항상 안전.</p>
<h4 id="2102-가시성-한계">2.10.2 가시성 한계</h4>
<p>Claude Code 2.1.100은 SessionStart hook의 stdout을 채팅에 표시하지 않는다 (모델 컨텍스트로만 주입). 대신 events.jsonl의 <code>session_start_sync</code> 이벤트 또는 <code>git log -1 main</code> vs <code>git log -1 origin/main</code> 비교로 검증.</p>
<h4 id="2103-안전-장치">2.10.3 안전 장치</h4>
<ul>
<li>network 실패 / detached HEAD / origin 없음 / non-git 폴더 → 모두 스킵</li>
<li>사용자가 끄고 싶으면 <code>auto_sync_main: false</code></li>
</ul>
<h3 id="211-인코딩-처리-windows">2.11 인코딩 처리 (Windows)</h3>
<p>v1 운영 중 발견된 결정적 버그. PowerShell 5.x와 <code>python script.py</code>로 실행되는 hook 프로세스의 <code>sys.stdout</code>/<code>sys.stderr</code>는 기본적으로 시스템 코드페이지(한국어 Windows에서는 cp949)로 설정된다. 정책 텍스트에 들어있는 <code>★</code>, <code>↓</code>, em-dash(<code>—</code>), 한글 같은 cp949에 없는 문자를 출력하면 <code>UnicodeEncodeError</code>가 발생한다.</p>
<p>문제는 hook의 try/except가 이 예외를 <strong>조용히 삼키고 exit 0을 반환</strong>한다는 것. 결과: stdout 0 바이트, stderr 0 바이트, exit 0. 정책 텍스트는 모델에 도달하지 않는다.</p>
<h4 id="2111-해결">2.11.1 해결</h4>
<p>모든 hook 스크립트 상단에 다음을 둔다.</p>
<pre><code class="language-python">import io
try:
    sys.stdout.reconfigure(encoding=&quot;utf-8&quot;)
except Exception:
    try:
        sys.stdout = io.TextIOWrapper(
            sys.stdout.buffer, encoding=&quot;utf-8&quot;,
            errors=&quot;replace&quot;, line_buffering=True,
        )
    except Exception:
        pass
try:
    sys.stderr.reconfigure(encoding=&quot;utf-8&quot;)
except Exception:
    try:
        sys.stderr = io.TextIOWrapper(
            sys.stderr.buffer, encoding=&quot;utf-8&quot;,
            errors=&quot;replace&quot;, line_buffering=True,
        )
    except Exception:
        pass</code></pre>
<h4 id="2112-installps1-인코딩">2.11.2 install.ps1 인코딩</h4>
<p>install 스크립트(.ps1) 자체도 PowerShell 5.x가 BOM 없는 UTF-8을 cp949로 잘못 읽는 문제가 있다. UTF-8 BOM으로 저장 + 스크립트 상단에서 <code>[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()</code> + <code>chcp 65001</code> 실행.</p>
<h3 id="212-로그-구조">2.12 로그 구조</h3>
<h4 id="2121-디렉토리-구조">2.12.1 디렉토리 구조</h4>
<pre><code>~/.harness/logs/
├── YYYY-MM-DD/
│   └── &lt;session_id&gt;/
│       ├── events.jsonl       # 모든 이벤트(타임스탬프)
│       ├── conversation.md    # 사람이 읽는 대화 + plan + diff 요약
│       ├── changes.json       # {파일경로: [변경 시각, hash, 줄수 변화]}
│       └── diffs/&lt;n&gt;.patch    # PostToolUse 에서 떨어지는 diff
└── index.md                   # 세션 인덱스</code></pre><p>핵심은 <code>index.md</code>다. &quot;지난주에 auth 관련 뭘 건드렸지?&quot; 같은 추적이 빠르려면 세션별 한 줄 요약이 한 파일에 모여 있어야 한다.</p>
<h4 id="2122-eventsjsonl-스키마">2.12.2 events.jsonl 스키마</h4>
<p>각 줄은 ISO 8601 타임스탬프, session_id, event_type, payload를 포함한 단일 JSON 객체.</p>
<pre><code class="language-json">{&quot;ts&quot;:&quot;2026-06-03T14:21:30+09:00&quot;,&quot;type&quot;:&quot;user_prompt&quot;,&quot;content&quot;:&quot;회원가입 validation 고쳐줘&quot;}
{&quot;ts&quot;:&quot;2026-06-03T14:21:45+09:00&quot;,&quot;type&quot;:&quot;tool_use_pre&quot;,&quot;tool&quot;:&quot;Read&quot;,&quot;decision&quot;:&quot;allow&quot;}
{&quot;ts&quot;:&quot;2026-06-03T14:22:05+09:00&quot;,&quot;type&quot;:&quot;plan_approved&quot;,&quot;plan_summary&quot;:&quot;...&quot;}
{&quot;ts&quot;:&quot;2026-06-03T14:22:10+09:00&quot;,&quot;type&quot;:&quot;tool_use_pre&quot;,&quot;tool&quot;:&quot;Edit&quot;,&quot;decision&quot;:&quot;allow&quot;,&quot;reason&quot;:&quot;token_present&quot;}
{&quot;ts&quot;:&quot;2026-06-03T14:22:30+09:00&quot;,&quot;type&quot;:&quot;turn_end&quot;,&quot;tokens_invalidated&quot;:1}</code></pre>
<h4 id="2123-conversationmd-포맷">2.12.3 conversation.md 포맷</h4>
<p>각 섹션: 시각 + 이벤트 종류 + 본문. 사용자 요청, 계획 승인, 파일 변경, 턴 종료가 시간순으로 정리됨.</p>
<h4 id="2124-indexmd-한-줄-포맷">2.12.4 index.md 한 줄 포맷</h4>
<pre><code>- 2026-06-03 14:21 / 회원가입 validation 수정 / auth/validators.py, tests/test_auth.py / 상세 링크</code></pre><h3 id="213-디렉토리-구조-설치-후">2.13 디렉토리 구조 (설치 후)</h3>
<pre><code>~/.harness/
├── lib/
│   ├── paths.py
│   ├── logger.py
│   ├── token.py
│   ├── bash_classifier.py
│   └── git_utils.py
├── hooks/
│   ├── _bootstrap.py
│   ├── session_start.py
│   ├── user_prompt_submit.py
│   ├── pre_tool_use.py
│   ├── post_tool_use.py
│   ├── stop.py
│   └── session_end.py
├── config/
│   ├── blacklist.txt
│   └── settings.json
├── state/
│   └── &lt;session_id&gt;/
└── logs/
    ├── index.md
    └── YYYY-MM-DD/&lt;session_id&gt;/</code></pre><h3 id="214-hook-별-책임">2.14 Hook 별 책임</h3>
<table>
<thead>
<tr>
<th>Hook</th>
<th>받는 입력</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>SessionStart</td>
<td>session_id, cwd</td>
<td>git fetch + 로컬 main 동기화</td>
</tr>
<tr>
<td>UserPromptSubmit</td>
<td>사용자 메시지</td>
<td>요청 기록 + stdout으로 정책 텍스트 출력</td>
</tr>
<tr>
<td>PreToolUse</td>
<td>tool_name, tool_input, cwd</td>
<td>차단/통과 결정. 토큰 있을 때 JSON <code>permissionDecision: allow</code> 출력</td>
</tr>
<tr>
<td>PostToolUse</td>
<td>tool_name, tool_input, tool_response</td>
<td>결과 기록. ExitPlanMode Accept면 토큰 발행. Edit/Write 후 diff 캡처</td>
</tr>
<tr>
<td>Stop</td>
<td>session_id</td>
<td>토큰 삭제, turn_end 이벤트 기록</td>
</tr>
<tr>
<td>SessionEnd</td>
<td>session_id</td>
<td>세션 요약, index.md 한 줄 추가</td>
</tr>
</tbody></table>
<h3 id="215-토큰-형식">2.15 토큰 형식</h3>
<p><code>state/&lt;session_id&gt;/approved</code> 파일 하나로 단순화. 파일 존재 = 승인됨.</p>
<pre><code class="language-json">{
  &quot;plan_id&quot;: &quot;uuid&quot;,
  &quot;approved_at&quot;: &quot;2026-06-03T14:22:05+09:00&quot;,
  &quot;plan_summary&quot;: &quot;회원가입 validation 수정&quot;,
  &quot;expected_files&quot;: []
}</code></pre>
<h3 id="216-사용-시나리오">2.16 사용 시나리오</h3>
<h4 id="2161-일반-흐름-소프트-유도">2.16.1 일반 흐름 (소프트 유도)</h4>
<ol>
<li>사용자: &quot;회원가입 validation 고쳐줘&quot;</li>
<li>UserPromptSubmit hook이 정책 주입</li>
<li>AI: EnterPlanMode → 분석 → 채팅에 plan 출력 → ExitPlanMode → 다이얼로그</li>
<li>사용자: Accept → 토큰 발행</li>
<li>AI 실행 → 추가 프롬프트 없이 통과</li>
<li>Stop hook → 토큰 삭제</li>
<li>SessionEnd → index.md 갱신</li>
</ol>
<h4 id="2162-보호-브랜치-위-흐름">2.16.2 보호 브랜치 위 흐름</h4>
<ol>
<li>사용자: &quot;X 기능 추가해줘&quot; (현재 main)</li>
<li>AI: plan 첫 단계로 <code>git checkout -b feature/add-x</code> 포함</li>
<li>Accept → Bash(git checkout -b ...) → 새 브랜치</li>
<li>Edit/Write 진행</li>
</ol>
<h4 id="2163-하드-게이트-정책-무시-시">2.16.3 하드 게이트 (정책 무시 시)</h4>
<ol>
<li>AI가 곧장 Bash(rm -rf ...) 시도 → 차단</li>
<li>AI가 메시지 보고 EnterPlanMode로 회복</li>
<li>이후 흐름은 2.16.1과 동일</li>
</ol>
<h4 id="2164-추적-시나리오">2.16.4 추적 시나리오</h4>
<p>&quot;지난주 auth 관련 뭘 건드렸지?&quot;</p>
<ol>
<li><code>~/.harness/logs/index.md</code> 열기</li>
<li>grep auth로 검색</li>
<li>해당 세션 폴더의 conversation.md 확인</li>
<li>diffs/에서 실제 변경 확인</li>
</ol>
<h3 id="217-검증-결과">2.17 검증 결과</h3>
<h4 id="2171-격리-샌드박스-e2e-테스트">2.17.1 격리 샌드박스 e2e 테스트</h4>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>Read/Glob/Grep 통과</td>
<td>✅</td>
</tr>
<tr>
<td>Edit 토큰 없이 차단</td>
<td>✅</td>
</tr>
<tr>
<td>Bash ls -la 통과</td>
<td>✅</td>
</tr>
<tr>
<td>Bash rm 차단</td>
<td>✅</td>
</tr>
<tr>
<td>Bash bash -c 차단</td>
<td>✅</td>
</tr>
<tr>
<td>Bash 체이닝 분해 차단</td>
<td>✅</td>
</tr>
<tr>
<td>ExitPlanMode Accept → 토큰 발행</td>
<td>✅</td>
</tr>
<tr>
<td>Stop hook → 토큰 폐기</td>
<td>✅</td>
</tr>
<tr>
<td>Edit 후 diff 캡처</td>
<td>✅</td>
</tr>
<tr>
<td>main + Edit (토큰X) → 차단</td>
<td>✅</td>
</tr>
<tr>
<td>main + Edit (토큰O) → 차단 (브랜치가 토큰보다 상위)</td>
<td>✅</td>
</tr>
<tr>
<td>main + git checkout -b → 통과 (safe Bash)</td>
<td>✅</td>
</tr>
<tr>
<td>feature 브랜치 + Edit (토큰O) → 통과</td>
<td>✅</td>
</tr>
<tr>
<td>develop + Edit (토큰O) → 차단</td>
<td>✅</td>
</tr>
<tr>
<td>SessionStart 자동 동기화</td>
<td>✅</td>
</tr>
</tbody></table>
<h4 id="2172-실사용-e2e-테스트-spring-boot-프로젝트">2.17.2 실사용 e2e 테스트 (Spring Boot 프로젝트)</h4>
<p><strong>&quot;댓글 기능 삭제해줘&quot; — 정책 + plan 강제</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>정책 주입 stdout 출력</td>
<td>✅</td>
</tr>
<tr>
<td>AI EnterPlanMode 호출</td>
<td>✅</td>
</tr>
<tr>
<td>채팅에 plan 4섹션 출력</td>
<td>✅</td>
</tr>
<tr>
<td>ExitPlanMode 다이얼로그</td>
<td>✅</td>
</tr>
</tbody></table>
<p><strong>&quot;test_v16_dir 만들고 hello.txt 쓰고 rm으로 지워줘&quot; — 권한 통합</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>PreToolUse JSON allow 출력</td>
<td>✅</td>
</tr>
<tr>
<td>plan에 mkdir/Write/rm 구체 명시</td>
<td>✅</td>
</tr>
<tr>
<td>Accept 후 세 작업 연속 실행 (추가 프롬프트 X)</td>
<td>✅</td>
</tr>
</tbody></table>
<p><strong>&quot;test_branch_protection_v17.txt 만들어줘&quot; (main 브랜치) — 보호 브랜치</strong></p>
<table>
<thead>
<tr>
<th>단계</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>AI가 main이 보호 브랜치임을 인지</td>
<td>✅</td>
</tr>
<tr>
<td>plan 첫 단계 = git checkout -b ...</td>
<td>✅</td>
</tr>
<tr>
<td>Accept 후 브랜치 생성 + 파일 생성 연속 실행</td>
<td>✅</td>
</tr>
<tr>
<td>파일이 main이 아닌 새 브랜치에 생성</td>
<td>✅</td>
</tr>
</tbody></table>
<h3 id="218-운영-중-조정-포인트">2.18 운영 중 조정 포인트</h3>
<ul>
<li><strong>차단 패턴 추가/제거</strong>: <code>~/.harness/config/blacklist.txt</code></li>
<li><strong>보호 브랜치 추가/제거</strong>: settings.json의 <code>protected_branches</code></li>
<li><strong>자동 동기화 끄기</strong>: <code>auto_sync_main: false</code></li>
<li><strong>차단 메시지 문구</strong>: settings.json의 <code>block_message_template</code></li>
<li><strong>정책 텍스트</strong>: <code>~/.harness/hooks/user_prompt_submit.py</code>의 <code>POLICY_TEXT</code></li>
<li><strong>변경 도구 목록</strong>: settings.json의 <code>mutating_tools</code></li>
</ul>
<h3 id="219-v1-운영-중-발견한-이슈-회고">2.19 v1 운영 중 발견한 이슈 (회고)</h3>
<p>v1을 실사용에 투입하면서 드러난 이슈와 그에 대응한 패치들.</p>
<ul>
<li><strong>v1.1 — 도구 화이트리스트의 패치 지옥</strong>: 처음엔 <code>always_allowed_tools</code>에 안전 도구 리스트를 두고 그 외는 차단했는데, Claude Code가 추가한 새 도구(Agent / ToolSearch / Skill)가 막혀서 매번 리스트에 추가해야 했음. → v1.3에서 화이트리스트 제거.</li>
<li><strong>v1.2 — EnterPlanMode vs ExitPlanMode 순환 블록</strong>: ExitPlanMode만 통과시키고 EnterPlanMode는 차단했더니, AI가 ExitPlanMode 호출하면 &quot;not in plan mode&quot; 에러 → EnterPlanMode 호출하면 하네스가 차단 → 무한 루프. → 두 도구 모두 기본 통과.</li>
<li><strong>v1.2 — 가짜 환경변수 추천</strong>: AI가 막혔을 때 <code>OMC_SKIP_HOOKS=pre_tool_use</code> 같은 존재하지 않는 환경변수를 추천. 정책에 명시.</li>
<li><strong>v1.4 — Plan 가시성 부재</strong>: ExitPlanMode 다이얼로그가 plan 본문을 안 보여주는 경우 사용자가 내용을 모르고 Accept하는 위험. → 정책에 &quot;ExitPlanMode 호출 전에 plan 전문을 채팅에 출력하라&quot; 추가.</li>
<li><strong>v1.5 — Windows cp949 인코딩 사일런트 실패</strong>: 가장 결정적 버그. UserPromptSubmit hook의 stdout이 UnicodeEncodeError를 던지고 try/except가 삼켜서 정책이 모델에 한 번도 도달 안 함. → 모든 hook에 stdout/stderr UTF-8 reconfigure.</li>
<li><strong>v1.6 — 권한 시스템 두 겹의 충돌</strong>: plan 승인했는데도 Claude Code가 매 Bash 명령마다 또 물었다. → PreToolUse가 JSON <code>permissionDecision: allow</code> 출력해 Claude Code 네이티브 프롬프트 우회.</li>
<li><strong>v1.6 — plan 추상성</strong>: AI가 plan을 &quot;comment 도메인 삭제&quot; 수준의 한 줄로 제출. → POLICY_TEXT에 파일별 정확한 라인/메서드/명령/SQL까지 요구하는 구체적 포맷 명시.</li>
<li><strong>v1.7 — main 보호 부재</strong>: AI가 main에서 직접 commit/push 시도. → 보호 브랜치 정책 도입. AI는 plan 첫 단계로 <code>git checkout -b feature/&lt;설명&gt;</code> 포함.</li>
<li><strong>v1.7 — 매번 수동 git pull</strong>: SessionStart hook으로 자동 동기화. 보호 브랜치 덕에 sync 로직이 단순.</li>
<li><strong>사용자 UX 함정</strong>: 하네스가 plan을 강제해도 <strong>사용자가 plan을 안 읽고 Accept</strong>하면 의미 없음. 운영 수칙: &quot;plan 본문 안 보이거나 부실하면 무조건 No&quot;.</li>
</ul>
<h3 id="220-v2-후보">2.20 v2 후보</h3>
<ul>
<li>모호한 요청 시 명료화 질문 유도 강화 (plan에 <code>[확인 필요]</code> 마커)</li>
<li>Plan 범위 외 파일 변경 시 PostToolUse 경고/차단</li>
<li>로그 보관 기간 자동 청소</li>
<li>차단 통계 / 알림 대시보드</li>
<li>다중 세션 동시 실행 시 글로벌 락</li>
<li>OMC planner agent와 우리 정책 흐름 통합</li>
<li>DESTRUCTIVE 작업 추가 확인 (키워드 타이핑)</li>
<li>브랜치별 변경 가능 파일 제약 (feature/auth-* → src/auth/만)</li>
<li>Plan 첫 단계가 정말 브랜치 생성인지 PostToolUse가 검증</li>
<li>현재 브랜치를 main 위로 자동 rebase 옵션</li>
</ul>
<h3 id="221-요약">2.21 요약</h3>
<table>
<thead>
<tr>
<th>요소</th>
<th>결정</th>
</tr>
</thead>
<tbody><tr>
<td>강제 방식</td>
<td>자연어 매칭이 아니라 모델 tool_use 단에서 강제</td>
</tr>
<tr>
<td>차단 게이트</td>
<td>PreToolUse 한 곳, 매처 <code>&quot;*&quot;</code>, 순수 블랙리스트</td>
</tr>
<tr>
<td>Bash 우회 방지</td>
<td>명령 체이닝 분해 + 동적 실행 키워드 자체 차단</td>
</tr>
<tr>
<td>승인 채널</td>
<td>EnterPlanMode → ExitPlanMode 네이티브 다이얼로그</td>
</tr>
<tr>
<td>정책 주입</td>
<td>UserPromptSubmit hook stdout → 모델 컨텍스트</td>
</tr>
<tr>
<td>Plan 가시성</td>
<td>What will change / WARNING / Risks / Rollback 4섹션 강제</td>
</tr>
<tr>
<td>토큰 수명</td>
<td>한 턴 한정</td>
</tr>
<tr>
<td>권한 통합</td>
<td>PreToolUse JSON <code>permissionDecision: allow</code> → Claude Code 네이티브 프롬프트 우회</td>
</tr>
<tr>
<td>보호 브랜치</td>
<td>main/master/develop에서 mutating 시도 차단 (토큰 무관)</td>
</tr>
<tr>
<td>자동 동기화</td>
<td>SessionStart에서 git fetch + 로컬 main 동기화</td>
</tr>
<tr>
<td>로그</td>
<td>events.jsonl + conversation.md + changes.json + diffs/ + index.md</td>
</tr>
<tr>
<td>하네스 위치</td>
<td>전역 <code>~/.harness/</code></td>
</tr>
<tr>
<td>인코딩</td>
<td>모든 hook이 UTF-8 reconfigure</td>
</tr>
<tr>
<td>이중 안전망</td>
<td>하드(차단) + 소프트(정책 주입)</td>
</tr>
</tbody></table>
<p>이 설계의 핵심은 <strong>신호 위치 이동</strong>이다. 사용자의 자연어 텍스트가 아니라 모델의 tool_use에서 신호를 받고, 승인도 자연어가 아니라 ExitPlanMode 네이티브 다이얼로그에서 받는다.</p>
<p>v1 최종본은 일곱 차례 패치(v1.1~v1.7)를 거치면서 다음을 학습했다.</p>
<ul>
<li>화이트리스트는 패치 지옥을 만든다 → 블랙리스트로 통일</li>
<li>하드 게이트만으로 부족하다 → 정책 주입 채널 추가</li>
<li>ExitPlanMode 다이얼로그를 신뢰할 수 없다 → 채팅에 plan을 따로 출력</li>
<li>Windows의 사일런트 인코딩 실패는 가장 잡기 어려운 버그다 → 인코딩 부트스트랩</li>
<li>권한 시스템이 두 겹이면 plan 승인의 의미가 깎인다 → PreToolUse JSON으로 통합</li>
<li>AI는 git 워크플로우 규칙을 모른다 → 보호 브랜치 정책을 토큰보다 상위 게이트로</li>
</ul>
<p>v2는 이 기반 위에서 시작한다.</p>
<hr>
<h2 id="3-데모">3. 데모</h2>
<h3 id="무분별-명령">&lt;무분별 명령&gt;</h3>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/8710cd4f-bd16-4ab0-bfd0-0b50014c2206/image.png" alt=""></p>
<p>정책 없는 상태에서 AI에게 작업 시키면 분석 없이 바로 명령을 마구 실행한다.</p>
<h3 id="무분별-삭제-막기">&lt;무분별 삭제 막기&gt;</h3>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/3f84ff45-fe54-4616-9d5b-4e12f27cc147/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/d6ff409a-2956-49c1-8ea5-312595b578f2/image.png" alt=""></p>
<p>하네스가 위험한 Bash 명령(<code>rm -rf ...</code>)을 PreToolUse hook에서 잡고, AI에게 &quot;plan mode로 진입하라&quot;는 메시지를 돌려준다.</p>
<h3 id="계획-세우기">&lt;계획 세우기&gt;</h3>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/aa850e20-371f-40ba-b9e8-0d55f2352d22/image.png" alt=""></p>
<p>AI가 차단 메시지 또는 정책 주입을 받고 EnterPlanMode로 진입해 plan을 작성한다.</p>
<h3 id="한-번-승인하면-끝까지-위임">&lt;한 번 승인하면 끝까지 위임&gt;</h3>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/b7237c6d-351b-4a70-9296-b1ead796773a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/fb40f995-25a7-49d1-aa71-b92d945b4ae3/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/476c3729-4926-4bac-9298-1968694bf477/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/2b4c30c0-605c-4256-932a-c0c5f332d6ce/image.png" alt=""></p>
<p>plan 승인 후 Bash + Write + Bash 연속 작업이 사용자 추가 입력 없이 흘러간다. plan 한 번 본 게 곧 전체 위임이라는 게 v1.6 의 핵심.</p>
<h3 id="main-브랜치-보호">&lt;main 브랜치 보호&gt;</h3>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/c4af6018-8792-4d2b-a17d-38605ef8c299/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/ee33eeb0-9c4b-498e-9d8c-972acb492565/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/a026ae96-45eb-4ab6-975b-741d579dac57/image.png" alt=""></p>
<p>main 브랜치 상태에서 작업 요청 시 AI 가 알아서 plan 첫 단계로 <code>git checkout -b feature/...</code> 를 넣는다. Accept 후 브랜치 생성과 파일 작업이 한 번에 흘러간다.</p>
<h3 id="자동-동기화">&lt;자동 동기화&gt;</h3>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/1ce650c5-771b-4223-9c0d-d92966736dea/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/4a440c46-ddd1-4eea-83aa-ae82992b307d/image.png" alt=""></p>
<p>Claude Code 세션 시작 시 SessionStart hook 이 자동으로 git fetch + 로컬 main 업데이트. PowerShell 에서 <code>git log -1 main</code> 과 <code>git log -1 origin/main</code> 의 해시가 같은 게 동기화 증거.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude 를 팀프로젝트에 적용시키기]]></title>
            <link>https://velog.io/@k_joon_/Claude-%EB%A5%BC-%ED%8C%80%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%A0%81%EC%9A%A9%EC%8B%9C%ED%82%A4%EA%B8%B0</link>
            <guid>https://velog.io/@k_joon_/Claude-%EB%A5%BC-%ED%8C%80%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%A0%81%EC%9A%A9%EC%8B%9C%ED%82%A4%EA%B8%B0</guid>
            <pubDate>Wed, 06 May 2026 22:23:08 GMT</pubDate>
            <description><![CDATA[<h1 id="claude-code를-팀-프로젝트에-녹이는-방법--하네스-엔지니어링부터-자동-리뷰까지">Claude Code를 팀 프로젝트에 녹이는 방법 — 하네스 엔지니어링부터 자동 리뷰까지</h1>
<blockquote>
<p>AI 코딩 도구가 쏟아지는 시대, &quot;팀 전체가 일관되게 쓰는 것&quot;이 진짜 경쟁력이다.</p>
</blockquote>
<hr>
<h2 id="들어가기">들어가기</h2>
<p>AI의 발전 속도가 무섭다. 불과 1년 전만 해도 &quot;AI가 코드를 짜준다&quot;는 말이 과장처럼 들렸는데, 지금은 Claude Code, Cursor, Copilot 같은 도구들이 실무 개발 흐름에 깊숙이 파고들었다.</p>
<p>나는 현재 팀 프로젝트에서 <strong>여러 Agent</strong>, <strong>OhMyClaudeCode(OMC)</strong>, <strong>hooks</strong>, <strong>skills</strong>, 그리고 <strong>MCP(Model Context Protocol)</strong> 기반의 Jira·GitHub 연동을 함께 사용하며 코드를 생성하고 있다.</p>
<p>그런데 어느 순간 이런 생각이 들었다.</p>
<blockquote>
<p><strong>&quot;나만 잘 쓴다고 되는 게 아니잖아?&quot;</strong></p>
</blockquote>
<p>팀원마다 Claude Code를 제각각으로 쓰면 커밋 컨벤션도, 코드 구조도, 리뷰 방식도 전부 달라진다. 결국 AI를 도입했는데 오히려 유지보수 비용이 늘어나는 역설이 생긴다.</p>
<p>이 글은 그 문제를 어떻게 해결했는지, <strong>Claude Code를 팀 단위로 체계화한 경험</strong>을 공유하는 글이다. 하네스 엔지니어링, 공유 Skills/Agents/Hooks 설계, GitHub Actions를 통한 자동 리뷰까지 최대한 구체적으로 다룬다.</p>
<hr>
<h2 id="전체-코드-작성-흐름">전체 코드 작성 흐름</h2>
<p>팀이 합의한 개발 흐름은 다음과 같다.</p>
<pre><code>요구사항 분석 (전체 팀)
        |
       설계 (하네스 엔지니어링 + TDD 작성)
        |
       구현 (DDD 기반, 공유 Agents/Skills/Hooks)
        |
       검증 (자동 리뷰 + 팀원 코드 리뷰)</code></pre><p>각 단계를 하나씩 뜯어보자.</p>
<hr>
<h2 id="1단계--요구사항-분석-전체-팀이-함께">1단계 — 요구사항 분석: 전체 팀이 함께</h2>
<p>요구사항 분석은 절대 파트별로 쪼개서 하지 않는다. 백엔드가 따로 분석하고, 프론트가 따로 분석하면 나중에 인터페이스에서 충돌이 생긴다. 전체 팀이 같은 언어로 요구사항을 이해해야 한다.</p>
<p>이 단계에서 Claude Code를 직접 쓰지는 않지만, 분석 결과물은 이후 AI가 읽을 수 있는 형태로 반드시 문서화한다.</p>
<p><strong>우리가 사용하는 형식 예시:</strong></p>
<pre><code class="language-markdown">## Feature: 사용자 주문 취소

### 배경
- 주문 후 30분 이내에만 취소 가능
- 결제 취소는 PG사 API를 통해 처리

### 핵심 규칙
1. 주문 상태가 PENDING 또는 CONFIRMED일 때만 취소 가능
2. 취소 요청 시 재고 복구 이벤트가 발행되어야 함
3. 취소 완료 시 사용자에게 이메일 알림 전송

### 예외 케이스
- 이미 배송 시작된 경우 → 400 에러
- PG사 환불 실패 시 → 보상 트랜잭션 롤백</code></pre>
<p>이 문서가 나중에 TDD Agent의 입력값이 된다.</p>
<hr>
<h2 id="2단계--설계-하네스-엔지니어링">2단계 — 설계: 하네스 엔지니어링</h2>
<p>&quot;하네스(Harness)&quot;라는 단어는 말의 안장에서 왔다. 말이 원하는 방향으로 달릴 수 있도록 틀을 잡아주는 것처럼, <strong>AI가 올바른 길을 벗어나지 않도록 사전에 구조를 잡아두는 행위</strong>를 하네스 엔지니어링이라고 부른다.</p>
<p>AI에게 &quot;주문 취소 기능 만들어줘&quot;라고 던지면 AI는 코드를 만들긴 한다. 하지만 그 코드가 팀의 아키텍처 원칙을 따르는지, 도메인 경계를 침범하지 않는지는 보장할 수 없다. 하네스가 없으면 AI는 점점 방향을 잃고, 코드베이스는 스파게티가 된다.</p>
<h3 id="하네스의-구성-요소">하네스의 구성 요소</h3>
<p>우리 팀은 하네스를 크게 두 층으로 관리한다.</p>
<h4 id="1-공통-하네스--claudemd--claude">(1) 공통 하네스 — <code>CLAUDE.md</code> &amp; <code>.claude/</code></h4>
<p>프로젝트 루트의 <code>CLAUDE.md</code>에는 팀 전체가 지켜야 할 공통 규칙을 명시한다.</p>
<pre><code class="language-markdown"># CLAUDE.md

## 아키텍처 원칙
- 이 프로젝트는 DDD(Domain-Driven Design) 기반으로 설계되어 있다.
- 도메인 레이어는 인프라 레이어를 절대 직접 참조하지 않는다.
- 모든 외부 I/O는 Port/Adapter 패턴을 통해 추상화한다.

## 코딩 컨벤션
- 언어: Kotlin, Spring Boot 3.x
- 네이밍: 클래스는 PascalCase, 함수/변수는 camelCase
- 함수 길이: 단일 책임 원칙에 따라 20줄을 넘기지 않도록 노력한다
- 예외 처리: CustomException을 상속받아 도메인별 예외를 정의한다

## 금지 사항
- 비즈니스 로직을 Controller 레이어에 절대 작성하지 않는다
- JPA Entity를 DTO로 직접 반환하지 않는다
- 하드코딩된 매직 넘버/문자열 사용 금지</code></pre>
<p><code>.claude/</code> 디렉터리에는 기능별 세부 규칙을 파일로 분리해둔다.</p>
<pre><code>.claude/
├── agents/
│   ├── endpoint-creator.md     # REST API 엔드포인트 생성 규칙
│   ├── tdd-writer.md           # TDD 작성 Agent 프롬프트
│   └── domain-designer.md      # 도메인 설계 Agent 프롬프트
├── hooks/
│   ├── pre-commit.md           # 커밋 전 검증 규칙
│   └── post-edit.md            # 코드 수정 후 자동 실행 규칙
└── skills/
    ├── commit.md               # /commit 스킬 정의
    ├── review.md               # /review 스킬 정의
    └── swagger.md              # /swagger 스킬 정의</code></pre><h4 id="2-개인-하네스--전문-영역-강화">(2) 개인 하네스 — 전문 영역 강화</h4>
<p>공통 하네스 위에 각 팀원이 자신의 전문 영역에 맞게 개인 하네스를 추가한다.</p>
<p><strong>팀원 A (보안 담당)의 개인 하네스 예시:</strong></p>
<pre><code class="language-markdown">## 보안 체크리스트 (자동 적용)
- SQL 쿼리 작성 시 반드시 PreparedStatement 또는 JPA를 사용할 것
- 사용자 입력값은 항상 Validation 어노테이션으로 검증할 것
- 민감 정보(비밀번호, 토큰)는 절대 로그에 출력하지 않을 것
- JWT 검증 로직은 SecurityConfig에만 집중시킬 것
- CORS 설정은 화이트리스트 방식으로만 허용할 것</code></pre>
<p><strong>본인 (성능/자원관리 담당)의 개인 하네스 예시:</strong></p>
<pre><code class="language-markdown">## 성능 및 자원관리 체크리스트 (자동 적용)
- N+1 문제 방지: 연관 엔티티는 @EntityGraph 또는 fetch join 활용
- 페이지네이션 없는 전체 조회 쿼리 작성 금지
- DB 커넥션은 try-with-resources 패턴으로 반드시 반환
- 캐싱이 가능한 데이터는 @Cacheable 적용 여부를 항상 검토
- 비동기 처리가 가능한 작업은 @Async 또는 이벤트 기반으로 분리</code></pre>
<p>리뷰 시 이 두 개의 하네스가 서로를 보완한다. 내가 성능에 집중하다 놓친 보안 이슈를 팀원이 잡아주고, 반대도 마찬가지다.</p>
<h3 id="tdd-기반-설계">TDD 기반 설계</h3>
<p>하네스를 잡았다면, 구현 전에 반드시 테스트를 먼저 작성한다. 이를 위해 <strong>TDD Writer Agent</strong>를 사용한다.</p>
<p>요구사항 분석 문서를 Agent에게 넣으면 다음과 같은 테스트 코드 뼈대가 나온다.</p>
<pre><code class="language-kotlin">// TDD Writer Agent가 생성한 테스트 뼈대
class OrderCancellationServiceTest {

    @Test
    fun `주문 상태가 PENDING일 때 취소 성공`() {
        // given
        val order = createOrder(status = OrderStatus.PENDING)

        // when
        val result = orderCancellationService.cancel(order.id)

        // then
        assertThat(result.status).isEqualTo(OrderStatus.CANCELLED)
    }

    @Test
    fun `배송 시작된 주문은 취소 불가`() {
        // given
        val order = createOrder(status = OrderStatus.SHIPPING)

        // when &amp; then
        assertThrows&lt;OrderCancellationException&gt; {
            orderCancellationService.cancel(order.id)
        }
    }

    @Test
    fun `취소 완료 시 재고 복구 이벤트가 발행된다`() {
        // given
        val order = createOrder(status = OrderStatus.CONFIRMED)

        // when
        orderCancellationService.cancel(order.id)

        // then
        verify { inventoryEventPublisher.publishRestoreEvent(order.items) }
    }
}</code></pre>
<p>이 테스트들이 구현 Agent의 &quot;목표 지점&quot;이 된다. AI는 이 테스트를 통과시키기 위한 코드를 작성하고, 팀원은 테스트가 통과되면 기능이 올바르게 구현됐음을 확신할 수 있다.</p>
<hr>
<h2 id="3단계--구현-ddd--공유-agentsskillshooks">3단계 — 구현: DDD + 공유 Agents/Skills/Hooks</h2>
<h3 id="ddd가-ai와-잘-맞는-이유">DDD가 AI와 잘 맞는 이유</h3>
<p>구현 단계에서는 DDD(Domain-Driven Design) 원칙을 따른다. 도메인이 명확하게 분리되어 있으면, AI도 어느 파일에 어떤 코드를 써야 하는지 헷갈리지 않는다.</p>
<pre><code>src/
├── domain/
│   ├── order/
│   │   ├── Order.kt                   # 도메인 엔티티
│   │   ├── OrderRepository.kt         # 포트 인터페이스
│   │   ├── OrderCancellationService.kt # 도메인 서비스
│   │   └── event/
│   │       └── OrderCancelledEvent.kt
│   └── inventory/
│       ├── Inventory.kt
│       └── InventoryRestorePort.kt
├── application/
│   └── order/
│       └── CancelOrderUseCase.kt      # 유스케이스
├── adapter/
│   ├── in/web/
│   │   └── OrderController.kt         # REST API
│   └── out/persistence/
│       └── OrderJpaRepository.kt      # JPA 구현체</code></pre><p>이 구조가 있으면 AI에게 &quot;OrderCancellationService에 취소 로직 추가해줘&quot;라고 하면 AI는 다른 레이어를 침범하지 않고 정확히 그 파일만 수정한다.</p>
<h3 id="공유-agents">공유 Agents</h3>
<p>팀에서 공통으로 사용하는 주요 Agent들이다.</p>
<h4 id="엔드포인트-생성-agent-endpoint-creator">엔드포인트 생성 Agent (<code>endpoint-creator</code>)</h4>
<p>유스케이스가 완성되면 이 Agent를 호출한다. Agent는 Controller, Request/Response DTO, Swagger 문서까지 한 번에 생성한다.</p>
<pre><code class="language-markdown"># endpoint-creator Agent 프롬프트 (요약)

주어진 UseCase를 분석하여 다음을 생성하라:
1. REST Controller (적절한 HTTP 메서드와 URL 패턴 준수)
2. RequestDto (Bean Validation 어노테이션 포함)
3. ResponseDto (필드 노출 최소화 원칙)
4. Swagger/OpenAPI 어노테이션

규칙:
- URL은 RESTful 컨벤션을 따른다 (명사형, 복수형)
- 에러 응답은 공통 ErrorResponse 형식을 사용한다
- 모든 API는 @Tag, @Operation 어노테이션으로 문서화한다</code></pre>
<h4 id="도메인-설계-agent-domain-designer">도메인 설계 Agent (<code>domain-designer</code>)</h4>
<p>요구사항 분석 문서를 입력으로 받아 도메인 엔티티, 값 객체, 도메인 이벤트를 설계해준다. 이 Agent가 만든 설계를 팀원 전체가 검토하고 확정하면, 이후 구현은 그 설계를 따른다.</p>
<h4 id="tdd-writer-agent-tdd-writer">TDD Writer Agent (<code>tdd-writer</code>)</h4>
<p>앞서 설명한 대로, 요구사항 문서를 받아 테스트 코드 뼈대를 생성하는 Agent다.</p>
<h3 id="공유-skills">공유 Skills</h3>
<p>Skills는 Claude Code에서 <code>/</code> 명령어로 호출하는 재사용 가능한 작업 단위다. 팀이 공유하는 주요 스킬들을 소개한다.</p>
<h4 id="commit--계층별-커밋-분리"><code>/commit</code> — 계층별 커밋 분리</h4>
<p>이 스킬이 없으면 누군가는 모든 변경사항을 <code>feat(OrderCancellation/order): 주문 취소 기능 추가</code> 하나로 커밋한다. 이 스킬은 변경된 파일을 분석해서 계층별, 기능별로 커밋을 나눠준다.</p>
<p>커밋 메시지 형식은 <code>타입(파일명/도메인):</code> 구조를 따른다.</p>
<pre><code class="language-markdown"># /commit 스킬 정의

변경된 파일을 분석하여 다음 순서로 커밋을 분리하라.
커밋 메시지 형식: `타입(파일명/도메인): 변경 내용`

타입 목록:
- feat: 새 기능 추가
- fix: 버그 수정
- refactor: 리팩토링 (기능 변경 없음)
- test: 테스트 추가/수정
- chore: 빌드/설정 변경

커밋 순서:
1. 도메인 레이어 (Entity, Domain Service, Port Interface)
2. 애플리케이션 레이어 (UseCase, ApplicationService)
3. 어댑터 레이어 (Controller, Repository 구현체)
4. 테스트 코드

각 커밋은 독립적으로 리버트 가능해야 한다.</code></pre>
<p>실행 예시:</p>
<pre><code>git commit → feat(OrderCancellationService/order): 주문 취소 도메인 서비스 추가
git commit → feat(CancelOrderUseCase/order): 주문 취소 유스케이스 구현
git commit → feat(OrderController/order): 취소 API 엔드포인트 추가
git commit → test(OrderCancellationServiceTest/order): 주문 취소 단위 테스트 작성</code></pre><h4 id="review--셀프-코드-리뷰"><code>/review</code> — 셀프 코드 리뷰</h4>
<p>PR을 올리기 전에 스스로 검토하는 스킬이다. CLAUDE.md의 규칙과 개인 하네스를 기준으로 현재 변경사항을 리뷰해준다.</p>
<pre><code class="language-markdown"># /review 스킬 정의

현재 스테이징된 변경사항을 다음 기준으로 검토하라:

체크리스트:
- [ ] CLAUDE.md의 아키텍처 원칙 준수 여부
- [ ] 비즈니스 로직이 올바른 레이어에 위치하는지
- [ ] 예외 처리가 누락된 케이스가 없는지
- [ ] 테스트 커버리지가 핵심 분기를 포함하는지
- [ ] N+1 문제 가능성이 있는 쿼리가 없는지
- [ ] 하드코딩된 값이 없는지

문제가 있으면 파일명과 라인 번호와 함께 지적하라.</code></pre>
<h4 id="swagger--api-문서-자동-보완"><code>/swagger</code> — API 문서 자동 보완</h4>
<p>컨트롤러 파일을 분석해서 Swagger 어노테이션이 빠진 부분을 채워주는 스킬이다.</p>
<h4 id="changelog--변경-이력-생성"><code>/changelog</code> — 변경 이력 생성</h4>
<p>PR에 포함된 커밋 메시지를 분석해서 사람이 읽기 좋은 CHANGELOG 항목을 자동 생성한다.</p>
<h3 id="공유-hooks">공유 Hooks</h3>
<p>Hooks는 특정 이벤트(파일 저장, 커밋 전 등)에 자동으로 실행되는 작업이다.</p>
<h4 id="post-edit-hook--코드-수정-후-자동-검증"><code>post-edit</code> Hook — 코드 수정 후 자동 검증</h4>
<p>파일이 수정될 때마다 자동으로 실행된다. 단순 포맷 이슈는 자동으로 수정하고, 규칙 위반은 바로 경고를 준다.</p>
<pre><code class="language-markdown"># post-edit Hook

코드 파일이 수정된 후 자동으로 실행:

1. 임포트 정리 (사용하지 않는 import 제거)
2. 함수 길이 체크 (20줄 초과 시 분리 권고 메시지 출력)
3. TODO/FIXME 주석 감지 시 Jira 티켓 번호 추가 안내
4. 테스트 파일이 아닌 곳에서 println/console.log 감지 시 경고</code></pre>
<h4 id="pre-commit-hook--커밋-전-게이트"><code>pre-commit</code> Hook — 커밋 전 게이트</h4>
<p><code>/commit</code> 스킬을 실행하기 전 자동으로 돌아가는 최종 관문이다.</p>
<pre><code class="language-markdown"># pre-commit Hook

커밋 직전에 실행:

1. 컴파일 에러 체크 (에러 있으면 커밋 차단)
2. 단위 테스트 실행 (실패 시 커밋 차단)
3. CLAUDE.md 핵심 규칙 위반 감지 시 경고 및 확인 요청
4. 민감 정보(API 키, 비밀번호 패턴) 포함 여부 스캔</code></pre>
<hr>
<h2 id="4단계--검증-github-actions--자동-리뷰">4단계 — 검증: GitHub Actions + 자동 리뷰</h2>
<p>검증은 전체 흐름에서 가장 중요한 단계다. 개인이 아무리 잘 작성해도 팀 전체의 코드가 합쳐지는 순간이 진짜 시험대다.</p>
<h3 id="claude-자동-리뷰-github-actions">Claude 자동 리뷰 (GitHub Actions)</h3>
<p>PR이 열리면 GitHub Actions가 자동으로 Claude API를 호출해서 1차 리뷰를 단다.</p>
<pre><code class="language-yaml"># .github/workflows/claude-review.yml
name: Claude Code Review

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Get PR diff
        id: diff
        run: |
          git diff origin/${{ github.base_ref }}...HEAD &gt; pr_diff.txt

      - name: Claude Review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          python scripts/claude_review.py pr_diff.txt</code></pre>
<pre><code class="language-python"># scripts/claude_review.py (핵심 로직)
import anthropic

SYSTEM_PROMPT = &quot;&quot;&quot;
너는 Kotlin/Spring Boot 전문 코드 리뷰어다.
다음 기준으로 PR을 리뷰하라:

1. 아키텍처 원칙 위반 (DDD 레이어 침범 등)
2. 잠재적 버그 및 예외 처리 누락
3. 성능 이슈 (N+1, 불필요한 전체 조회 등)
4. 보안 취약점
5. 테스트 누락

각 이슈는 다음 형식으로 작성:
- 심각도: [CRITICAL / WARNING / INFO]
- 파일: [파일명:라인번호]
- 문제: [구체적인 문제 설명]
- 제안: [개선 방법]

칭찬할 점이 있으면 마지막에 간단히 언급하라.
&quot;&quot;&quot;</code></pre>
<p><strong>왜 자동 리뷰가 필요한가?</strong>: 여기서 핵심적인 문제가 하나 있다. 내가 짠 코드를 내 Claude Code로 <code>/review</code> 하면, AI도 내가 설정한 하네스와 컨텍스트 위에서 동작하기 때문에 <strong>내가 만든 코드를 당연히 맞다고 넘어가는 경향</strong>이 생길 수 있다. 일종의 확증 편향이다.</p>
<p>그래서 PR 시점의 자동 리뷰는 <strong>아무런 개인 하네스도 없는 순수한 Claude API</strong>로 돌린다. 내 맥락을 전혀 모르는 제3자의 시각으로 코드를 바라보는 것이다.</p>
<p>또한 OhMyClaudeCode(OMC) 같은 고도화된 도구를 PR마다 돌리면 API 비용이 기하급수적으로 늘어난다. 우리는 의도적으로 단순 API 호출로 &quot;큼지막한 검증&quot;만 하도록 설계했다.</p>
<p>자동 리뷰 결과는 PR 코멘트로 달린다:</p>
<pre><code>🤖 Claude Auto Review

⚠️ WARNING | OrderController.kt:45
문제: 비즈니스 로직(주문 가능 시간 체크)이 Controller에 위치해 있습니다.
제안: 이 로직은 OrderCancellationService 또는 도메인 서비스로 이동하세요.

❌ CRITICAL | OrderJpaRepository.kt:23
문제: findAll()을 호출한 후 Stream으로 필터링하고 있습니다. N+1 위험 및
      메모리 과부하 가능성이 있습니다.
제안: WHERE 조건을 쿼리 레벨로 내리세요.

✅ 잘된 점: 도메인 이벤트 발행 패턴이 일관성 있게 잘 적용되어 있습니다.</code></pre><h3 id="팀원-코드-리뷰--개인-하네스가-빛을-발하는-순간">팀원 코드 리뷰 — 개인 하네스가 빛을 발하는 순간</h3>
<p>자동 리뷰를 보고 PR 작성자가 수정을 완료하면, 이제 팀원이 직접 리뷰에 들어간다. 이 단계가 바로 <strong>각자의 개인 하네스 엔지니어링이 진짜 가치를 드러내는 순간</strong>이다.</p>
<p>자동 리뷰는 일반적인 아키텍처 이슈나 명백한 버그를 잡아낸다. 하지만 &quot;보안 관점에서 이 로직이 괜찮은가?&quot;, &quot;이 쿼리가 트래픽이 몰릴 때 버텨낼 수 있는가?&quot; 같은 전문 영역의 질문은 사람이 직접 해야 한다.</p>
<p>팀원은 자신의 전문 하네스가 적용된 Claude Code로 PR 코드를 분석한다. 예를 들어 보안 담당 팀원은 자신의 보안 하네스를 활성화하고 변경사항을 분석해서 날카로운 보안 리뷰를 남긴다. 나는 성능 하네스로 쿼리 효율과 자원 관리 측면에서 상대방 코드를 본다.</p>
<p>결과적으로 리뷰 흐름은 이렇게 된다:</p>
<pre><code>[ PR 작성자 ]
  /review 스킬로 셀프 체크 (1차 필터)
        |
        ↓
[ GitHub Actions ]
  순수 Claude API 자동 리뷰 — 개인 편향 없는 객관적 검증 (2차 필터)
        |
  PR 작성자가 지적 사항 수정
        |
        ↓
[ 팀원 리뷰 ]
  보안 하네스 → 취약점 집중 분석
  성능 하네스 → 쿼리·자원 집중 분석
  서로 다른 전문 영역이 교차하며 상호 보완 (3차 필터)</code></pre><p>혼자 짠 코드를 혼자 검증하는 구조가 아니라, 3단계의 서로 다른 시각이 겹쳐지는 구조다.</p>
<hr>
<h2 id="팀-약속-체계화--공유-컨벤션-저장소">팀 약속 체계화 — 공유 컨벤션 저장소</h2>
<p>개인이 각자 설정을 관리하면 시간이 지나면서 어긋난다. 우리는 <code>.claude/</code> 디렉터리 전체를 Git으로 관리하고, 변경 시 팀 전체 동의를 받는다.</p>
<h3 id="pr-컨벤션">PR 컨벤션</h3>
<pre><code class="language-markdown">## PR 제목 형식
[타입] 도메인명: 간결한 설명

타입 목록:
- feat: 새 기능
- fix: 버그 수정
- refactor: 리팩토링
- test: 테스트 추가/수정
- docs: 문서 수정
- chore: 빌드/설정 변경

예시: [feat] order: 주문 취소 기능 추가

## PR 본문 필수 항목
- 변경 이유 (Why)
- 주요 변경 내용 (What)
- 테스트 방법
- 관련 Jira 티켓 번호</code></pre>
<h3 id="mcp-연동-jira--github">MCP 연동 (Jira + GitHub)</h3>
<p>Claude Code의 MCP를 통해 Jira와 GitHub를 연동해두면, <code>/commit</code> 스킬 실행 시 Jira 티켓 번호가 커밋 메시지에 자동으로 붙는다. PR 생성 시에도 관련 Jira 이슈가 자동으로 링크된다.</p>
<pre><code>feat(OrderCancellationService/order): [PROJECT-142] 주문 취소 도메인 서비스 추가
feat(CancelOrderUseCase/order): [PROJECT-142] 주문 취소 유스케이스 구현</code></pre><hr>
<h2 id="전체-흐름-요약">전체 흐름 요약</h2>
<pre><code>[ 요구사항 분석 ] ──────────────────────────────────────────
  전체 팀이 함께, 결과물은 마크다운으로 문서화

[ 설계 ]
  TDD Writer Agent → 테스트 뼈대 생성
  Domain Designer Agent → 도메인 설계
  공통 CLAUDE.md + 개인 하네스 적용

[ 구현 ]
  DDD 구조 위에서 Agent/Skills/Hooks 활용
  Endpoint Creator Agent, /swagger 스킬
  post-edit Hook (자동 검증), /commit 스킬 (계층별 커밋)
  MCP (Jira 연동) 으로 티켓 자동 추적

[ 검증 ]
  /review 스킬 → 셀프 체크 (1차: 본인 필터)
  pre-commit Hook → 컴파일/테스트 게이트
  GitHub Actions → 순수 Claude API 자동 리뷰 (2차: 편향 없는 객관적 검증)
  팀원 리뷰 → 개인 하네스 활용, 전문 영역 교차 보완 (3차: 사람 필터)</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>AI 코딩 도구를 &quot;개인 생산성 도구&quot;로만 쓰면 절반만 쓰는 것이다. 진짜 가치는 <strong>팀 전체가 일관된 방식으로 AI를 활용할 때</strong> 나온다.</p>
<p>핵심을 정리하면:</p>
<ul>
<li><strong>하네스는 AI의 고삐다.</strong> 잘 설계된 CLAUDE.md와 개인 하네스가 없으면 AI는 방향을 잃는다.</li>
<li><strong>TDD는 AI의 목표 지점이다.</strong> 테스트가 먼저 있으면 AI가 무엇을 만들어야 하는지 명확하다.</li>
<li><strong>공유 Agents/Skills/Hooks는 팀의 코드 문화다.</strong> 규칙을 문서로만 남기지 말고 자동화로 강제하라.</li>
<li><strong>자동 리뷰는 팀원의 시간을 지킨다.</strong> 기계가 잡을 수 있는 이슈는 기계가 잡게 하고, 사람은 로직과 설계에 집중하라.</li>
</ul>
<p>AI는 계속 발전하겠지만, 결국 <strong>그 AI를 어떻게 팀에 녹이느냐</strong>가 팀의 실력을 결정한다고 생각한다.</p>
<hr>
<h3 id="번외--git-worktree로-도메인-병렬-작업하기">번외 — git worktree로 도메인 병렬 작업하기</h3>
<p>팀 프로젝트를 하다 보면 이런 상황이 종종 생긴다.</p>
<p>&quot;A 도메인 PR 리뷰가 달렸는데, B 도메인 PR 리뷰도 동시에 반영해야 해.&quot;</p>
<p>기존 방식이라면 어쩔 수 없이 순차적으로 처리해야 한다. A 도메인 수정 → 커밋 → B 도메인으로 브랜치 전환 → 수정 → 커밋. 시간 흐름에 따라 하나씩 처리하는 구조다.
git worktree를 쓰면 이게 병렬로 바뀐다.</p>
<pre><code># A 도메인 브랜치를 별도 디렉터리에 체크아웃
git worktree add ../project-order feature/order-cancel

# B 도메인 브랜치를 또 다른 디렉터리에 체크아웃
git worktree add ../project-user feature/user-profile</code></pre><p>이제 터미널을 두 개 열어놓고, 각각 다른 디렉터리에서 Claude Code를 실행하면 된다. 두 도메인이 서로 영향을 주지 않는 독립적인 작업이라면, 말 그대로 동시에 진행할 수 있다.</p>
<pre><code class="language-터미널">Claude Code — order 리뷰 반영      Claude Code — user 리뷰 반영
            ↓                                   ↓
    feat(Order/order): 수정          feat(UserProfile/user): 수정
            ↓                                   ↓
           PR                                  PR</code></pre>
<p>PR 리뷰가 두 도메인에 동시에 달렸을 때 특히 유용했다. 예전엔 한 도메인을 수정하는 동안 다른 도메인은 그냥 기다렸는데, 이제는 둘 다 동시에 Claude Code가 돌아가고 있다.</p>
<p>물론 도메인 간 의존성이 있는 경우엔 순서를 지켜야 하지만, DDD 기반으로 도메인이 명확하게 분리되어 있다면 대부분의 작업은 충분히 병렬로 처리할 수 있다. 이미 Sub-Agent를 병렬로 돌리는 구조를 갖추고 있다면, git worktree는 그 흐름을 워크스페이스 레벨까지 확장해주는 셈이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GIN을 만드느 기준]]></title>
            <link>https://velog.io/@k_joon_/GIN%EC%9D%84-%EB%A7%8C%EB%93%9C%EB%8A%90-%EA%B8%B0%EC%A4%80</link>
            <guid>https://velog.io/@k_joon_/GIN%EC%9D%84-%EB%A7%8C%EB%93%9C%EB%8A%90-%EA%B8%B0%EC%A4%80</guid>
            <pubDate>Mon, 20 Apr 2026 17:05:28 GMT</pubDate>
            <description><![CDATA[<p>난 문자열을 검색할 예정이기에 문자열을 나누는 기준을 알아보겠다.</p>
<h4 id="1-trigram">1. trigram</h4>
<h4 id="2-tsvector">2. tsvector</h4>
<h3 id="1-trigram-1">[1] trigram</h3>
<p>: 세글자씩 잘라서 역색인 구조로 만들기</p>
<p>자료 저장이든 검색이든 앞에 공백 두개, 뒤에 공백 하나를 넣음
&quot;개발의 신이 될거다&quot;
-&gt; 
&quot;  개&quot;
&quot; 개발&quot;
&quot;개발의&quot;
...
&quot;될거다&quot;
&quot;거다 &quot;
까지 저장된다는 것.</p>
<h5 id="공식-문서">[공식 문서]</h5>
<blockquote>
<p>pg_trgm ignores non-word characters (non-alphanumerics) when extracting trigrams from a string. Each word is considered to have two spaces prefixed and one space suffixed when determining the set of trigrams contained in the string. For example, the set of trigrams in the string “cat” is “ c”, “ ca”, “cat”, and “at ”. The set of trigrams in the string “foo|bar” is “ f”, “ fo”, “foo”, “oo ”, “ b”, “ ba”, “bar”, and “ar ”.</p>
</blockquote>
<h3 id="2-tsvector-1">[2] tsvector</h3>
<p>: 토큰으로 잘라서 역색인 구조로 만들기</p>
<blockquote>
<h4 id="토큰이란">토큰이란?</h4>
<p>의미를 가진 최소 단위</p>
</blockquote>
<p>무엇을 토큰으로 볼지는 토크나이저가 정함</p>
<h4 id="1단계---띄어쓰기-기준으로-나누기">1단계 - 띄어쓰기 기준으로 나누기</h4>
<h4 id="2단계---조사나-be동사-같은거-없앰-규칙-기반-파서">2단계 - 조사나 be동사 같은거 없앰 [규칙 기반 파서]</h4>
<p>PostgreSQL의 ts_parse가 실제로 이렇게 동작함. 23가지 토큰 타입을 구분</p>
<pre><code class="language-입력">&quot;apple is good&quot;
 ↑
 왼쪽부터 한 글자씩 읽음

글자별 추적
ts_parse는 상태머신이야. 현재 상태를 들고 있다가, 다음 글자 보고 상태를 바꿈.
시작: 상태 = INIT

1. &#39;a&#39; 읽음

INIT 상태 + 알파벳 글자 → &quot;아, 영문 단어 시작이네&quot;
상태 전환: INIT → IN_ASCIIWORD
버퍼에 &#39;a&#39; 저장
토큰 확정은 아직 안 함 (계속 이어질 수 있으니까)

2. &#39;p&#39; 읽음

IN_ASCIIWORD 상태 + 알파벳 → &quot;계속 단어 중&quot;
상태 유지: IN_ASCIIWORD
버퍼: &quot;ap&quot;

3. &#39;p&#39; 읽음

상태 유지: IN_ASCIIWORD
버퍼: &quot;app&quot;

4. &#39;l&#39; 읽음

버퍼: &quot;appl&quot;

5. &#39;e&#39; 읽음

버퍼: &quot;apple&quot;

6. &#39; &#39; (공백) 읽음

IN_ASCIIWORD 상태 + 공백 → &quot;어, 단어 끝났네&quot;
토큰 확정! → {token: &quot;apple&quot;, tokid: 1 (ASCIIWORD)}
상태 전환: IN_ASCIIWORD → IN_BLANK
버퍼에 공백 저장

7. (다음 글자 &#39;i&#39; 읽기 전에) 공백도 토큰임

IN_BLANK 상태에서 공백 아닌 글자(&#39;i&#39;)를 만남
토큰 확정! → {token: &quot; &quot;, tokid: 12 (BLANK)}
상태 전환: IN_BLANK → IN_ASCIIWORD

8. &#39;i&#39; 처리 (7번에서 이미 읽은 글자)

버퍼: &quot;i&quot;

9. &#39;s&#39; 읽음

버퍼: &quot;is&quot;

10. &#39; &#39; 읽음

토큰 확정! → {token: &quot;is&quot;, tokid: 1 (ASCIIWORD)}
상태: IN_BLANK

11. &#39;g&#39; 읽음

공백 토큰 확정! → {token: &quot; &quot;, tokid: 12 (BLANK)}
상태: IN_ASCIIWORD
버퍼: &quot;g&quot;

12~14. &#39;o&#39;, &#39;o&#39;, &#39;d&#39; 읽음

버퍼: &quot;good&quot;

15. EOF (문자열 끝)

토큰 확정! → {token: &quot;good&quot;, tokid: 1 (ASCIIWORD)}
파싱 종료


최종 결과
sqlSELECT * FROM ts_parse(&#39;default&#39;, &#39;apple is good&#39;);

 tokid |  token  |  description
-------+---------+---------------
     1 | apple   |  ASCIIWORD
    12 | (공백)   |  BLANK  
     1 | is      |  ASCIIWORD
    12 | (공백)   |  BLANK
     1 | good    |  ASCIIWORD
총 5개의 토큰으로 분해됨 (단어 3개 + 공백 2개).</code></pre>
<h4 id="3단계---사전-기반">3단계 - 사전 기반</h4>
<p>running -&gt; run으로 변환</p>
<p>위 과정으로 토큰을 나눔.</p>
<p>근데 문제 ! 이게 영어만 가능하다는거. 물론 한글 가능하지. 띄어쓰기 기준으로 나누고 가능해. 근데 한글은 단어 옆에 조사가 붙어있음. 프로그램은 이걸 조사로 인식할 방법이 없음. 그래서 한글은 지원 안하기에 이건 앞으로도 사용 안할 것임</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PostgreSQL은 null을 받지 못한다?]]></title>
            <link>https://velog.io/@k_joon_/PostgreSQL%EC%9D%80-null%EC%9D%84-%EB%B0%9B%EC%A7%80-%EB%AA%BB%ED%95%9C%EB%8B%A4</link>
            <guid>https://velog.io/@k_joon_/PostgreSQL%EC%9D%80-null%EC%9D%84-%EB%B0%9B%EC%A7%80-%EB%AA%BB%ED%95%9C%EB%8B%A4</guid>
            <pubDate>Mon, 20 Apr 2026 16:24:04 GMT</pubDate>
            <description><![CDATA[<p>&lt; 기존 인기순 쿼리 &gt;</p>
<pre><code>  @Query(&quot;&quot;&quot;                        
      SELECT p FROM TherapyPost p
      WHERE p.deletedAt IS NULL
        AND (:cursorScore IS NULL OR
             p.popularityScore &lt; :cursorScore OR
             (p.popularityScore = :cursorScore AND p.id &lt; :cursorId))
      ORDER BY p.popularityScore DESC, p.id DESC
      &quot;&quot;&quot;)
  List&lt;TherapyPost&gt; findFeedPopular(
      @Param(&quot;cursorScore&quot;) Long cursorScore,
      @Param(&quot;cursorId&quot;) Long cursorId,
      Pageable pageable
  );</code></pre><p>cursorScore이 없으면 바로 true 되는거로 생각되었는데 첫 페이지에 제대로 반영 못함.</p>
<p>&lt; Hibernate가 PostgreSQL에 보내는 실제 SQL &gt;</p>
<pre><code> SELECT * FROM therapy_posts
  WHERE ($1 IS NULL OR popularity_score &lt; $1)</code></pre><p>:cursorScore 같은 이름 있는 파라미터가 $1, $2 같은 위치 기반 바인딩 변수로 바뀜. 만약 여기에 150 이 들어가게 된다면 $1은 Int네? 라고 판단하게됨. 근데 null을 보면 이게 어떤 타입인지 PostgreSQL은 판단할 수 없는거임
그래서 여기서 500 에러를 내는거임</p>
<p>그래서 아래와 같이 수정</p>
<pre><code>  // null을 아예 안 넘김
  if (popCursor == null) {
      // 첫 페이지 → WHERE절에 커서 조건 자체가 없는 쿼리 호출
      repo.findFeedPopular(limit);
  } else {
      // 다음 페이지 → non-null 값만 넘김, PostgreSQL이 타입 추론 가능
      repo.findFeedPopular(popCursor.score(), popCursor.id(), limit);
  }</code></pre><p>즉, null을 넘기지 않도록 쿼리를 다시 작성. 기존 4개의 쿼리였다면 지금은 분기처리로 인해 8개 쿼리이긴함. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GIN / GIST]]></title>
            <link>https://velog.io/@k_joon_/GIN-GIST</link>
            <guid>https://velog.io/@k_joon_/GIN-GIST</guid>
            <pubDate>Mon, 13 Apr 2026 15:09:25 GMT</pubDate>
            <description><![CDATA[<h1 id="검색-인덱스-전략-정리">검색 인덱스 전략 정리</h1>
<h2 id="현재-gin-선택-이유">현재: GIN 선택 이유</h2>
<ul>
<li>데이터 규모가 작음 (게시글 수백~수천 건 수준)</li>
<li>후보가 적으니 similarity() 전체 계산해도 빠름</li>
<li>인덱스 크기가 작고 쓰기 부담 없음</li>
<li>구현이 단순 — % 연산자 + ILIKE로 필터링하고 similarity()로 점수 매기면 끝</li>
</ul>
<h2 id="언제-gist로-전환하나">언제 GiST로 전환하나</h2>
<p>  검색 응답이 체감될 정도로 느려질 때. 구체적으로는:</p>
<ul>
<li>게시글이 수만 건 이상으로 늘어나고</li>
<li>흔한 키워드(&quot;상담&quot;, &quot;치료&quot;)로 검색 시 후보가 수천 건 이상 잡히면서</li>
<li>similarity() 계산이 병목이 되어 응답이 수백 ms를 넘길 때</li>
</ul>
<h3 id="왜-gist로-바꾸나">왜 GiST로 바꾸나</h3>
<p>  GIN은 후보 전체에 similarity()를 돌려야 하지만, GiST는 KNN(&lt;-&gt; 연산자)으로 상위 N개만<br>  인덱스에서 바로 꺼냄. 후보가 많아질수록 차이가 커짐.</p>
<h4 id="전환-비용">전환 비용</h4>
<ul>
<li>인덱스만 교체 + 쿼리에서 &lt;-&gt; 연산자로 변경</li>
<li>코드 변경 최소한 — 엔티티, DTO, 서비스 로직은 그대로</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블 슈팅 (2)]]></title>
            <link>https://velog.io/@k_joon_/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-2</link>
            <guid>https://velog.io/@k_joon_/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-2</guid>
            <pubDate>Sun, 12 Apr 2026 23:59:39 GMT</pubDate>
            <description><![CDATA[<p>커서 기반 페이지네이션 (무한스크롤)</p>
<p>최신 기반과 인기 기반으로 무한스크롤이 가능하다.</p>
<p>근데 안에서 커서를 지정하는게 인코딩방식으로 한다.</p>
<p>내가 필요한건 인기Scroe와 PostId인데, 이걸 넘겨주면 되지 않나?
하지만 문제는 인기순으로만 한다면 문제가 없겠지만 전에 다른 팀원이 만둘어둔 최신순이 있다. 이걸 살리기 위해 커서를 인코딩하여 넣었다. 만약 날짜 기준이라면 날짜+ID 인코딩을 보내고 인기순이라면 인기점수+ID 로 보내서 나중에 풀어 사용했다.</p>
<p>커서 이용을 안하려면 인기순 하나, 날짜순 하나 해서 두개를 각각 만들어야한다.
유지보수성 올리기 위함!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[트러블 슈팅 (1)]]></title>
            <link>https://velog.io/@k_joon_/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-1</link>
            <guid>https://velog.io/@k_joon_/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-1</guid>
            <pubDate>Fri, 10 Apr 2026 21:00:07 GMT</pubDate>
            <description><![CDATA[<h1 id="n1-해결">N+1 해결</h1>
<h4 id="단순히-join을-쓰면-되는데-왜-문제가-되었을까">단순히 Join을 쓰면 되는데 왜? 문제가 되었을까?</h4>
<h4 id="현-프로젝트는-gin을-사용하여-관련도에-기반한-검색을-구현했음-하지만-여기서-postgresql의-similarity-함수가-사용됨-이-함수를-사용하기-위해선-native-query를-날려야함-원래는-jpql의-entitygraph를-사용하는데-여기선-해당-함수를-사용하기-위해-native-query-를-사용해야함-근데-native-query를-사용하면-쿼리가-너무-길어지는-문제가-있음-물론-길게길게-해도-되겠지만-본인은-이걸-두-단계로-나눔">현 프로젝트는 GIN을 사용하여 관련도에 기반한 검색을 구현했음. 하지만 여기서 PostgreSQL의 similarity() 함수가 사용됨. 이 함수를 사용하기 위해선 Native Query를 날려야함. 원래는 JPQL의 @EntityGraph를 사용하는데, 여기선 해당 함수를 사용하기 위해 Native Query 를 사용해야함. 근데 Native Query를 사용하면 쿼리가 너무 길어지는 문제가 있음. 물론 길게길게 해도 되겠지만, 본인은 이걸 두 단계로 나눔.</h4>
<blockquote>
<ol>
<li>Native Query로 similarity() 계산 -&gt; 정렬 -&gt; ID만 반환함 (Join 없이 ID만)</li>
<li>JPQL + EntityGraph 활용
: ID 목록으로 author 자동 Join fetch 함.</li>
</ol>
</blockquote>
<h2 id="개념-자세히">개념 자세히</h2>
<h3 id="jpa">JPA</h3>
<p>: JPA는 세 가지 종류가 있음</p>
<ol>
<li>메서드 이름으로 자동 생성<pre><code>findByAuthorId(Long authorId)
// JPA가 자동으로 SQL 생성</code></pre></li>
<li>JPQL (객체 기준 쿼리 날림)<pre><code>@Query(&quot;SELECT p FROM TherapyPost p WHERE p.deletedAt IS NULL&quot;)
// TherapyPost = 자바 클래스명
// deletedAt = 자바 필드명
// JPA가 이걸 SQL로 변환해줌</code></pre></li>
<li>Native Query (DB 기준 쿼리)<pre><code>@Query(value = &quot;SELECT p.id FROM therapy_posts p WHERE p.deleted_at IS NULL&quot;, 
    nativeQuery = true)
// therapy_posts = 실제 테이블명
// deleted_at = 실제 컬럼명
// JPA 변환 없이 그대로 DB로 날아감</code></pre></li>
</ol>
<h2 id="근데-또-궁금한점">근데 또 궁금한점</h2>
<h5 id="그냥-길게-native-query쓰면-되잖아-왜-2단계로-나눴을까">그냥 길게 Native Query쓰면 되잖아. 왜 2단계로 나눴을까?</h5>
<p>문제 1. 페이지네이션</p>
<blockquote>
<p>JPA의 <code>Page&lt;&gt;</code> 반환을 쓰려면 <code>countQuery</code>도 같이 써야함</p>
</blockquote>
<pre><code>@Query(value = &quot;SELECT p.*, u.* ...&quot;, 
       countQuery = &quot;SELECT count(*) ...&quot;,
       nativeQuery = true)
Page&lt;TherapyPost&gt; findByRelevance(...)</code></pre><p>Native Query에서 <code>Page&lt;TherapyPost&gt;</code>로 받으면 JPA가 <code>p.*</code>와 <code>u.*</code>를 TherapyPost 객체에 맵핑하는걸 보장 못함
JPQL은 JPA가 서버 구조를 알고(어떤필드가 게시글을 나타내는지 등) 만든 쿼리라 맵핑이 보장됨
Native는 JPA가 모르는 쿼리를 날림. 따라서 컬럼 겹치거나 바로바로 맵핑이 안됨</p>
<p>&lt;요약&gt;</p>
<pre><code>Native Query  →  DB 표현 (p.*, u.*)
Page&lt;TherapyPost&gt;  →  JPA 표현 (자바 객체)

둘이 언어가 달라서 JPA가 &quot;이 컬럼이 어느 필드야?&quot; 를 모름
→ 매핑 보장 안 됨

그래서 해결책
Native Query  →  Long(ID)만 반환   ← DB 표현 그대로, 단순한 타입
JPQL          →  Page&lt;TherapyPost&gt; ← JPA 표현, 매핑 보장


[1단계 native query]
  similarity 계산 + 정렬 + 페이지네이션
  → [5, 2, 8, 1] ID 목록 반환

[2단계 JPQL + EntityGraph]
  WHERE id IN (5, 2, 8, 1)
  → author JOIN해서 TherapyPost 객체로 매핑해서 반환</code></pre><p>첫 번째로, countQuery를 NativeQuery로 추가적으로 날려줘야함
두 번째로, NativeQuery가 가리키는 데이터(u.<em>, p.</em>)를 JPA가 맵핑을 못함</p>
<p>문제 2. 매핑 복잡도</p>
<blockquote>
<p>native qyery 결과 + TherapyPost + User 컬럼이 섞인 ResultSet
JPA가 이걸 TherapyPost.author 까지 자동 매핑? 보장 안됨</p>
</blockquote>
<h2 id="기본-개념-설명">기본 개념 설명</h2>
<h4 id="jpa-1">JPA</h4>
<p>: Java Persistence API
자바 객체와 DB 테이블을 연결해주는 표준 인터페이스</p>
<pre><code>자바 객체  ↔  JPA  ↔  DB 테이블
TherapyPost    ↔       therapy_posts</code></pre><p>즉, 직접 SQL 안써도 자바 객체로 DB를 다룰 수 있음
JPA는 스펙이고 실제 구현체는 하이버네이트임.</p>
<h4 id="jpql">JPQL</h4>
<p>: Java Persistence Query Language
JPA에서 쓰는 객체 기준 쿼리 언어</p>
<pre><code>-- SQL (테이블/컬럼 기준)
SELECT * FROM therapy_posts WHERE deleted_at IS NULL

-- JPQL (객체/필드 기준)
SELECT p FROM TherapyPost p WHERE p.deletedAt IS NULL</code></pre><p>SQL이랑 생김새가 비슷한데 테이블명 대신 클래스명, 칼럼명 대신 필드명을 사용함. JPA가 이걸 받아서 SQL로 변환하여 DB로 날림</p>
<h1 id="근데-offset기반-페이지네이션이-아니다-위-설계-다-갈아엎고-커서기반으로-바꿔야함">근데 &quot;offSet&quot;기반 페이지네이션이 아니다!! 위 설계 다 갈아엎고 &quot;커서기반&quot;으로 바꿔야함</h1>
]]></description>
        </item>
        <item>
            <title><![CDATA[공부할 것 (설정)]]></title>
            <link>https://velog.io/@k_joon_/%EA%B3%B5%EB%B6%80%ED%95%A0-%EA%B2%83-%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@k_joon_/%EA%B3%B5%EB%B6%80%ED%95%A0-%EA%B2%83-%EC%84%A4%EC%A0%95</guid>
            <pubDate>Fri, 10 Apr 2026 20:04:53 GMT</pubDate>
            <description><![CDATA[<p>그럼 지금 어떤 테스트들을 한거야? 자세하게 설명좀해줘
그리고 DB를 어떻게 들어갔어? -&gt; 이거도 뭐한건지 자세히 설명해줘
아까 dev와 .env를 본 이유가 뭔지 자세하게 알려줘. 이게 목적이 뭔지
오전 5:02</p>
<ol>
<li>어떤 테스트를 한 거야?
목적: 구현한 검색 기능이 실제로 동작하는지 검증</li>
</ol>
<p>테스트 1 — backfill 확인</p>
<p>sql
SELECT id, search_text FROM therapy_posts LIMIT 5;
V25 마이그레이션이 실행되면서 기존 게시글에 search_text가 채워졌는지 확인. 1번 게시글에 test post for notification 선택안함 이 들어있었으니까 마이그레이션은 정상 실행된 거예요.</p>
<p>테스트 2 — trigram similarity 동작 확인</p>
<p>sql
SELECT similarity(search_text, &#39;감각통합&#39;) AS sim ...
GIN 인덱스가 실제로 유사도 점수를 계산하는지 확인. 처음엔 데이터가 영어뿐이라 0이 나왔고, 한국어 데이터 넣고 나서 0.178이 나와서 trigram이 정상 동작한다는 거 확인.</p>
<p>테스트 3 — 임계값 적정성 확인 0.178 &gt; 0.03 임계값 → 검색 결과가 정상적으로 반환된다는 거 확인.</p>
<ol start="2">
<li>DB를 어떻게 들어간 거야?
bash
docker exec -it builders-db psql -U builders -d builders
이걸 뜯어보면:</li>
</ol>
<p>docker exec        → 실행 중인 컨테이너 안에서 명령어 실행
-it                → 터미널 입출력 연결 (interactive)
builders-db        → 컨테이너 이름 (docker ps로 확인한 것)
psql               → PostgreSQL 클라이언트 실행
-U builders        → 유저명 builders로 접속
-d builders        → builders 데이터베이스 선택
즉 Docker로 떠있는 PostgreSQL 컨테이너 안에 직접 들어가서 SQL을 날린 거예요.</p>
<ol start="3">
<li>dev와 .env를 본 이유
흐름을 이렇게 생각하면 돼요.</li>
</ol>
<p>앱 실행
  → Spring이 DB에 연결 시도
  → DB 연결 정보(URL, 계정)가 필요
  → 이 정보가 .env에 있음
  → .env가 안 읽히면 URL이 비어서 에러
dev를 확인한 이유</p>
<p>IntelliJ Run Configuration dev가 .env 파일을 자동으로 읽도록 설정돼 있어요. gradlew bootRun은 .env를 자동으로 못 읽기 때문에 &#39;url&#39; must start with &quot;jdbc&quot; 에러가 난 거예요.</p>
<p>결론</p>
<p>gradlew bootRun  → .env 못 읽음 → DB URL 없음 → 에러
IntelliJ dev     → .env 읽음   → DB URL 있음 → 정상 실행</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GIN (+ trigram)]]></title>
            <link>https://velog.io/@k_joon_/GIN-trigram</link>
            <guid>https://velog.io/@k_joon_/GIN-trigram</guid>
            <pubDate>Fri, 10 Apr 2026 19:05:45 GMT</pubDate>
            <description><![CDATA[<p>관련도 검색을 하려고 한다. 검색은 게시글 검색
우리 서비스에선 제목, 본문, 치료영역, 나이 이렇게 내용이 있음.</p>
<h3 id="gin">GIN</h3>
<p>: 역인덱스 저장 + 역인덱스 탐색
<strong>&lt;일반 인덱스&gt;</strong></p>
<table>
<thead>
<tr>
<th>Idx</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>컴퓨터</td>
</tr>
<tr>
<td>2</td>
<td>컴퓨터</td>
</tr>
<tr>
<td>3</td>
<td>키보드</td>
</tr>
<tr>
<td>4</td>
<td>마우스</td>
</tr>
<tr>
<td>5</td>
<td>모니터</td>
</tr>
</tbody></table>
<hr>
<p><strong>&lt;역인덱스&gt;</strong></p>
<table>
<thead>
<tr>
<th>역인덱스</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>컴퓨터</td>
<td>1,2</td>
</tr>
<tr>
<td>키보드</td>
<td>3</td>
</tr>
<tr>
<td>마우스</td>
<td>4</td>
</tr>
<tr>
<td>모니터</td>
<td>5</td>
</tr>
</tbody></table>
<h3 id="trigram">trigram</h3>
<p>: 글자를 세글자씩 쪼개는 것</p>
<blockquote>
<p>Ex. 개발의 신이 될거예요
=&gt; 개발의 / 발의&quot; &quot; / 의 신 / &quot; &quot;신이 / 신이&quot; &quot; / 이&quot; &quot;될 / &quot; &quot;될거 / 될거예 / 거예요</p>
</blockquote>
<h3 id="gin--trigram">GIN + trigram</h3>
<p>&lt;일반 인덱스&gt;</p>
<table>
<thead>
<tr>
<th>Idx</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>1번</td>
<td>&quot;컴퓨터마우스&quot;</td>
</tr>
<tr>
<td>2번</td>
<td>&quot;컴퓨터키보드&quot;</td>
</tr>
<tr>
<td>3번</td>
<td>&quot;키보드마우스&quot;</td>
</tr>
</tbody></table>
<p>&lt;역인덱싱&gt;</p>
<table>
<thead>
<tr>
<th>역인덱스</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>컴퓨터</td>
<td>1,2</td>
</tr>
<tr>
<td>퓨터마</td>
<td>1</td>
</tr>
<tr>
<td>터마우</td>
<td>1</td>
</tr>
<tr>
<td>마우스</td>
<td>1,3</td>
</tr>
<tr>
<td>터키보</td>
<td>2</td>
</tr>
<tr>
<td>키보드</td>
<td>2,3</td>
</tr>
<tr>
<td>보드마</td>
<td>3</td>
</tr>
</tbody></table>
<h3 id="검색어-입력시-동작-원리">&lt;검색어 입력시 동작 원리&gt;</h3>
<ol>
<li>trigram이 검색어 쪼갬<pre><code>&quot;컴퓨터마우스&quot; =&gt; &quot;컴퓨터&quot;/&quot;퓨터마&quot;/&quot;터마우&quot;/&quot;마우스&quot;</code></pre></li>
<li>GIN이 역인덱스에서 각 trigram 탐색</li>
</ol>
<pre><code>&quot;컴퓨터&quot;  →  1, 2
&quot;퓨터마&quot;  →  1
&quot;터마우&quot;  →  1
&quot;마우스&quot;  →  1, 3</code></pre><ol start="3">
<li><p>게시글별로 trigram 몇 개 겹쳤는지 카운터</p>
<pre><code>1번 게시글: &quot;컴퓨터&quot; &quot;퓨터마&quot; &quot;터마우&quot; &quot;마우스&quot; → 4개 겹침
2번 게시글: &quot;컴퓨터&quot;                             → 1개 겹침
3번 게시글: &quot;마우스&quot;                             → 1개 겹침</code></pre></li>
<li><p>similarity() 점수 계산</p>
<pre><code>1번: 4/4 겹침 → similarity 1.0  (완전 일치)
2번: 1/4 겹침 → similarity 0.25
3번: 1/4 겹침 → similarity 0.25</code></pre></li>
</ol>
<h2 id="문제-상황">문제 상황</h2>
<p>근데 게시판은 여러 데이터들이 모여있음. 예를들어 제목, 본문, 치료영역, 나이
등등 정보들이 모여있는데, 이걸 어떻게 trigram으로 나눠서 GIN을 적용시킬까?</p>
<h2 id="해결책">해결책</h2>
<ol>
<li>각각 따로 GIN 적용해서 계산을 해, 가중치를 적용하자</li>
<li>역정규화로 모으자 !</li>
</ol>
<h3 id="1번-설명">1번 설명</h3>
<p>각 테이블(데이터) 마다 점수를 계산함. 하지만 해당 IDX 자료구조를 데이터 개수만큼 방문해야함. 이게 트레이드 오프. 이걸 통해 얻을 수 있는건 가중치를 다르게 줘서 제목이 중요하다면 제목 가중치를 올리면 되는것. 커스텀이 가능함.</p>
<pre><code>score = similarity(p.title,   :kw) * 3.0   -- 제목 일치가 가장 중요
      + similarity(p.content, :kw) * 1.0   -- 본문은 보조
      + therapyArea_score              * 1.5   -- Java 매핑값
      + ageGroup_score                 * 1.0   -- Java 매핑값</code></pre><p>하지만 현재 서비스에서는 2번으로 진행하고, 후에 정확도가 필요하다고 판단되면 1번으로 리팩토링 할 예정</p>
<h3 id="2번-설명">2번 설명</h3>
<p>역정규화로 줄글로 모음 -&gt; trigram -&gt; GIN 진행</p>
<pre><code>therapy_area = ART       → &quot;미술치료&quot;
age_group    = AGE_6_12  → &quot;6세&quot;

search_text = 제목 + &quot; &quot; + 본문 + &quot; &quot; + &quot;미술치료&quot; + &quot; &quot; + &quot;6세&quot;</code></pre><p>여기서 트레이드 오프는 본문의 내용이 너무 길때가 문제가 된다.
만약 글자가 10000자라면 trigram은 9998개가 나옴. GIN 역인덱스에 9998개 엔트리 삽입해야함. 너무 큰 비용이라고 생각.
본문을 적을 때는, 앞에 중요 내용을 적을거라 생각하여 앞에서부터 100자만 포함하는 것이 합리적이라 생각.</p>
<p>제목 30자 + 본문 100자 + 치료영역 4자 + 나이 3자 =&gt; 약 150개의 trigram.</p>
<p>물론, 데이터 저장량이 훨씬 많음. 하지만 커뮤니티는 쓰기보다 읽기가 압도적으로 많을 것으로 예상됨. 따라서 검색의 <code>FULL SCAN</code>을 해결하고, 데이터를 좀 더 저장하는 편이 서비스차원에서 맞다고 생각함.</p>
<h4 id="요약">&lt;요약&gt;</h4>
<p>커뮤니티 서비스 특성상 쓰기보다 읽기가 압도적으로 많음
→ 시간-공간 트레이드오프에서 공간을 희생하고 시간을 얻는 것이 유리
→ search_text 역정규화 + GIN trigram 채택</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[페이지네이션]]></title>
            <link>https://velog.io/@k_joon_/%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@k_joon_/%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98</guid>
            <pubDate>Thu, 09 Apr 2026 15:26:21 GMT</pubDate>
            <description><![CDATA[<h1 id="페이지-네이션">페이지 네이션</h1>
<p><strong>크게 세가지 방법이 있음</strong></p>
<ul>
<li>Offset</li>
<li>커서</li>
<li>Keyset</li>
</ul>
<h1 id="offset-페이지네이션">Offset 페이지네이션</h1>
<blockquote>
<p>방식 : LIMIT와 OFFSET SQL 구문을 사용하여 특정 페이지 번호를 지정해 데이터를 가져옴.
특징 : 구현이 간단, 사용자가 특정 페이지로 직접 이동하는 UI에 적합함
단점 : 뒤로 갈수록 이전 행들을 모두 읽고 버려야하므로, 데이터가 많아질수록 성능이 떨어짐 (O(N))</p>
</blockquote>
<h2 id="작동-원리">작동 원리</h2>
<ul>
<li><code>LIMIT</code> : 한 페이지에 보여줄 데이터의 개수</li>
<li><code>OFFSET</code> : 건너뛸 데이터의 개수 ((페이지 번호 - 1 ) * 페이지당 개수)</li>
<li>프론트는 페이지 번호만 넘겨주면 됨</li>
<li>백엔드 역시 직접 계산하지 않고 <code>Pageable</code>객체에 페이지 번호만 넘겨주면 알아서 <code>OFFSET</code>쿼리를 생성해줌<blockquote>
<p>Ex. 5페이지를 조회 (페이지당 10개 조회)</p>
</blockquote>
<pre><code class="language-SQL">SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 40;</code></pre>
<h2 id="장점">장점</h2>
</li>
<li>임의 접근 가능 : 사용자가 1페이지에서 10페이지로 바로 이동하는 경우 쉬움</li>
<li>구현 단순성 : 백엔드와 프론트엔드 모두 추가적인 로직 없이 정수값만 주고받으면 됨</li>
<li>전체 페이지 수 계산 용이: <code>count(*)</code> 쿼리를 통해 전체 페이지가 총 몇 개인지 사용자에게 쉽게 보여줄 수 있음</li>
</ul>
<h2 id="단점">단점</h2>
<ul>
<li><strong>성능 저하</strong>
: 뒤쪽 페이지로 갈수록 DB는 앞의 데이터를 읽고 버리는 작업을 반복해야함. 만약 <code>OFFSET 1,000,000</code>이라면 엔진은 100만개의 행을 메모리에 올리고 정렬한 뒤 버림. I/O 부하와 CPU 소모를 급격히 증가시킴</li>
<li><strong>데이터 불일치</strong>
: 사용자가 1페이지를 보는 동안 새로운 데이터가 삽입되면 2페이지로 넘어갔을 때 1페이지에서 봤던 마지막 데이터가 2페이지의 첫 번째 데이터로 중복 노출되는 현상 발생. 반대로 삭제가 일어나면 데이터를 건너띄게됨</li>
</ul>
<h2 id="질문">질문</h2>
<ul>
<li><strong>Offset 방식에서 인덱스가 있는 컬럼(예: PK인 id)으로 정렬하더라도, 뒤쪽 페이지로 갈수록 성능이 느려지는 근본적인 이유는 무엇일까요? (힌트: 인덱스 엔트리만 읽는 것과 실제 데이터 블록에 접근하는 것의 차이)
=&gt; DB엔진이 특정 행의 물리적 위치를 즉시 알 수 없어 앞선 데이터를 전부 읽고 숫자를 세며 지나가야 함</strong></li>
</ul>
<ol>
<li>배열과 다른 구조 : 메모리 상의 배열은 인덱스로 즉시 접근 가능하지만, DB의 데이터는 디스크 블록에 흩어져있음. 100만번째를 찾으려면 1번부터 100만번까지 순차적으로 스캔해야함</li>
<li>불필요한 I/O 발생 : <code>OFFSET 1,000,000</code>인 경우, DB는 실제로 쓰지도 않을 100만개의 행을 디스크에서 읽어 메모리로 올림. 이 과정에서 엄청난 디스크 I/O와 CPU 자원이 낭비됨</li>
<li>인덱스의 한계 : 인덱스가 정렬은 도와주지만, &quot;몇 번째 데이터가 어디에 있는지&quot;에 대한 지도는 없음. 따라서 인덱스 리프 노드를 하나하나 따라가며 개수를 세는 작업은 피할 수 없음.</li>
</ol>
<ul>
<li><strong>Offset 방식의 성능 문제를 해결하기 위해, 인덱스만으로 Offset을 처리하여 PK만 뽑아낸 뒤, 그 PK들로만 다시 본문을 조회하는 최적화 기법을 &#39;커버링 인덱스(Covering Index)&#39;를 활용한다고 합니다. 이 방식이 왜 성능을 개선할 수 있을까요? (힌트: 데이터 블록 접근 여부)</strong>
: 불필요한 데이터 블록 접근을 차단하여 디스크 I/O를 획기적으로 줄이기 때문.</li>
</ul>
<ol>
<li><p>일반 Offset 방식의 한계
: 일반적인 <code>SELECT * ... LIMIT 10 OFFSET 100000</code> 쿼리는 아래와 같이 동작
 a. 인덱스 탐색 : 정렬 기준에 맞는 인덱스를 찾음</p>
<p> 어떤 인덱스를 말하는 것인가?
 PK 인덱스: 가장 흔하게 ORDER BY id DESC를 사용하는데, 이때는 기본 키(Primary Key)에 자동으로 생성된 인덱스를 사용합니다.
 보조 인덱스(Secondary Index): 만약 ORDER BY created_at처럼 생성일순으로 정렬한다면, 개발자가 성능을 위해 해당 컬럼에 직접 생성해둔 인덱스를 의미합니다.
b. 데이터 블록 접근 : 인덱스에 없는 나머지 컬럼 데이터(제목, 내용 등)를 읽기 위해 매번 디스크의 실제 데이터 블록에 접근함
 c. 버림 : 100만개를 다 읽어온 뒤, 100만개를 버림 (가장 비효율)</p>
</br>        </li>
<li><p>커버링 인덱스 최적화의 원리
: 커버링 인덱스란 쿼리에 필요한 모든 컬럼이 인덱스에 이미 포함되어있는 상태</p>
</li>
</ol>
<p>a. 인덱스만 스캔 : <code>SELECT id</code>와 같이 인덱스에 포함된 컬럼만 사용하여 100만개를 건너 띔. 이때 DB는 실제 데이터 블록에 가지 않고 가벼운 인덱스 파일만 읽음</p>
<blockquote>
<p>여기서 질문!
그럼 정렬부터 시켜야하지 않나?
-&gt; 1. 대부분 B-Tree 구조로 인덱스를 관리함. 따라서 정렬된 상태를 유지함. 데이터가 삽입/수정될 때마다 DB는 인덱스 트리 내에서 정렬 순서에 맞는 위치를 찾아 삽입함.
2. 리프 노드의 연결 : 인덱스의 가장 아래층은 데이터들이 정렬된 상태로 양방향 연결 시트르 형태로 연결되어있음.
3. 결과 : <code>ORDER BY createdAt</code>쿼리가 들어오면, DB는 정렬 알고리즘을 돌리는게 아니라 인덱스 리프 노드의 시작점부터 필요한 개수만큼 순서대로 따라가며 읽기만 함
즉, createdAt을 인덱싱하면 알아서 됨~</p>
</blockquote>
<p>b. 최소한의 접근 : 결과로 확정된 10개의 PK(Id)에 대해서만 실제 데이터 블록에 접근하여 전체 컬럼을 가져옴</p>
<blockquote>
<p>&lt; 요약 &gt;
일빈 방식은 1,000,010 번의 데이터 블록 접근 발생
최적화 방식은 1,000,000 번의 가벼운 인덱스 스캔 + 10번의 데이터 블록 접근
즉, 정렬 비용은 사라지지만 읽고 버리는 행위자체는 여전히 O(N)의 비용이 듬. 단지 <strong>불필요한 데이터 블록 접근(I/O)를 최소화 하는 것</strong>에 의의가 있음 (게시글,작성자,내용 등을 같이 불러오는 비효율을 말하는 것)</p>
</blockquote>
<h1 id="커서-기반">커서 기반</h1>
<blockquote>
<p>방식 : 마지막으로 조회한 데이터의 식별자를 커서로 삼아 그 다음 데이터를 조회함
특정 : 대용량 데이터에서도 인덱스를 활용해 일정한 성능 O(1) 또는 O(logN) 보장
한계 : 3페이지로 바로가기 같은 임의 접근이 불가능하며 무한 스크롤이나 더보기 버튼 형태에 주로 쓰임</p>
</blockquote>
<h2 id="작동-원리-1">작동 원리</h2>
<p>: 사용자가 마지막으로 본 데이터의 <strong>고유한 값</strong>을 커서로 삼아, 그 다음 데이터를 조회함</p>
<pre><code class="language-SQL">SELECT * FROM posts 
WHERE id &lt; [마지막_조회_ID] 
ORDER BY id DESC 
LIMIT 10;</code></pre>
<p>ID 기준 내림차순으로 최대 10개까지 조회를 함. 무한 스크롤에 최적
커서는 값을 기준으로 점프함. 
<strong>offset</strong> : 100번째 데이터를 줘 (20개씩 1페이지일 때 5페이지 누르면됨)
=&gt; 앞에서부터 N개를 건너뛰고 다음걸 보여줌
<strong>cursur</strong> : 100번째 데이터를 주라고 못함. 대신 ID가 550번인 데이터 다음부터 10개 줘 라고 가능
=&gt; 이 값보다 큰 것 중 가장 가까운걸 보여줘</p>
<h2 id="왜-무한스크롤에-적합한가">왜 무한스크롤에 적합한가?</h2>
<p>무한 스크롤은 사용자가 데이터를 순차적으로 소비함</p>
<blockquote>
<ol>
<li>첫 10개를 본다. (마지막 데이터 ID를 기억함)</li>
<li>다음 10개를 요청할 때 그 ID(커서)를 넘겨준다.</li>
<li>DB는 그 ID 위치로 점프해서 다음 10개를 가져온다.
즉, 사용자는 <strong>직전의 좌표</strong>를 항상 가지고 있기에 &quot;특정 조건&quot;에 해당되어 관련된 데이터로 바로 점프할 수 있기 때문임</li>
</ol>
</blockquote>
<h2 id="질문-1">질문</h2>
<ol>
<li>&quot;무한 스크롤 서비스에서 커서 방식을 사용 중인데, 사용자가 특정 게시물을 보고 &#39;뒤로 가기&#39;를 눌렀을 때 이전에 보던 스크롤 위치(데이터 리스트)를 그대로 유지하려면 기술적으로 어떤 처리가 필요할까요?&quot;
: 프론트에서 지금까지 불러온 데이터 리스트와 마지막 커서 위치를 캐싱해야함</li>
</ol>
<ol start="2">
<li><p>개념을 잘 이해하셨다면, 기술 면접 단골 질문을 드리겠습니다.
&quot;커서 기반 페이지네이션을 구현할 때, 정렬 기준이 되는 컬럼(예: 생성일시 created_at)에 인덱스가 걸려 있지 않다면 성능상 어떤 문제가 발생할까요? 이 경우에도 OFFSET 방식보다 빠를까요?&quot;
:
a. FULL SCAN : <code>WHERE createdAt &gt; &#39;&#39;</code> 으로 하나하나 다 찾음
b. 정렬 부하 : 인덱스는 이미 정렬된 상태를 유지하지만, 인덱스가 없으면 DB는 매 요청마다 메모리나 디스크에서 데이터를 다시 정렬해야함. (문제에서 정렬 기준이 되는 컬럼에 인덱스만 없다는 조건이니까)</p>
</li>
<li><p>&quot;커서 기반 페이지네이션을 사용 중인데, 사용자가 리스트 중간에 있는 게시물을 삭제하고 다시 리스트로 돌아왔을 때, 캐싱된 데이터와 서버 데이터 사이의 &#39;데이터 정합성&#39; 문제를 어떻게 해결하는 것이 가장 효율적일까요?&quot;
a. 로컬 캐시 업데이트</p>
</li>
</ol>
<p>-&gt; 서버에서 삭제 성공 응답을 받는 즉시, 프론트 메모리에 저장된 데이터 리스트에서 해당 게시물의 ID를 찾아 삭제
-&gt; 장점 : 추가적인 API 호출 없이 UI를 즉시 갱신할 수 있어 사용자 경험UX가 가장 뛰어남</p>
<p>b. 쿼리 무효화
-&gt; 사용자가 리스트로 돌아왔을 때 해당 리스트 API를 다시 호출하도록 설정
-&gt; 장점 : 구현이 단순, 그 사이 다른 사용자에 의해 변경된 데이터까지 모두 반영된 최신 상태를 보장함
-&gt; 커서 기반 페이지네이션에서 첫 페이지부터 다시 불러와야 하므로 데이터 소모와 서버 부하가 발생할 수 있음.</p>
<h1 id="keyset">Keyset</h1>
<blockquote>
<p>커서 방식의 일종이지만, 정렬 조건이 복잡할 때(생성일순 등) 여러 컬럼을 조합한 키셋을 사용하여 다음 페이지를 결정하는 방식</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE (EventSource 객체)]]></title>
            <link>https://velog.io/@k_joon_/SSE-EventSource-%EA%B0%9D%EC%B2%B4</link>
            <guid>https://velog.io/@k_joon_/SSE-EventSource-%EA%B0%9D%EC%B2%B4</guid>
            <pubDate>Thu, 09 Apr 2026 08:02:43 GMT</pubDate>
            <description><![CDATA[<h1 id="배경">배경</h1>
<p>이 내용에 대해 쓰는 이유는 토큰이 URL상 노출되어 어떤 원리가 적용되는지 공부하고자 씀</p>
<blockquote>
<p>HTTP : 웹에서 클라이언트와 서버가 데이터를 주고받기 위해 사용하는 가장 기초적인 약속(프로토콜) -&gt; 요청과 응답이 존재
구조
: 해더(여기에 토큰), 바디(데이터-JSON,HTML), URL</p>
</blockquote>
<blockquote>
<p>REST API :HTTP의 장점을 최대한 활용하여 자원중심으로 설계를 관리하는 아키텍쳐 스타일</p>
</blockquote>
<blockquote>
<p>EventSource : 브라우저에 내장된 표준 API. 서버가 푸시하는 SSE를 클라이언트에서 수신하기 위해 사용하는 전용 수신기 역할</p>
</blockquote>
<h3 id="여기서-문제">여기서 문제</h3>
<p>EventSource는 헤더 설정이 불가능함. HTTP 헤더를 수정할 수 있는 옵션이 아예 없음. 애초에 설계를 이렇게 하였음. 인증을 위해 JWT 토큰을 보내야 하는데 헤더(Authorization)를 못쓰니 서버가 읽을 수 있는 공간인 URL 쿼리 파라미터(token = axf...)에 실어 보냄</p>
<p><code>/subscribe</code> 하나에서만 하면 문제가 최소겠지만, 그 전 코드에선 모든 요청에 쿼리 파라미터 token을 읽으려 시도함
AI를 도입하며 가장 조심해야 되는게 이런 부분임. 보안 관련 조심하라고 말을 해도 듣질 않음.
<strong>이럴 땐 보완 관련 체크리스트를 세심히 만들어서 체크하라고 해야함.</strong></p>
<p>암튼 전체 바꿔버린 이유가 SSE가 헤더를 못바꾼다고 해서 바꾼거 같은데, subscribe 할 때만 헤더에 넣어줌으로써 토큰 노출을 최소화 함</p>
<blockquote>
<p>문제 1. 서버 로그에 찍힘
: Nginx, Apache, Tomcat 같은 웹 서버는 모든 요청 URL을 로그에 남김. 공격자가 서버 로그만 탈취해도 많은 사용자의 토큰을 얻게 됨
문제 2. 브라우저 히스토리
: 사용자의 브라우저 방문 기록에 토큰이 포함된 URL이 그대로 남음. 공용 PC를 사용한다면 다음 사람이 내 계정을 탈취할 수 있음.
문제 3. Referer 헤더 유출
: 사용자가 해당 페이지에서 외부 링크를 클릭해 다른 사이트로 이동할 때, 브라우저는 이전 페이지의 URL(토큰 포함)을 상대 사이트 서버로 전송</p>
</blockquote>
<p><strong>우선 해결책으로는 노출을 최소화 하는 방향으로 갔음</strong></p>
<h3 id="방어책">방어책</h3>
<p>하지만 노출을 최소화 하더라도 어쩄든 노출이 있을 수 밖에 없음. 해당 서비스에서는 돈 관련이나 보안 문제가 크지 않기에 문제가 되지 않을 수 있으나 방어책은 필요함</p>
<blockquote>
<h4 id="1-일회용-티켓-으로-알림-설정하기">1. 일회용 티켓 으로 알림 설정하기</h4>
</blockquote>
<ol>
<li>티켓 요청 (REST API방식&lt;헤더에 토큰 담겨있음&gt;)
 : 클라이언트가 헤더에 JWT을 담아 <code>POST /api/v1/notification/ticket</code>을 호출함</li>
<li>티켓 발급
 : 서버는 수명이 짧은(10초~30초 - 여유되면 더 줄이고) UUID를 생성해 DB나 Redis에 저장해둠</li>
<li>SSE 연결
 : 클라이언트는 EventSource(/subscribe?ticket=티켓번호) 로 접속</li>
<li>검증 및 폐기
 : 서버는 티켓을 확인해 인증을 처리하고 즉시 티켓 삭제<h4 id="2-서버-로그-마스킹">2. 서버 로그 마스킹</h4>
: 인프라 수준에서 로그 자체를 숨김</li>
</ol>
<p>-&gt; 서버 하드웨어에 로그는 남지만, 관리자나 해커가 그 로그 파일을 열어봐도 실제 토큰 값은 볼 수 없음
<strong><em>-근본적인 해결이 아님-</em></strong></p>
<ol>
<li><em>데이터가 전송되는 과정(In-flight)이나 클라이언트 측에 남는 흔적은 전혀 보호하지 못한다는 명확한 한계</em></li>
<li>*Referer 헤더를 통해 외부 사이트로 토큰이 전달되는 문제는 마스킹으로 해결할 수 없음 *</li>
<li><em>로그를 마스킹하면 디버깅이 어려워질수 있음</em><h4 id="3-폴리필-라이브러리-사용">3. 폴리필 라이브러리 사용</h4>
: 표준 API의 한계를 우회함</li>
</ol>
<p>-&gt; 브라우저의 기본 EventSource 대신 fetch-event-source 같은 라이브러리를 사용
-&gt; 내부적으로 fetch를 사용하므로 SSE 연결임에도 헤더에 토큰을 담을 수 있음. 
<strong>의존성 추가라는 트레이드 오프 (그 외에는 학습이 필요)</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE (ConcurrentHashMap 자료구조)]]></title>
            <link>https://velog.io/@k_joon_/SSE-ConcurrentHashMap-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@k_joon_/SSE-ConcurrentHashMap-%EC%9E%90%EB%A3%8C%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Thu, 09 Apr 2026 06:15:54 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[SSE 동작 원리 일부]]></title>
            <link>https://velog.io/@k_joon_/SSE-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%9D%BC%EB%B6%80</link>
            <guid>https://velog.io/@k_joon_/SSE-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC-%EC%9D%BC%EB%B6%80</guid>
            <pubDate>Thu, 09 Apr 2026 05:37:10 GMT</pubDate>
            <description><![CDATA[<p>댓글 달기가 성공한 후 <code>eventPublisher.publishEvent()</code> 호출
리스너가 <code>@EventLinstner</code>라면 즉시 실행되겠지만, <code>@TranszaztionalEventListner</code>라면 해당 작업을 커밋 후 리스트에 등록을 하도록 요청함.
이 리스트에 등록하는 건 <code>TransactionSynchronizationManager</code>에 저장됨. </p>
<ol>
<li><code>CommentService</code>의 메서드가 종료</li>
<li><code>TransactionManager</code>가 실제 DB에 커밋 명령을 내림. </li>
<li>커밋 성공 후 <code>TransactionManager</code>는 아까 스레드 보관함에 등록된 작업 리스트를 훑음</li>
<li>거기에 담겨 있던 <code>NotificationEvet</code>처리 로직을 꺼내서 리스너에게 전달함</li>
</ol>
<p><code>TransactionSynchronizationManager</code> 안에는 
도착지 정보 + 이벤트, 성공 여부 정보(정상 커밋 되었는지, 실패했는지) 가 저장되어있음. 성공하면 <code>afterCommit()</code>콜백 실행, 또는 <code>afterCompletion()</code> 콜백 실행 </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE 연결 (Emitter 관리)]]></title>
            <link>https://velog.io/@k_joon_/SSE-%EC%97%B0%EA%B2%B0</link>
            <guid>https://velog.io/@k_joon_/SSE-%EC%97%B0%EA%B2%B0</guid>
            <pubDate>Thu, 09 Apr 2026 05:23:10 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-상황">문제 상황</h2>
<h3 id="기본-개념">기본 개념</h3>
<p>Emitter 관리 데이터 : <code>Map&lt;Long, Map&lt;String, SseEmitter&gt;&gt;</code>
=&gt; &lt;UserId, &lt;emitterId, SseEmitter&gt;&gt;
emitterId : 한 사용자가 가진 여러 통로 고유ID (크롬, 모바일, 사파리 ...)
SseEmitter : 데이터를 보내는 HTTP 연결 객체 (통로)</p>
<h3 id="문제">문제</h3>
<p>기본 코드에서는 UserId가 있다면 해당 사용자의 여러 고유ID중 특정 emitterId만 제거한 후, 남은 연결이 없다면 사용자의 미수신 이벤트 캐시(미수신 데이트)도 모두 정리했음. 이건 재시도 과정을 놓치고 설계한 설계실수.</p>
<ol>
<li><p>스레드 A (삭제): 사용자가 마지막 탭을 닫음. isEmpty()를 확인하니 true임. &quot;이제 userId를 지워야지!&quot;라고 결심함.</p>
</li>
<li><p>스레드 B (추가): 그 찰나에 사용자가 새로운 탭을 열어서 userId 밑에 새 이메터를 추가함.</p>
</li>
<li><p>스레드 A (삭제): 아까 true였던 기억만 가지고 emitters.remove(userId)를 실행함.</p>
</li>
<li><p>결과: 스레드 B가 방금 만든 따끈따끈한 새 연결까지 같이 증발함. 사용자는 접속하자마자 알림을 못 받는 상태가 됨.</p>
</li>
</ol>
<p>확인 후 실행 하고 있음.
즉, 연산 사이사이에 타 스레드 침범 가능성이 있음.</p>
<h2 id="해결책-locking">해결책 (Locking)</h2>
<p>Emitter의 <code>computeIfPresent()</code> 사용.
: 해당 키가 속한 버킷(위의 데이터)에 락을 검. 하여 해당 로직이 끝날 때까지 다른 스레드는 이 userId를 건들 수 없음. 즉, 중간에 데이터가 바뀌지 않음
-&gt; 원자성 보장
-&gt; 일관성 보장</p>
<h2 id="추가-학습">추가 학습</h2>
<h4 id="q1-만약-이-emitters-맵을-concurrenthashmap이-아닌-일반-hashmap으로-선언하고-멀티스레드-환경에서-운영한다면-동시에-여러-사용자가-접속하거나-나갈-때-어떤-치명적인-오류가-발생할-수-있을까요-힌트-concurrentmodificationexception">Q1. 만약 이 emitters 맵을 ConcurrentHashMap이 아닌 일반 HashMap으로 선언하고 멀티스레드 환경에서 운영한다면, 동시에 여러 사용자가 접속하거나 나갈 때 어떤 치명적인 오류가 발생할 수 있을까요? (힌트: ConcurrentModificationException)</h4>
<p>: <code>ConcrurrentHashMap</code>은 스레드가 동시에 데이터를 넣고 빼도 데이터가 망가지지 않도록 설계된 자료구조 - 현재 SSE 연결할 때 이 구조 사용
[ 스레드 A가 1번 구역을 쓰는 동안, 스레드 B는 2번 구역을 동시에 쓸 수 있음 -&gt; 맵 전체에 Lock을 거는 것보다 훨씬 빠르고 효율적임 ]
하지만 HashMap을 이용하였을 때, </p>
<ol>
<li>스레드 A가 맵을 돌면서 알림을 보내고 있는데 스레드B가 들어와서 알림을 추가하거나 삭제하면 에러가 남</li>
<li>동시에 같은 위치의 자리에 데이터를 저장할 가능성이 있음</li>
</ol>
<h4 id="q2-sseemitter는-영원히-유지될까요-만약-네트워크-문제로-이-빨대가-막히거나-서버가-설정한-타임아웃-시간이-지나면-어떤-일이-벌어질까요-이때-우리가-공부한-remove-로직이-어떻게-호출되는지-설명해-보세요">Q2. SseEmitter는 영원히 유지될까요? 만약 네트워크 문제로 이 &#39;빨대&#39;가 막히거나 서버가 설정한 &#39;타임아웃&#39; 시간이 지나면 어떤 일이 벌어질까요? 이때 우리가 공부한 remove 로직이 어떻게 호출되는지 설명해 보세요.</h4>
<p>: 영원히 유지되지 않으며, 타임아웃이나 네트워크 오류 발생 시 스프링이 미리 등록된 콜백을 실행하여 <code>remove</code>로직을 자동으로 호출함</p>
<blockquote>
<p>종료 사유</p>
</blockquote>
<ol>
<li>정상 종료 : 모든 데이터를 보내고 <code>complete()</code>를 호출</li>
<li>타임 아웃 : 설정된 시간 동안 아무런 통신이 없을 때</li>
<li>네트워크 오류 : 사용자가 브라우저 탭을 강제로 닫거나 인터넷 연결이 끊겼을 때</li>
</ol>
<p><code>SseEmitter</code>를 생성하는 시점에 문제가 생기면 아래 행동들을 하도록 콜백 등록을 해둠.</p>
<pre><code class="language-java">    public SseEmitter subscribe(Long userId, String lastEventId) {
        SseEmitter emitter = new SseEmitter(SSE_TIMEOUT);
        String emitterId = sseEmitterRepository.save(userId, emitter);
        // 작업이 끝났을 때
        emitter.onCompletion(() -&gt; sseEmitterRepository.remove(userId, emitterId));

        // 시간이 다 되었을 때
        emitter.onTimeout(() -&gt; sseEmitterRepository.remove(userId, emitterId));

        // 에러 발생 (네트워크 끊김 ,, )
        emitter.onError(e -&gt; {
            log.warn(&quot;SSE emitter error userId={}, emitterId={}: {}&quot;, userId, emitterId, e.getMessage());
            sseEmitterRepository.remove(userId, emitterId);
        });

        try {
            emitter.send(SseEmitter.event()
                    .name(&quot;connect&quot;)
                    .data(&quot;connected&quot;));
        } catch (IOException e) {
            sseEmitterRepository.remove(userId, emitterId);
            throw new CustomException(ErrorCode.SSE_CONNECTION_ERROR);
        }

        if (lastEventId != null &amp;&amp; !lastEventId.isBlank()) {
            List&lt;SseEmitterRepository.CachedEvent&gt; missedEvents =
                    sseEmitterRepository.getEventsAfter(userId, lastEventId);
            for (SseEmitterRepository.CachedEvent event : missedEvents) {
                try {
                    emitter.send(SseEmitter.event()
                            .id(event.eventId())
                            .name(&quot;notification&quot;)
                            .data(event.data()));
                } catch (IOException e) {
                    sseEmitterRepository.remove(userId, emitterId);
                    break;
                }
            }
        }

        return emitter;
    }</code></pre>
<h4 id="q3-만약-사용자가-수백만-명이고-알림이-초당-수천-건씩-발생하는-대규모-시스템이라면-이렇게-키마다-락을-거는-방식이-서버-성능에-어떤-영향을-줄-수-있을까요-그리고-이를-개선하기-위해-redis-같은-외부-저장소를-도입한다면-어떤-이점이-있을까요">Q3. 만약 사용자가 수백만 명이고 알림이 초당 수천 건씩 발생하는 대규모 시스템이라면, 이렇게 키마다 락을 거는 방식이 서버 성능에 어떤 영향을 줄 수 있을까요? 그리고 이를 개선하기 위해 &#39;Redis&#39; 같은 외부 저장소를 도입한다면 어떤 이점이 있을까요?</h4>
<p>: 키 단위 락은 연산 자체보다 메모리 점유 측면에서 한계가 있음.</p>
<ol>
<li><p>락 경합 : 수만 개의 스레드가 서로 다른 userId를 수정할 때는 영향이 없음. 하지만 특정 유저에게 수만 건의 알림이 몰려 동일한 키에 접근하는 경우, 스레드들이 줄을 서게 되어 CPU 대기 시간 급증
=&gt; <code>ConcurrentHashMap</code>은 내부적으로 데이터를 여러 개의 <code>버킷</code> 구역에 나눠 담음. 스레드 100개가 각각 서로 다른 <code>userID</code>를 수정한다면, <code>userId</code>들은 해시 함수에 의해 서로 다른 버킷에 배정될 학률이 높음. 
하지만 동인 <code>userId</code>로 접근할 경우 동일한 버킷에 저장될것이기에 큐가 길어짐.</p>
</li>
<li><p>메모리 부하, GC 지연 : SseEmitter 객체를 JVM 힙 메모리에 들고 있으면, GC가 메모리를 청소할 때마다 훑어야 할 객체가 너무 많아져 서비스가 순간적으로 멈추는 현상이 길어짐</p>
</li>
</ol>
<p>Redis 도입 시 이점</p>
<ol>
<li>분산 환경 지원<ul>
<li>어느 서버에서 이벤트가 터지든 Redis에 메시지 발행 (kafka가 할 수도 있지. kafka가 더 안정적)</li>
<li>모든 서버는 Redis를 구독하고 있다가, 자기 서버에 A 유저가 연결되어 있다면 즉시 알림을 보냄. (실시간성 보장)</li>
</ul>
</li>
<li>이벤트 캐싱 및 복구<ul>
<li>서버 메모리가 아닌 Redis에 미수신 알림을 저장하면, 일시적으로 네트워크가 끊겼다 재접속해도 Redis에서 누락된 데이터를 찾아 복구 가능</li>
</ul>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE (비동기 스레드 풀)]]></title>
            <link>https://velog.io/@k_joon_/SSE-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%92%80</link>
            <guid>https://velog.io/@k_joon_/SSE-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%92%80</guid>
            <pubDate>Wed, 08 Apr 2026 19:35:46 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>댓글을 작성할 때, 알림이 가도록 하는게 비즈니스 로직임</p>
<p>한 트랜잭션 안에서 댓글 작성 -&gt; DB 저장 -&gt; 알림 발송
까지 하면 너무 오래 스레드를 잡고있음.</p>
<p>또한 후에 서버가 분리될 수도, SSE가 아닌 다른 외부 시스템을 도입할 수도 있음. 만약 외부와 소통(알림발송)하면서 서버가 기다려야 한다면?
언제까지 기다려야하는지 도통 모름.</p>
<p>이걸 그래서 한 트랜잭션에서 하는건 무모한감이 있음.</p>
<p>현재 서비스를 구현하면서 트랜잭션의 분리가 필요했음.
또한 동기적으로 위의 과정이 된다면 문제</p>
<p>따라서 비동기적으로 알림이 가도록함</p>
<p>여러가지 패턴이 있음. Outbox, 메시지큐, Pub/Sub, Saga Pattern 등이 있음. 물론 여러 패턴들도 비동기 패턴! 이러면서 종류로 나오는 것이 아닌, 비동기를 사용할 때 자주 사용되는 패턴들임.</p>
<p>하지만 현 서비스는 서버 1개로 작개 시작. </p>
<p>따라서 알림은 비동기 스레드 풀로 할 예정</p>
<h2 id="비동기-스레드-풀">비동기 스레드 풀?</h2>
<p>: 작업을 호출한 메인 스레드와 분리하여 백그라운드에서 별도의 스레드가 작업을 처리하도록 관리하는 자원 집합소.</p>
<p>알림에서 이걸 사용하는 이유는 크게 두가지.</p>
<ol>
<li>자원 관리</li>
<li>알림이 지연되더라도 유저에게 빠른 응답을 보내기 위해서임</li>
</ol>
<p>비동기 스레드 풀은 메인 스레드의 작업을 대기열(큐)에 담아두고, 백그라운드에서 별도의 스레드가 이를 처리하게 함으로써 시스템의 응답속도를 높이는 자원 관리 기법임.</p>
<p>메인 스레드와 비동기 스레드를 나누는게 다른 구역에 저장된다! 이런게 아니라 역할에 따라 나뉘는 것. <code>메인 스레드</code>라는건 맨 처음 댓글작성을 맡은 스레드를 말하는 것이고, <code>@Async()</code> 어노테이션이 붙어있는 메서드를 </p>
<h2 id="본인-코드-흐름">본인 코드 흐름</h2>
<blockquote>
<p>자바의 스레드 풀의 기본 설계 원칙
: 스레드 생성 비용이 비싸니 최대한 큐로 버텨라</p>
</blockquote>
<p>댓글작성 
-&gt; 이벤트 발행 : <code>eventPublisher.publishEvent(event)</code> 를 호출하면 스프링 컨테이너(<code>ApplicationContext</code>)에 이벤트 발행했다고 알려줌
-&gt;트랜잭션 동기화 : <code>handleNotificationEvent</code> 에 <code>@TransactionalEventListener</code>(이 메서드는 Event를 받는 리스너라는 뜻) 가 붙어 있으므로, 스프링은 즉시 실행하지 않고 현재 진행 중인 댓글 저장 트랜잭션의 커밋 시점을 기다림
-&gt; 비동기 위임 : 커밋이 완료되면 스프링이 해당 리스너를 깨움. 이 때 <code>@Async(&quot;notificationExecutor&quot;)</code>가 붙어 있는 것을 확인하고, 작업을 메인 스레드가 아닌 <code>notificationExecutor</code> 스레드 풀의 대기열에 던짐 (notificationExecutor -&gt; 얘는 서버가 실행될 때 미리 만듬 )
-&gt; 메인 스레드 종료 : 작업을 던진 직후 메인 스레드는 즉시 사용자에게 응답 반환
-&gt; : 별도 스레드 실행 : <code>notificationExecurot</code> 소속의 스레드 하나가 작업을 꺼내 <code>notificationService.createAndSend(event)</code>를 실제로 수행</p>
<p>&lt; AsyncConfig &gt;</p>
<pre><code class="language-java">    @Bean(name = &quot;notificationExecutor&quot;) -&gt; 스레드 풀의 이름
    public TaskExecutor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2); -&gt; 최소한의 스레드 수 2
        executor.setMaxPoolSize(4); -&gt; 큐가 찼을 때, 추가로 이용할 수 있는 스레드 수
        executor.setQueueCapacity(100); -&gt; 큐의 크기. (스레드가 모두 꽉 찼을 때, 큐에서 대기하는 최대 크기)
        executor.setThreadNamePrefix(&quot;notification-&quot;); -&gt; 로그에 찍힐 스레드 이름의 접두사
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); =&gt; 큐도 차고, 스레드도 찼을 때, 새로운 요청을 버리지 않고, 요청을 보낸 메인 스레드가 직접 알림을 처리하게 하여 작업 유실을 막는 것
        executor.initialize();
        return executor;
    }</code></pre>
<pre><code class="language-java">        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); </code></pre>
<blockquote>
<p>이 친구는 메인 스레드가 작업해주도록 하기 때문에, 비동기가 아닌 동기방식으로 진행됨. 목적은 작업 유실 방지이며, 부하 조절역시 할 수 있음. 메인 스레드가 알림을 처리하느라 바빠지면, 새로운 API 요청을 받는 속도가 느려짐. 근본적인 해결은 아니긴함. </p>
</blockquote>
<p>&lt; CommentService &gt;</p>
<pre><code class="language-java">        if (request.getParentCommentId() == null) { -&gt; 새 댓글
            eventPublisher.publishEvent(NotificationEvent.builder()
                    .senderId(currentUserId)
                    .receiverIds(List.of(post.getAuthor().getId()))
                    .type(NotificationType.NEW_COMMENT)
                    .referenceId(postId)
                    .content(author.getNickname() + &quot;님이 회원님의 게시글에 댓글을 남겼습니다.&quot;)
                    .build());
        } else { -&gt; 대댓글
            eventPublisher.publishEvent(NotificationEvent.builder()
                    .senderId(currentUserId)
                    .receiverIds(List.of(comment.getParentComment().getAuthor().getId()))
                    .type(NotificationType.NEW_REPLY)
                    .referenceId(postId)
                    .content(author.getNickname() + &quot;님이 회원님의 댓글에 답글을 남겼습니다.&quot;)
                    .build());
        }</code></pre>
<p>&lt; NotificationEventListener &gt;</p>
<pre><code class="language-java">    @Async(&quot;notificationExecutor&quot;) -&gt; notificationExecutor 스레드 풀에게 시킴
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) -&gt; 댓글이 DB에 완전히 저장된 후에만 이 메서드를 실행하라
    public void handleNotificationEvent(NotificationEvent event) {
        try {
            notificationService.createAndSend(event);
        } catch (Exception e) {
            log.error(&quot;알림 처리 실패: type={}, senderId={}&quot;, event.getType(), event.getSenderId(), e);
        }
    }</code></pre>
<ol>
<li><p>이벤트 발행
: <code>CommentService</code>가 댓글 저장 로직 중 <code>publishEvent()</code>를 호출.
스프링은 이 이벤트를 즉시 처리하지 않고 현재 트랜잭션의 예약 목록에 담아둠.</p>
</li>
<li><p>커밋 대기 (메인 스레드)
: DB 작업이 모두 끝나고 커밋 될때까지 기다림. (데이터 정합성 보장됨)</p>
</li>
<li><p>핸들러 트리거 (메인 스레드)
: 커밋이 성공하면 스프링이 <code>NotificationEventLinstener</code>를 깨움</p>
</li>
<li><p>비동기 위임 (메인 스레드 -&gt; Executor)
: 핸들러에 <code>@Async</code> 어노테이션이 있음. 메인 스레드는 작업을 <code>notificationExecutor</code>의 대기열에 넣음</p>
</li>
<li><p>메가 응답 (메인 스레드)
: 작업을 던진 직후, 메인 스레드는 자기 할 일을 다했음. 사용자에게 즉시 HTTP 응답을 보냄</p>
</li>
<li><p>백그라운드 처리 (작업 스레드)
: 스레드 풀에 있던 2개의 스레드 중 하나가 대기열에서 작업을 꺼내 실제로 알림을 발송함</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드를 통한 프로토타입 개발]]></title>
            <link>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%86%B5%ED%95%9C-%ED%94%84%EB%A1%9C%ED%86%A0%ED%83%80%EC%9E%85-%EA%B0%9C%EB%B0%9C</link>
            <guid>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C%EB%A5%BC-%ED%86%B5%ED%95%9C-%ED%94%84%EB%A1%9C%ED%86%A0%ED%83%80%EC%9E%85-%EA%B0%9C%EB%B0%9C</guid>
            <pubDate>Wed, 25 Mar 2026 15:21:31 GMT</pubDate>
            <description><![CDATA[<h1 id="고민-사항">고민 사항</h1>
<ul>
<li>클로드 에이전트를 사용해야 효율이 올라감</li>
<li>subAgents 가 있고 AgentTeams가 있음</li>
<li>SubAgents는 비동기적으로 처리 (에이전트끼리 상호 대화 X)</li>
<li>AgentsTeams은 동기적으로 처리 (에이전트끼리 상호 대화 함)
근데 subagents를 만들고 나중에 상호작용하는 과정이 필요한데 필요에 맡게 팀에이전트로 변경이 불가능해
(처음부터 팀에이전트로 하면 되지 않냐고? 이건 토큰이 비효율적으로 사용될 위험에 너무 노출되어있음)</li>
<li>그래서 애초에 만들 때 SubAgents와 TeamAgents를 만들어서 섭에이전트로 작업 시키는걸 Progress.md에 저장해놔
그러면 TeamAgents들이 필요할 때 해당 파일을 보여주고 작업시키는거지</li>
</ul>
<p>팀에이전트가 필요한 순간은 프론트와 백 연동할 때, 복잡한 동기를 Test할 때 등등이 필요함</p>
<p>그러면 섭에이전트와 팀에이전트의 구조를 똑같이 해야 progress를 읽고도 팀에이전트가 잘 하겠네?</p>
<p>그리고 두 번째 문제
모바일에서 클로드코드를 수정할 때, 문제점이 외부API 같은 민감키들임
이거는 차차 어떻게 해결할지 고민해보자</p>
<h1 id="진행-상황">진행 상황</h1>
<p>현재 백엔드와 프론트 구조를 짠 상태
백엔드는 도메인 별로, 프론트는 계층별로 구성했는데 각 디렉터리 안에는 해당 디렉터리에 해당하는 CLAUDE.md를 작성한 상태
최상위 CLAUDE.md를 가볍게 하기 위해 각 단계별 분리를 시키고, 상위 CLAUDE.md는 하위 CLAUDE.md를 참조하는 형태</p>
<blockquote>
<ol>
<li>토큰을 효율적으로 사용하기 위함</li>
<li>너무 많은 명령어는 컨텍스트를 더럽힐 위험</li>
<li>이에 따라 정확도가 떨어질 위험이 있음</li>
</ol>
</blockquote>
<p>현재 Sub-Agent와 Team-Agent는 같은 구조로 되어있음.
코드를 처음부터 작성하며 진행하는 건 병렬로 진행될 수 있는 경우가 많기에 Sub-Agent를 활용함. 단위 테스트까지 가능
하지만 여러 도메인을 서로서로 참조해야하는 통합테스트 인 경우는 Team-Agent로 진행해야함. Team-Agent가 Sub-Agent들이 한 일을 파악하기 쉽도록 같은 구조로 형성함</p>
<p>Sub-Agent는 기능을 완성한 상태이며 단위테스트 까지 모두 진행 완료됨
Team-Agent를 통해 통합테스트를 진행중.
Team-Agent를 위한 /@@ 이런 방법은 없고, 직접 명령어로 Agents들을 말하고 Team-Agents들을 구성해야함.</p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/7ad7f887-165d-40d9-94a0-525da523dfad/image.png" alt=""></p>
<p>Agents를 사용하는데 장점은 컨텍스트를 역시 더럽히지 않음
그래서 Agents들은 각각 자신의 컨텍스트를 다른 공간에서 활용하고, 결괏값만 가져오는 형태임
그래서 Leader로부터 최종 결과를 내가 받는 것</p>
<p>원래 목표는 팀 에이전트를 단계별로 계층화할 생각이였음
하나의 개발 부서 &gt; 프론트 팀, 백엔드 팀 &gt; 각각 도메인 별 혹은 계층별 팀원들</p>
<p>이렇게 하려고 했으나, 아직 클로드코드에서 지원하지 않는 기능</p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/e4ca7251-30c6-441a-8fca-4176059f9afd/image.png" alt="">
근데 얘네가 진짜 완료되면 알아서 돌릴까?
저번에 Hook 이란 개념도 있던데 이건 여기서 사용되나? 봐보자한번</p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/dee50491-867e-4440-94e4-fe767a45d14f/image.png" alt="">
신기하게 대화가 멈춘줄 알았는데 알아서 진행해주네</p>
<br>

<p>&lt; Agent 목록 &gt;
<img src="blob:https://velog.io/0feb92d3-6e76-4ef6-b5fb-c1411023b0fd" alt="업로드중.."></p>
<p>현재 터미널의 클로드 코드의 컨텍스트를 깔끔하게 관리하기 위해 브라우저의 클로드에게 내 명세사항을 자세하게 말하고, 클로드코드에게 명령내릴 프롬포트를 작성해달라고 하는중.
이래야 클로드코드가 명확하게 이해할 수 있게끔 구조를 짜줌
하지만 여전히 올바른 방향으로 짜주지 않는 것들이 있으니 잘 봐야함
(block이랑 차단 기능의 구조를 겹쳐서 짜놓고 결과 다 짰다 그래서 다시 수정함. 이건 너무 많은 AI Agent를 만들어달라고 명령해서 그랬음. 하나씩 만들면 가능하긴 한데 시간이 오래 걸려서 그랬음. 하지만 바쁠 수록 돌아가란 말이 있듯이 하나씩 짜는게 더 안전하다. 내가 이 계획을 놓치고 코드를 다 짜고 했다면 상상만으로도 귀찮아 죽겠다. 암튼 설계의 중요성을 굉장히 느낌. 코드를 짠다고 해서 믿고 놔두면 안됨</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드 가이드 - 심화]]></title>
            <link>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%8B%AC%ED%99%94</link>
            <guid>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%8B%AC%ED%99%94</guid>
            <pubDate>Wed, 25 Mar 2026 10:06:23 GMT</pubDate>
            <description><![CDATA[<h1 id="1-skills--ai에게-주는-업무-매뉴얼">1. Skills — AI에게 주는 업무 매뉴얼</h1>
<h2 id="🎯-1-1-skills란">🎯 1-1. Skills란?</h2>
<p>SKILL.md 파일에 반복 업무 맡김
그럼 프롬포트랑 뭐가 다름?
-&gt; 프롬포트는 재사용성이 떨어짐. 매번 재사용해야하고 일관성이 항상 같다고 보장할 수 없음. 또한 팀공유가 어렵고 트리거 방식임.
하지만 SKILLS는 파일로 보관하고 동일한 품질로 진행되며 파일로 보관하기에 GIT으로 공유하기 쉽고 자동 감지해서 실행하는 기능까지 갖고있음.</p>
<h2 id="📂-1-2-스킬-구조와-로딩-방식">📂 1-2. 스킬 구조와 로딩 방식</h2>
<p>.claude/skills/ 
ㄴ ppt-generator/ - 스킬 폴더
...ㄴSKILL.md - 핵심 파일(필수)
...ㄴtemplate.md - Claude가 채울 템플릿 (선택)
...ㄴexamples/ - 예제 출력(선택)
......ㄴsample.md
...ㄴscripts/ - 실행 가능한 스크립트 (선택)
......ㄴgenerate.py</p>
<p>스킬이 왜 중요한가?
-&gt; 컨텍스트가 한정되어있기 떄문임
-&gt; 무겁게 컨텍스트를 쓰면 예전에 얘기했던거 까먹고 품질이 낮아짐</p>
<h3 id="로딩-방식">로딩 방식</h3>
<p>스킬은 두단계로 나눠서 불러옴</p>
<h4 id="1-description만-로딩">1. Description만 로딩</h4>
<p>: 항상 메모리에 상주
가볍고 빠르게 매칭
=&gt; 스킬 이름, 한 줄 설명, 아주 작은 컨텍스트 만 차지함</p>
<h4 id="2-전체-프롬포트-로딩">2. 전체 프롬포트 로딩</h4>
<p>: 필요할 때만 <strong>온디멘드 로딩</strong>
전체 지시사항 활성화</p>
<p>처음에는 이름과 간단 설명만 있다가 클로드가 이 스킬을 써야곘다 판단이 되면 스킬을 쓰는 구조임. 전에 배운 LazyLoading과 같음</p>
<blockquote>
<h3 id="claudmd-에서-하면-되잖아">Claud.md 에서 하면 되잖아?</h3>
<p>근데 얘는 시작할 때마다 항상 로딩됨
또한 용도는 프로젝트 규칙을 담은거지 워크플로우 용도가 아님
매번 로딩 되니까 토큰 절약을 해야하기 때문에 다 담을 수 없음
세션마다 무조건 적용된다는 특징</p>
<blockquote>
<p>으로 인해서 </p>
</blockquote>
</blockquote>
<h4 id="skills와-다른-특징들을-가짐">SKILLS와 다른 특징들을 가짐</h4>
<p>SKILLS.md는 필요할 때만 로딩되고 반복 작업의 자동화 레시피임
본문은 500줄 이하로 권장되고, 별도 파일 분리가 가능함
슬래시 명령어 또는 조건 매칭으로 명시적으로 혹은 자동으로 적용됨</p>
<p>(서브 에이전트는 별도의 컨텍스트로 사용하고 그 공간에서 돌아감. 따라서 메인 컨텍스트에 침범을 안함. 하지만 컨텍스트에 전부 로드되긴 해서 아주 작은 작업들이라면 불필요함)
반면, 스킬은 안써도 공간을 안잡아 먹어서 토큰 효율이 증가함</p>
<h2 id="✍️-1-3-skillmd-작성법">✍️ 1-3. SKILL.md 작성법</h2>
<h4 id="설정-정보-이름-설명">설정 정보 (이름, 설명)</h4>
<ul>
<li>이름</li>
<li>설명 -&gt; 얘가 가장 중요함 (클로드 코드가 명령을 읽고 이 스킬이 필요하겠다고 판단해야하기 때문에) -&gt; 즉, 추상적인 단어는 사용하지 말도록. 또한 비슷한 다른 스킬과 구분하려면 구체적으로 적어줘야함<h4 id="본문-본문-목적절차참조파일">본문 (본문 (목적/절차/참조파일))</h4>
</li>
</ul>
<h2 id="🛠️-1-4-스킬-만들기">🛠️ 1-4. 스킬 만들기</h2>
<pre><code>/Install-plugin skill-Creator</code></pre><h2 id="📍-1-5-스킬-저장-위치">📍 1-5. 스킬 저장 위치</h2>
<h1 id="2-sub-agent--병렬-실행">2. Sub-Agent &amp; 병렬 실행</h1>
<h2 id="🤖-2-1-sub-agent란">🤖 2-1. Sub-Agent란?</h2>
<p>: 메인 클로드 안에서 별도의 작업 공간을 가진 도우미를 하나 더 생성하는 것
각 서브 에이전트는 자기만의 지시사항, 쓸 수 있는 도구, 별도의 권한을 갖고 있음.</p>
<h4 id="장점">장점</h4>
<ol>
<li>병렬 처리
: 여러 작업을 동시에 실행해 전체 소요 시간을 단축</li>
<li>컨텍스트 보호
: 메인 에이전트의 컨텍스트를 오염시키지 않음</li>
<li>전문화
: 각 Sub-Agent에 전문 역할을 부여</li>
<li>재사용
: 한 번 만든 에이전트를 여러 워크플로우에서 활용</li>
</ol>
<h4 id="해야-하는-것">해야 하는 것</h4>
<ol>
<li>독립적인 작업을 병렬로 분배하기</li>
<li>Sub-Agent에 명확한 역할과 범위 지정</li>
<li>결과를 메인 에이전트에서 통합 처리</li>
<li>에러 발생 시 graceful fallback 설계 -?</li>
</ol>
<h4 id="하지-말아야-하는-것">하지 말아야 하는 것</h4>
<ol>
<li>의존성 있는 작업을 무리하게 병렬화</li>
<li>하나의 Sub-Agent에 너무 많은 역할 부여</li>
<li>Sub-Agent 간 직접 통신 시도 -&gt; 이건 Agent-Teams의 기능에서 해야함</li>
<li>결과 검증 없이 그대로 사용</li>
</ol>
<h2 id="📋-2-2-내장-sub-agent-5종">📋 2-2. 내장 Sub-Agent 5종</h2>
<h3 id="클로드-내부에-이미-에이전트들이-있음">클로드 내부에 이미 에이전트들이 있음</h3>
<h4 id="explore">Explore</h4>
<p>: 코드 탐색, 파일 검색, 구조 파악 (Haiku 모델 사용)</p>
<h4 id="plan">Plan</h4>
<p>: Plan Mode에서 계획 수립을 위한 연구
-&gt; plan 모드 시 자동 트리거 됨.</p>
<h4 id="general-purpose">General-purpose</h4>
<p>: 탐색+수정 모두 필요한 복잡한 다단계 작업</p>
<h4 id="bash">Bash</h4>
<p>: 별도 컨텍스트에서 터미널 명령을 실행함. 명령 실행이 필요할 때 자동 트리거</p>
<h4 id="cluade-code-guide">Cluade Code Guide</h4>
<p>: 클로드 코드 기능에 대한 답변을 함</p>
<h2 id="🔧-2-3-커스텀-에이전트-만들기">🔧 2-3. 커스텀 에이전트 만들기</h2>
<blockquote>
<h4 id="1-agents-명령어로-에이전트-관리-화면-진입">1. /agents 명령어로 에이전트 관리 화면 진입</h4>
</blockquote>
<h4 id="2-create-new-agent-선택">2. Create new agent 선택</h4>
<h4 id="3-이름-입력-ex-code-reviewer">3. 이름 입력 (ex. code-reviewer)</h4>
<h4 id="4-모델-선택">4. 모델 선택</h4>
<h4 id="5-description-작성">5. Description 작성</h4>
<p>-&gt; 에이전트의 목적과 역할을 한 줄로 명확히 설명</p>
<h4 id="6-프롬포트-작성">6. 프롬포트 작성</h4>
<p>-&gt; 에이전트의 행동 지침, 규칙, 출력 형식을 상세히 정의</p>
<h4 id="7-테스트--배포">7. 테스트 &amp; 배포</h4>
<p>-&gt; 실제 작업으로 동작을 검증하고 팀에 공유</p>
<blockquote>
<p>안에서 Congigure agent memory 칸이 있는데, 이거는 Enable 추천 
: 에이전트가 계속 본인의 로컬 메모리, 로컬 캐시같은걸 업데이트 하면서 Self-learning 할 수 있도록 하는 것</p>
</blockquote>
<p><code>프롬포트에서는 A에이전트를 사용해서 B작업을 해줘</code> 라고 명령하면 됨</p>
<blockquote>
<p>테스트 관련 에이전트를 따로 만들면 좋다. 테스트 관련해서는 메인 에이전트가 할 필요가 없지. 그래서 테스트는 에이전트로 관리하자~ </p>
</blockquote>
<h3 id="에이전트-파일-상세">에이전트 파일 상세</h3>
<p>.claude &gt; agents &gt; test-agent.md</p>
<pre><code>You are a senior QA engineer and testing specialist with deep expertise in software testing methodologies, test automation, and quality assurance best practices. You have extensive experience with various testing frameworks across multiple programming languages including Jest, Pytest, JUnit, Mocha, RSpec, and others.

Core Responsibilities
You will:

Execute Tests: Run test suites, individual test files, or specific test cases as needed
Analyze Results: Thoroughly examine test outputs, identify failures, and provide clear explanations
Debug Failures: Investigate failing tests to determine root causes - whether in the test itself or the implementation
Improve Coverage: Identify gaps in test coverage and suggest additional test cases
Maintain Quality: Ensure tests follow best practices and are maintainable
Testing Methodology
When running tests:

First, identify the appropriate test framework and configuration for the project
Run the most relevant tests based on recent changes or user requests
Provide clear, structured output of test results
For failures, include the error message, stack trace, and relevant context
When analyzing failures:

Distinguish between test bugs and implementation bugs
Check for common issues: race conditions, missing mocks, incorrect assertions, setup/teardown problems
Suggest specific fixes with code examples when possible
When improving tests:

Follow the AAA pattern (Arrange, Act, Assert)
Ensure tests are isolated, deterministic, and fast
Use descriptive test names that explain the expected behavior
Mock external dependencies appropriately
Output Format
Structure your responses as follows:

Test Execution Summary
Total tests run
Passed / Failed / Skipped counts
Execution time
Detailed Results (for failures)
Test name and location
Error message and stack trace
Root cause analysis
Suggested fix
Recommendations
Coverage improvements
Test quality suggestions
Performance optimizations
Best Practices
Always check for existing test configuration files (jest.config.js, pytest.ini, etc.)
Respect project-specific testing conventions from CLAUDE.md or similar documentation
Run tests in watch mode only when explicitly requested
Prefer running targeted tests over the entire suite when investigating specific issues
Report both the symptoms and underlying causes of test failures
Edge Cases
If no tests exist, offer to help create a testing structure
If tests require environment setup (databases, services), identify these dependencies
If tests are flaky, note this and suggest stabilization approaches
If test configuration is missing or broken, help fix it before proceeding
You are proactive in ensuring code quality. When you notice untested code or potential issues, raise them even if not explicitly asked.</code></pre><pre><code>&lt;전체 구조&gt;

YAML Frontmatter
-&gt; 에이전트의 메타데이터를 정의, name, model, desctiption, tools 등 핵심 설정

tools 필드
에이전트가 사용할 수 있는 도구를 제한. 불필요한 권한 최소화

Markdown 프롬포트
에이전트의 행동 지침을 자연어로 작성, 역할, 규칙, 체크리스트 등 포함

구조화된 지침
마크다운 헤딩과 리스트로 명확한 구조 제공. 에이전트가 체계적으로 동작</code></pre><h2 id="📍-2-4-에이전트-저장-위치--범위">📍 2-4. 에이전트 저장 위치 &amp; 범위</h2>
<h4 id="claudeagents">~/.claude/agents/</h4>
<p>-&gt; 내 모든 프로젝트에서 사용 가능 (나만 쓰는 개인용 에이전트)</p>
<h4 id="claudeagents-1">.claude/agents/</h4>
<p>-&gt; 이 프로젝트에서만 사용 가능 (팀과 공유할 프로젝트 전용 에이전트)</p>
<h4 id="플러그인의-agents">플러그인의 agents/</h4>
<p>-&gt; 플러그인 활성화된 곳에서만 (플러그인이 제공하는 에이전트)</p>
<h4 id="--agents-cli-플래그">--agents CLI 플래그</h4>
<p>-&gt; 지금 이 세션에서만 (일회용 - 자동화 스크립트에서 즉석으로 쓸 때)
--&gt; 에이전트를 여러개 만들면 컨텍스트에 모두 그 에이전트의 정의가 로드되기 때문에 컨텍스트 효율이 떨어지게 됨. 
CLI에서 즉석으로 만들면 Agent가 생성되고 사라지기 때문에 컨텍스트 오염이 안됨. 즉, 1회성 작업에 적합함
ex. CI/CD</p>
<h2 id="⚡-2-5-실전-패턴">⚡ 2-5. 실전 패턴</h2>
<h3 id="격리">격리</h3>
<p>: 독립 브랜치에서 안전하게 작업. 메인 코드에 영향없이 실행 가능</p>
<pre><code>main -&gt; worktree branch -&gt; 에이전트 작업 -&gt; merge</code></pre><h3 id="병렬">병렬</h3>
<p>: 여러 에이전트를 동시에 실행하여 작업 속도를 극대화</p>
<pre><code>         Agent A
시작  -&gt;  Agent B -&gt; 결합
         Agent C</code></pre><h3 id="체인">체인</h3>
<p>: 한 에이전트의 결과를 다음 에이전트에 전달하여 순차 처리</p>
<pre><code>분석 -&gt; 리팩터 -&gt; 테스트 -&gt; 리뷰</code></pre><h3 id="재개">재개</h3>
<p>: 이전 에이전트의 컨텍스트를 이어받아 작업을 계속 진행</p>
<pre><code>세션 1 중단 -&gt; 컨텍스트 저장 -&gt; 세션 2 재게</code></pre><h2 id="🧭-2-6-sub-agent-vs-메인-대화-판단-기준">🧭 2-6. Sub-Agent vs 메인 대화 판단 기준</h2>
<h4 id="기준--직접-할지-심부름을-시킬지-임">기준 : 직접 할지, 심부름을 시킬지 임</h4>
<p>간단한 건 직접 하는게 빠름
시간이 오래 걸리는건 서브에이전트 시키자. 이걸 메인에서 하면 다른 걸 못함. 따라서 심부름 시키고 결과만 받아오자</p>
<h3 id="main-대화">Main 대화</h3>
<pre><code>질문 1 -&gt; 질문 2 -&gt; 질문 3 -&gt; 질문 4</code></pre><ol>
<li>단일 스레드 - 순차처리</li>
<li>모든 컨텍스트 공유</li>
<li>순서대로 실행</li>
</ol>
<h3 id="sub-agent">Sub-Agent</h3>
<pre><code>         Sub A
메인  -&gt;  Sub B -&gt; 결과 통합
         Sub C</code></pre><ol>
<li>분기된 스레드 - 병렬 가능</li>
<li>격리된 컨텍스트</li>
<li>동시 실행 가능</li>
</ol>
<h3 id="판단-기준">판단 기준</h3>
<ol>
<li>작업이 독립적인가?
Yes : Sub-Agent</li>
<li>컨텍스트 오염 우려가 있나?
Yes : Sub-Agent</li>
</ol>
<blockquote>
<h2 id="여기서-sub-agent와-agent-teams와의-차이점이-뭐냐">여기서 Sub-Agent와 Agent-Teams와의 차이점이 뭐냐?</h2>
</blockquote>
<h3 id="sub-agent-1">Sub-Agent</h3>
<p>: 단방향 (Main -&gt; Sub)
Sub는 Main이랑만 연결되어있음.
에이전트 끼리 소통을 못함</p>
<h3 id="teams">Teams</h3>
<p>: 상호 작용 (양방향) - 모두 얽혀있는 구조
: 여러 클로드 코드 인스턴스가 팀으로 협업하는 구조임
리더 에이전트가 작업을 조율하고 팀원에게 분배함
팀원끼리 직접 메시지를 주고받을 수 있음
Agent A &lt;-&gt; Agent B &lt;-&gt; Agent C &lt;-&gt; Agent A</p>
<h1 id="3-hooks--자동화-엔진">3. Hooks — 자동화 엔진</h1>
<h2 id="⚡-3-1-hook이란">⚡ 3-1. Hook이란?</h2>
<h2 id="🔧-3-2-hook-만들기">🔧 3-2. Hook 만들기</h2>
<h2 id="📝-3-3-hook-json-구조">📝 3-3. Hook JSON 구조</h2>
<h2 id="📋-3-4-이벤트-타입">📋 3-4. 이벤트 타입</h2>
<h1 id="4-고급-팁--생산성-극대화">4. 고급 팁 &amp; 생산성 극대화</h1>
<h2 id="🖥️-4-1-멀티-인스턴스-운영">🖥️ 4-1. 멀티 인스턴스 운영</h2>
<h2 id="🌳-4-2-git-worktree로-병렬-작업">🌳 4-2. Git Worktree로 병렬 작업</h2>
<h2 id="🎤-4-3-음성-입력-voice">🎤 4-3. 음성 입력 (/voice)</h2>
<h2 id="🔧-4-4-mcp-대신-로컬-bash-스크립트">🔧 4-4. MCP 대신 로컬 Bash 스크립트</h2>
<h2 id="🏗️-4-5-커스텀-mcp-서버-빌드">🏗️ 4-5. 커스텀 MCP 서버 빌드</h2>
<h1 id="5-통합-데모-요약--1차-피드백-한큐에-수정하기">5. 통합 데모 요약 — 1차 피드백 한큐에 수정하기</h1>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드 가이드 - 실전]]></title>
            <link>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%8B%A4%EC%A0%84</link>
            <guid>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EA%B0%80%EC%9D%B4%EB%93%9C-%EC%8B%A4%EC%A0%84</guid>
            <pubDate>Tue, 24 Mar 2026 14:16:24 GMT</pubDate>
            <description><![CDATA[<h1 id="컨텍스트-관리">컨텍스트 관리</h1>
<h2 id="1-1-second-brain--memory-구축">1-1. Second Brain &amp; /memory 구축</h2>
<p>세컨드 브레인을 구축해야함
새로운 패턴, 해결책, 의사결정 이유에 대해 로컬 마크다운 파일에 저장해야함
앱을 개발하는 동안에 마주했던 패턴들이나 해결책 들에 대해 명시해주면, 다음에 비슷한 포인트가 있을 때 이걸 참고하게끔 하면 됨. 근데 이제 이걸 수동으로 할 필요가 없음
-&gt;  클로드코드가 <code>/memory</code> 라는 기능으로 자동 메모리 시스템 관리를 함
MEMORY.md 파일에 저장함</p>
<ul>
<li>빌드 명령 자동 기억</li>
<li>디버깅 인사이트 저장</li>
<li>코드 패턴 학습</li>
</ul>
<p>매 세션 시작 시 이 파일이 자동으로 로드되기 때문에 클로드가 바로 활용 가능함. ex. 방금 한 내용 패턴, 인사이트를 기억해줘. 라고 한다면 바로 저장이 됨</p>
<p>개인 패턴은 MEMORY.md
팀원간 패턴은 CLAUDE.md에 저장하도록!</p>
<h2 id="1-2-lazy-loading---claudemd-참조-구조">1-2. Lazy Loading - CLAUDE.md 참조 구조</h2>
<p>컨텍스트를 어떻게 불러올 것인가?
CLAUDE.MD에 API앤드포인트, DB스키마, 코딩 컨벤션, 아키텍쳐 문서 등을 모두 넣어놓는게 문제. 정작 필요한건 매우 적지만 불필요한 토큰을 매번 사용하고 있는 것</p>
<blockquote>
<p>CLAUDE.md는 <strong>규칙과 참조만</strong>
세부 .md를 만들어서 거기서 해당 도메인이나 내용이 한정된 것들에 대한 정보를 담아야함.</p>
</blockquote>
<p>이 전략을 사용하면 Lazy Loading이 돼서 필요할 때만 읽을 수 있음. 즉, 토큰이 아껴지고 정확도가 좀 더 높아짐</p>
<p>또한 다른 팁은 메인claude.md 를 놓고, 각 폴더의 claude.md를 세부적으로 만드는 것</p>
<h4 id="클로드가-필요한-것만-가져오고-작업하는-것이-목표">클로드가 필요한 것만 가져오고 작업하는 것이 목표</h4>
<h2 id="1-3-컨텍스트-윈도우-관리">1-3. 컨텍스트 윈도우 관리</h2>
<p>** 한 세션에서는 한 작업만 하자. 즉, 도메인이 달라지고 겹치지 않게끔 혹은 백엔드갔다가 프론트갔다가 db갔다가 테스트했다가 작업하지 말자. 또는 로그인 기능이 끝났다면 회원정보 쪽으로 이동하는걸 추천 **</p>
<p>작업을 <strong>* 세분화 *</strong> 하는 습관이 필요함.
전체 결제 시스템을 만들어줘 ! 가 아니라,
웹훅 핸들러 구현해줘! 와같이 세분화를 시켜서 쪼개야함</p>
<h4 id="실전-워크플로우">실전 워크플로우</h4>
<ol>
<li>Plan Mode에서 전체 설계</li>
<li>/clear 또는 새 세션</li>
<li>첫 번째 단계인 구현 (이 단계에서는 터미널을 여러개 열어서 하나의 작업씩 맡겨서 하면 더 빠름 - 더 나아가면 서브 에이전스, 에이전스 팀즈 기능까지 해야함)</li>
<li>완료 -&gt; /clear -&gt; 다음 단계</li>
</ol>
<p>** 핵심은 플랜을 짜는 세션과 구현을 하는 세션을 분리해야한다. **
(과거 세션을 없애는 것을 두려워하지 말라. 새 세션으로 하는게 더 성능이 좋을 수 있다)</p>
<h2 id="1-4-mcp-토큰-모니터링">1-4. MCP 토큰 모니터링</h2>
<p>/mcp : 갖고있는 MCP
만약 안쓰는 MCP는 비활성화 해야함 (토큰 관리)
도구설명 가지고도 많은 토큰을 사용함</p>
<p>자주 쓰는 기능만 골라서 커스텀 MCP를 사용하는 것을 추천</p>
<h2 id="1-5-mermaid-아키텍처-정리">1-5. Mermaid 아키텍처 정리</h2>
<blockquote>
<p>Mermaid : 코드를 입력하면 자동으로 다이어그램을 그려주는 도구
이걸 바탕으로 ERD, 시퀀스 다이어그램 등의 내용을 표현할 수 있음</p>
</blockquote>
<p>CLAUDE.md에 Mermaid 아키텍쳐를 다이어그램으로 표현하면 컨텍스트 효율적이며 클로드가 빠르게 이해할 수 있음.</p>
<p>이것도 LazyLoading으로 활용하면 됨</p>
<h2 id="1-6-무거운-작업은-스크립트로-오프로드">1-6. 무거운 작업은 스크립트로 오프로드</h2>
<blockquote>
<p>무거운 작업을 대화에서 하다보면 컨텍스트가 오염될 수밖에 없음</p>
</blockquote>
<p>그래서 클로드에게 오히려 스크립트를 작성하게하고 결과를 돌려줘서 클로드가 다음 작업을 할 수 있게 만드는 것!</p>
<p>데이터 파싱이나 파일처리 같은 것들을 대화안에서 시키면 컨텍스트가 너무 커지게됨. 왜냐면 그 큰 파일을 처리해야하는  과정에서 컨텍스트가 오염됨.
출력이 너무 길어지기 때문임</p>
<p>만약 10만행의 CSV 파일을 읽고 -&gt; 컨텍스트 저장 -&gt; 처리 
이 과정에서 컨텍스트가 오염되고 퀄리티가 낮아짐</p>
<p>그래서 클로드에게 CSV를 파싱하는 디비 마이그레이션 스크립트를 작성해달라고 부탁하고 클로드에게 그 스크립트를 돌려달라고 하고, 그 스크립트를 돌려서 나온 아웃풋을 다시 클로드가 받아서 다음 작업을 실행하게 되는 것</p>
<p>클로드는 그 큰 엑셀을 읽을 필요가 없음. 데이터 처리를 할 필요가 없음
대신에 스크립트가 처리한 데이터에 결괏값만 받아서 다음 작업을 진행하면됨</p>
<p>** AI가 방대한 원본 데이터를 직접 읽게 하지 말고, 데이터를 요약·가공할 &#39;도구(스크립트)&#39;를 만들게 하여 그 결과값만 대화에 활용 **</p>
<blockquote>
<p><strong>스크립트 오프로드(Offload)의 과정</strong></p>
</blockquote>
<ol>
<li>목표 전달: &quot;이 10만 행 CSV에서 결제 실패 원인별로 개수를 집계하는 파이썬 스크립트를 짜줘&quot;라고 요청합니다.</li>
<li>스크립트 생성: AI는 방대한 데이터를 직접 보는 대신, 데이터를 처리할 <strong>논리(코드)</strong>만 만듭니다. (이 코드는 매우 짧으므로 컨텍스트를 거의 차지하지 않습니다.)</li>
<li>실행 및 결과 입력: 사용자가 로컬에서 스크립트를 실행하거나,
AI의 코드 실행 기능(Claude의 Analysis Tool 등)을 통해 실행합니다.</li>
<li>결과값 피드백: 스크립트가 뽑아낸 핵심 요약 결과(예: &quot;A에러 50건, B에러 30건&quot;)만 AI에게 전달합니다.</li>
<li>최종 작업: AI는 깨끗한 컨텍스트 상태에서 그 요약된 결과를 바탕으로 &quot;왜 A에러가 많이 발생하는지 분석해줘&quot; 같은 고차원적인 추론을 수행합니다.</li>
</ol>
<h1 id="워크플로우--코딩-철학">워크플로우 &amp; 코딩 철학</h1>
<h2 id="2-1-plan-mode-먼저">2-1. Plan Mode 먼저</h2>
<h2 id="2-2-tdd-기반-스마트-코딩">2-2. TDD 기반 스마트 코딩</h2>
<p>작은 변경을 한다면, 꼭 테스트를 작성해서 테스트를 꼭 잘 돌아가는지 확인하고 커밋하고 이거를 반복해야함.</p>
<h2 id="2-3-thinking-로그-읽기">2-3. Thinking 로그 읽기</h2>
<p>클로드가 과정을 진행하며 생각하는 과정을 진행하는데, 이걸 무시하면 안됨
클로드가 &quot;이 함수는 @를 하는 것 같으니까,,&quot; 라면서 가정을 세우는 순간들이 있는데, 이게 틀릴때가 있음. 그러면 바로 중단시켜야함. 다시 시작하거나, 그 가정을 수정해줘야함. 초반에 잡는게 좋다.</p>
<h2 id="2-4-다른-ai에게-비평-받기">2-4. 다른 AI에게 비평 받기</h2>
<p>클로드랑 작업하다가 클로드 플랜이 다른 AI가 어떻게 생각할지에 대해 Export 해서 Gemini에게 보여줌</p>
<p>팁: 커스텀 스킬을 만들 수 있음
이 과정을 자동화 하는거지.
내가 plan을 요약해서 다른AI에게 보여주고, 취합해서 나에게 다시 보여주도록 하는 것을 작성하는 것</p>
<h2 id="2-5-에러-로그-그대로-붙여넣기">2-5. 에러 로그 그대로 붙여넣기</h2>
<p>에러를 해석해서 설명하지 말고, 에러를 그대로 붙여넣어라</p>
<h2 id="2-6-로컬-todomd-활용">2-6. 로컬 TODO.md 활용</h2>
<p>TODO 파일을 프로젝트 처음부터 끝까지 하나의 파일에서 관리하는걸 추천
AI가 오늘할일 내일할일을 알고 있는게 아니기 때문에 우리가 TODO파일을 관리하면서 업데이트 해주고, 한거, 혹은 안한거 AI에게 공유를 해주고 AI도 똑같이 작업이 뭐가 남았고, ~를 했고 그런것들을 알 수 있음.</p>
<p><strong>하루 시작 할 때</strong></p>
<ol>
<li>오늘 할일을 정리함</li>
<li>TODO.md에 체크리스트 작성</li>
<li>클로드에게 순차 처리 지시</li>
<li>Agent Teams로 병렬처리</li>
<li>세션 종료 : TODO.md 업데이트<h2 id="2-7-wat-프레임워크">2-7. WAT 프레임워크</h2>
프로젝트 관리 방법론임
WorkFlow
: 작업 흐름을 정리</li>
</ol>
<ul>
<li>예: 코드 작성 → 테스트 실행 → 오류 수정 → 배포</li>
</ul>
<p>Agents
: AI가 스스로 판단하고 작업하고 (AgentTeams 같은 것들)</p>
<ul>
<li>모든 걸 다 잘하는 하나의 AI가 아니라, &#39;코드 리뷰어&#39;, &#39;테스터&#39;, &#39;기획자&#39;처럼 역할(Persona)이 부여된 AI들의 팀 </li>
</ul>
<p>Tools
: 도구들을 조합해준다. </p>
<ul>
<li>AI가 직접 할 수 없는 실제 물리적 작업을 수행</li>
<li>서버 가동, 유닛 테스트 실행, DB 쿼리 전송, API 호출 등</li>
</ul>
<p>효과
<strong>추론과 코드 실행 분리</strong>
복잡한 추론 과정은 클로드에게 맡기고,
실행은 도구나 스크립트에게 맡기는 것 (서버 돌리고 테스트하고 등?)</p>
<h1 id="체크리스트">체크리스트</h1>
<h2 id="컨텍스트-관리-체크리스트">컨텍스트 관리 체크리스트</h2>
<h2 id="워크플로우-체크리스트">워크플로우 체크리스트</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[클로드 코드 가이드 - 입문 (CLAUD.MD)]]></title>
            <link>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@k_joon_/%ED%81%B4%EB%A1%9C%EB%93%9C-%EC%BD%94%EB%93%9C-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Tue, 24 Mar 2026 07:00:56 GMT</pubDate>
            <description><![CDATA[<h1 id="claudemd-작성법">CLAUDE.MD 작성법</h1>
<h3 id="절대-규칙-맨-위에-배치">절대 규칙 (맨 위에 배치)</h3>
<ul>
<li>프로덕션 DB 직접 쿼리 금지, 시크릿 파일 커밋 금지 등<h3 id="아키텍쳐">아키텍쳐</h3>
</li>
<li>폴더 구조를 트리 형태로<h3 id="빌드--테스트-명령어">빌드 / 테스트 명령어</h3>
</li>
<li>pnpm dev, pnpm test - Claude가 알아서 빌드 &amp; 테스트 실행<h3 id="도메인-컨텍스트">도메인 컨텍스트</h3>
</li>
<li>비즈니스 로직<h3 id="코딩-컨벤션">코딩 컨벤션</h3>
</li>
<li>네이밍 규칙, 커밋 메시지 포맷</li>
</ul>
<p>근데 매번 CLAUDE.MD를 읽기 때문에 크기가 커지면 커질수록 토큰 소모량이 커짐.
CLAUDE.MD는 300자 이내로 유지하는게 토큰 효율적임
프로젝트가 커지면 당연하게 CLAUDE.MD 역시 커질 수 밖에 없음
이럴 땐 폴더 별로(API / WEB / DB) 이런식으로 나눠서 CLAUDE.MD를 따로 작성해줌. 이런 경우 더 가볍고 효율적이게 움직임</p>
<p>CLAUDE.MD를 직접 수정할 필요는 없음
클로드코드에게 <code>@@패턴을 CLAUDE.MD에 추가해줘</code> 만 하면 알아서 저장해줌</p>
<p>또한 <code>Trigger-Keyword</code> 를 등록할 수 있음.</p>
<blockquote>
<h3 id="trigger-keyword란">Trigger-Keyword란?</h3>
<p>여러 스텝이 있는 과정을 하나의 명령어로 실행할 수 있는 것
예를 들어 깃에 push하는 과정
<code>테스트 -&gt; git add -&gt; git commit -m &quot;message&quot; -&gt; git push -&gt; 과정 요약 정리</code>
이 과정을 특정 키워드로 바로 실행하도록 명령하는 설계를 넣어줄 수 있음.
반복 작업 및 여러 작업이 있는 경우 설정해두면 편리</p>
</blockquote>
<br>

<p>이거와 비슷한 기능이 <code>슬래시 커맨드</code>로 만들 수 있음 (반복적인 작업)</p>
<blockquote>
<p>&quot;배포&quot; or &quot;ship&quot;
<code>테스트 -&gt; git add -&gt; git commit -m &quot;message&quot; -&gt; git push -&gt; 과정 요약 정리</code> 이걸 슬래시 커멘트로 만들어줘 ~
라고 하면 
.claude / commands / ship.md 라는 파일이 아래와 같이 만들어짐
<img src="https://velog.velcdn.com/images/k_joon_/post/4f5af19e-498a-4c53-b277-880b41cbdfb7/image.png" alt=""></p>
</blockquote>
<pre><code class="language-java">Ship Command
Git add, commit, push를 자동으로 수행하는 명령어입니다.

실행 단계
변경 사항 확인: git status와 git diff를 실행하여 변경된 파일 목록과 내용을 확인합니다.
파일 분석: 변경된 파일들의 내용을 읽고 분석합니다.
커밋 메시지 생성: 변경 내용을 기반으로 적절한 커밋 메시지를 생성합니다.
Conventional Commits 형식 사용 (feat, fix, refactor, docs, chore 등)

한글로 작성
변경의 &quot;무엇&quot;보다 &quot;왜&quot;에 초점
Git 작업 수행:
git add - 변경된 파일 스테이징
git commit - 생성된 메시지로 커밋
git push - 원격 저장소에 푸시

주의사항
민감한 파일(.env, credentials 등)이 포함되어 있으면 경고하고 사용자에게 확인을 요청합니다.
커밋 전에 생성된 메시지를 사용자에게 보여주고 확인을 받습니다.
push 실패 시 원인을 분석하고 해결 방법을 제안합니다.

실행
위 단계를 순서대로 수행하세요. 각 단계에서 문제가 발생하면 사용자에게 알리고 진행 여부를 확인합니다.</code></pre>
]]></description>
        </item>
    </channel>
</rss>