<?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>Tue, 01 Sep 2026 18:15:11 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[LIKE '%두글자%'는 왜 trigram GIN 인덱스를 타지 못하는가]]></title>
            <link>https://velog.io/@k_joon_/LIKE-%EB%91%90%EA%B8%80%EC%9E%90%EB%8A%94-%EC%99%9C-trigram-GIN-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%ED%83%80%EC%A7%80-%EB%AA%BB%ED%95%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@k_joon_/LIKE-%EB%91%90%EA%B8%80%EC%9E%90%EB%8A%94-%EC%99%9C-trigram-GIN-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%ED%83%80%EC%A7%80-%EB%AA%BB%ED%95%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Tue, 01 Sep 2026 18:15:11 GMT</pubDate>
            <description><![CDATA[<p>앞 글에서 <code>WHERE name LIKE &#39;%공원%&#39;</code>이 GIN trigram 인덱스를 만들어놨는데도 5만 건을 전부 후보로 올려버리는 걸 확인했음. 그때는 &quot;공백 패딩 때문인 것 같다&quot;까지만 알아내고 끝냈는데, 계속 걸리는 게 하나 있었음.</p>
<blockquote>
<p><code>show_trgm(&#39;공원&#39;)</code>은 <code>&quot;  공&quot;</code>, <code>&quot; 공원&quot;</code>, <code>&quot;공원 &quot;</code> 세 개를 뱉음. 토큰이 분명히 나오는데 왜 &quot;추출된 트라이그램이 없다&quot;는 거임?</p>
</blockquote>
<p>이번 글은 이 질문 하나를 소스까지 따라가서 푸는 내용임.</p>
<hr>
<h2 id="1-넣을-때와-찾을-때는-다른-함수를-씀">1. 넣을 때와 찾을 때는 다른 함수를 씀</h2>
<p>GIN 인덱스는 텍스트를 트라이그램으로 쪼개는 일을 두 군데에서 함. 넣을 때 한 번, 찾을 때 한 번. 근데 이 둘은 <strong>서로 다른 함수</strong>임.</p>
<p>이건 내가 지어낸 구분이 아니라 GIN의 공식 인터페이스임. PostgreSQL 문서 &quot;GIN Indexes → Extensibility&quot;에 오퍼레이터 클래스가 구현해야 할 지원 메서드로 규정돼 있음.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>하는 일</th>
</tr>
</thead>
<tbody><tr>
<td><code>extractValue</code></td>
<td>색인할 항목에서 키 배열을 뽑음</td>
</tr>
<tr>
<td><code>extractQuery</code></td>
<td>질의 값에서 키 배열을 뽑음</td>
</tr>
</tbody></table>
<p>그리고 <code>extractQuery</code>의 시그니처에는 <code>StrategyNumber n</code>이 들어감. 문서 설명은 이럼.</p>
<blockquote>
<p>n은 오퍼레이터 클래스 내 연산자의 전략 번호이며, extractQuery는 종종 n을 참조해 query의 자료형과 <strong>키 값을 추출할 방법을 결정해야 한다</strong></p>
</blockquote>
<p>즉 어떤 연산자로 들어왔느냐에 따라 추출 방식이 갈리는 게, 임시방편이 아니라 처음부터 의도된 설계라는 거임.</p>
<h2 id="2-pg_trgm의-실제-구현">2. pg_trgm의 실제 구현</h2>
<p><code>contrib/pg_trgm/trgm_gin.c</code>를 열어보면 그대로 나옴.</p>
<p><strong>색인 쪽</strong> — <code>gin_extract_value_trgm()</code>은 <code>generate_trgm()</code>을 호출함. <code>show_trgm()</code>이 쓰는 그 함수임.</p>
<p><strong>질의 쪽</strong> — <code>gin_extract_query_trgm()</code>은 <code>strategy</code>로 <code>switch</code> 분기함.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>호출 함수</th>
</tr>
</thead>
<tbody><tr>
<td><code>Similarity</code> / <code>WordSimilarity</code> / <code>StrictWordSimilarity</code> / <code>Equal</code></td>
<td><code>generate_trgm()</code></td>
</tr>
<tr>
<td><code>Like</code> / <code>ILike</code></td>
<td><code>generate_wildcard_trgm()</code></td>
</tr>
<tr>
<td><code>RegExp</code> / <code>RegExpICase</code></td>
<td><code>createTrgmNFA()</code></td>
</tr>
</tbody></table>
<p><code>Like</code> 분기에 붙은 주석이 핵심임. 와일드카드 검색에서는 <strong>잠재적으로 매칭될 수 있는 모든 문자열이 반드시 포함해야 하는</strong> 트라이그램만 추출한다고 돼 있음.</p>
<p>그리고 함수 끝에 이 처리가 있음.</p>
<pre><code class="language-c">if (trglen == 0)
    *searchMode = GIN_SEARCH_MODE_ALL;</code></pre>
<p>주석은 &quot;추출된 트라이그램이 없으면 인덱스 전체를 스캔해야 한다&quot;. <code>rows=50000</code>의 정체가 이 세 줄이었음.</p>
<blockquote>
<p>정리하면, <code>show_trgm(&#39;공원&#39;)</code>이 보여주는 3개는 <code>generate_trgm()</code>의 결과고, <code>LIKE &#39;%공원%&#39;</code>은 <code>generate_wildcard_trgm()</code>을 탐. <strong>애초에 다른 함수임.</strong> 그래서 앞의 결과를 보고 &quot;3개 나오는데 왜 없다는 거지?&quot;라고 생각한 게 헛다리였던 거임.</p>
</blockquote>
<h2 id="3-그럼-와일드카드-함수는-왜-0개를-뱉나">3. 그럼 와일드카드 함수는 왜 0개를 뱉나</h2>
<p><code>trgm_op.c</code>를 봐야 함. 여기서 함수 두 개가 나옴.</p>
<h3 id="3-1-get_wildcard_part--패딩은-조건부임">3-1. <code>get_wildcard_part()</code> — 패딩은 조건부임</h3>
<p>함수 헤더 주석에 조건이 명시돼 있음.</p>
<blockquote>
<p>찾은 단어가 <strong>비단어 문자나 문자열 경계로 둘러싸여 있으면</strong>, 이 함수는 대응되는 패딩 공백을 buf에 포함시킨다</p>
</blockquote>
<p>패딩을 아예 안 붙이는 게 아니라, <strong>와일드카드 메타문자(<code>%</code>, <code>_</code>)에 인접하면 생략</strong>하는 거임. 함수 안에 <code>in_leading_wildcard_meta</code> / <code>in_trailing_wildcard_meta</code> 플래그가 그 판정용으로 들어 있음.</p>
<p><code>%공원%</code>은 양쪽이 다 <code>%</code>니까 두 플래그가 모두 참이 되고, 패딩 없이 <code>공원</code> 6바이트(UTF-8)만 buf에 담김.</p>
<h3 id="3-2-make_trigrams--패딩을-안-함-자르기만-함">3-2. <code>make_trigrams()</code> — 패딩을 안 함, 자르기만 함</h3>
<p>여기가 마지막 매듭이었음. 함수 위에 붙은 주석이 이럼.</p>
<blockquote>
<p>&#39;str&#39;에 담긴 단어(<strong>필요한 경우 이미 패딩된 상태</strong>)로부터 트라이그램을 추가한다</p>
</blockquote>
<p>이 함수는 패딩을 안 함. 이미 공백이 채워진 버퍼를 받아서 3글자씩 창을 밀며 <strong>자르기만</strong> 함. 패딩은 그 이전 단계, 호출자가 하는 일임.</p>
<table>
<thead>
<tr>
<th>호출자</th>
<th>패딩 정책</th>
</tr>
</thead>
<tbody><tr>
<td><code>generate_trgm_only()</code></td>
<td><strong>무조건</strong> 앞 2칸 · 뒤 1칸</td>
</tr>
<tr>
<td><code>get_wildcard_part()</code></td>
<td><strong>조건부</strong> (와일드카드 인접 시 생략)</td>
</tr>
</tbody></table>
<p>그래서 <code>make_trigrams</code>에 넘어가는 buf가 애초에 다름.</p>
<pre><code>show_trgm(&#39;공원&#39;)   →   buf = &quot;  공원 &quot;   (5글자)
LIKE &#39;%공원%&#39;       →   buf = &quot;공원&quot;      (2글자)</code></pre><p><code>make_trigrams</code>가 하는 일은 양쪽 다 똑같음. 받은 buf를 3글자씩 자를 뿐임.</p>
<pre><code>&quot;  공원 &quot;  →  [  공] [ 공원] [공원 ]      창이 3번 채워짐
&quot;공원&quot;     →  [공 원 ?]                    3번째 칸이 없음 → 0번</code></pre><h2 id="4-표현을-바꾸니까-바로-이해됨">4. 표현을 바꾸니까 바로 이해됨</h2>
<p>계속 <strong>&quot;2글자를 3글자 토큰으로 만든다&quot;</strong>고 생각했던 게 문제였음. 정확히는 이거임.</p>
<blockquote>
<p><strong>&quot;2글자를 5글자로 늘려놓고, 그걸 3글자씩 자른다&quot;</strong></p>
</blockquote>
<p>토큰을 <em>만드는</em> 게 아니라, 늘려놓은 문자열에서 <em>잘라내는</em> 거임. 그러니 늘리는 단계(패딩)를 건너뛰면 자를 재료가 없음. <code>LIKE &#39;%공원%&#39;</code>은 <code>%</code> 때문에 그 단계를 건너뛴 거고.</p>
<p><code>&#39;대공원코&#39;</code>가 됐던 건 늘리지 않아도 원본이 이미 4글자라 창이 채워졌기 때문임.</p>
<h2 id="5-확인--대공원코는-5개가-아니라-2개였음">5. 확인 — <code>&#39;대공원코&#39;</code>는 5개가 아니라 2개였음</h2>
<p>앞 글에서 <code>&quot;대공원코&quot;</code>가 5개 토큰이라 인덱스를 탔다고 썼는데, 이건 부정확한 서술이었음.</p>
<pre><code>show_trgm(&#39;대공원코&#39;)  →  &quot;  대&quot;, &quot; 대공&quot;, &quot;대공원&quot;, &quot;공원코&quot;, &quot;원코 &quot;   (5개)
LIKE &#39;%대공원코%&#39;      →  &quot;대공원&quot;, &quot;공원코&quot;                            (2개)</code></pre><p>패딩 붙은 3개가 빠지는 이유는 똑같음. <code>서울대공원코스</code>에는 <code>&quot;  대&quot;</code>가 없음(대가 단어 앞이 아니니까). 그걸 필수 조건으로 걸면 정작 찾아야 할 행을 놓침.</p>
<p>앞 글 실행계획의 <code>Buffers: shared hit=6</code>이 그 흔적이었음. 5개 키를 조회했으면 인덱스 버퍼를 더 읽었을 거임.</p>
<h2 id="6-그럼-like-show_trgm공원-같은-건-안-되나">6. 그럼 <code>LIKE show_trgm(&#39;공원&#39;)</code> 같은 건 안 되나</h2>
<p>안 됨. 세 가지가 걸림.</p>
<p><strong>타입이 안 맞음.</strong> <code>show_trgm()</code>은 <code>text[]</code> 배열을 반환함. <code>LIKE</code>의 우변은 <code>text</code> 하나라 문법 오류가 남.</p>
<p><strong>설령 문자열이라 쳐도 의미가 뒤집힘.</strong> <code>LIKE &#39;  공원 &#39;</code>은 &quot;이 값이 공백 두 개 + 공원 + 공백과 일치하냐&quot;는 뜻임. 패딩 공백은 인덱스 내부에서만 쓰는 경계 마커지 실제 데이터에 존재하는 문자가 아님.</p>
<p><strong>가장 중요한 건, 그래도 전략이 안 바뀜.</strong> 우변에 뭘 넣든 <code>LIKE</code>는 <code>LikeStrategyNumber</code>고, <code>gin_extract_query_trgm()</code>은 그 번호를 보고 <code>generate_wildcard_trgm()</code>으로 감. 우변 문자열이 예뻐진다고 다른 함수를 타지 않음. <strong>분기 기준은 연산자지 값이 아님.</strong></p>
<h2 id="7-정말-그-3개를-쓰고-싶다면">7. 정말 그 3개를 쓰고 싶다면</h2>
<p><code>generate_trgm()</code>을 타는 연산자로 바꾸면 됨. <code>SimilarityStrategyNumber</code>가 그 분기임.</p>
<pre><code class="language-sql">SET pg_trgm.similarity_threshold = 0.3;

EXPLAIN ANALYZE
SELECT * FROM route WHERE name % &#39;공원&#39;;</code></pre>
<p>이건 <code>&#39;공원&#39;</code>을 <code>&quot;  공원 &quot;</code>으로 패딩해서 3개를 뽑고, 그대로 인덱스 키로 씀.</p>
<p>대신 판정 방식이 다름. LIKE는 &quot;추출된 키가 전부 존재해야 통과&quot;인데(<code>gin_trgm_consistent()</code>가 AND로 검사함), 유사도는 &quot;일치 비율이 임계값 이상이면 통과&quot;임. 그래서 <code>테스트공원목장</code>은 3개 중 0개 일치라 탈락함. <strong>검색 결과 자체가 달라지는 거라 <code>LIKE</code>의 대체재는 아님.</strong></p>
<h2 id="8-정리">8. 정리</h2>
<table>
<thead>
<tr>
<th>쿼리</th>
<th>인덱스에 던지는 키</th>
<th>판정</th>
</tr>
</thead>
<tbody><tr>
<td><code>show_trgm(&#39;공원&#39;)</code></td>
<td>(진단용, 인덱스 안 씀)</td>
<td>—</td>
</tr>
<tr>
<td><code>LIKE &#39;%공원%&#39;</code></td>
<td>0개</td>
<td>전체 스캔 후 Recheck</td>
</tr>
<tr>
<td><code>LIKE &#39;%대공원코%&#39;</code></td>
<td>2개 (<code>대공원</code>, <code>공원코</code>)</td>
<td>전부 존재해야 통과</td>
</tr>
<tr>
<td><code>name % &#39;공원&#39;</code></td>
<td>3개 (패딩 포함)</td>
<td>비율이 임계값 이상</td>
</tr>
</tbody></table>
<p>실패 조건은 두 개가 동시에 성립할 때임.</p>
<pre><code>①  리터럴 양옆이 %      →  패딩 생략
②  리터럴이 3글자 미만  →  자를 재료 부족</code></pre><p><code>공원%</code>는 ①이 깨져서 되고, <code>%대공원코%</code>는 ②가 깨져서 됨. <code>%공원%</code>만 둘 다 걸림.</p>
<p>한글에서 2글자 <code>LIKE &#39;%...%&#39;</code>가 주력이면 pg_trgm은 구조적으로 안 맞음. 2-gram 색인인 pg_bigm을 봐야 하는 이유가 여기 있음. 다음 글에서 다뤄볼 예정임.</p>
<hr>
<h2 id="출처">출처</h2>
<p><strong>공식 문서</strong></p>
<ul>
<li>데이터 쪽 패딩 규칙(앞 2 · 뒤 1), &quot;추출 가능한 트라이그램이 없는 패턴은 전체 인덱스 스캔으로 전락한다&quot;
→ <a href="https://www.postgresql.org/docs/current/pgtrgm.html">https://www.postgresql.org/docs/current/pgtrgm.html</a> (F.35.1, F.35.4)</li>
<li><code>extractValue</code> / <code>extractQuery</code> 분리, <code>StrategyNumber</code>로 추출 방법 결정
→ <a href="https://www.postgresql.org/docs/current/gin-extensibility.html">https://www.postgresql.org/docs/current/gin-extensibility.html</a></li>
</ul>
<p><strong>소스 코드</strong></p>
<ul>
<li><code>gin_extract_value_trgm()</code> / <code>gin_extract_query_trgm()</code>의 전략 분기, <code>GIN_SEARCH_MODE_ALL</code>, <code>gin_trgm_consistent()</code>의 AND 판정
→ <a href="https://github.com/postgres/postgres/blob/master/contrib/pg_trgm/trgm_gin.c">https://github.com/postgres/postgres/blob/master/contrib/pg_trgm/trgm_gin.c</a></li>
<li><code>get_wildcard_part()</code>의 조건부 패딩, <code>make_trigrams()</code>의 &quot;already padded&quot; 주석
→ <a href="https://github.com/postgres/postgres/blob/master/contrib/pg_trgm/trgm_op.c">https://github.com/postgres/postgres/blob/master/contrib/pg_trgm/trgm_op.c</a></li>
</ul>
<p><strong>pg_bigm</strong></p>
<ul>
<li>2-gram 색인, GIN만 지원, LIKE만 지원, 1~2글자 키워드에 강함, PostgreSQL 18 지원
→ <a href="https://github.com/pgbigm/pg_bigm/blob/master/docs/pg_bigm_en.md">https://github.com/pgbigm/pg_bigm/blob/master/docs/pg_bigm_en.md</a></li>
</ul>
<p><strong>메일링 리스트</strong> (아래 둘은 현재 코드 명세가 아니라 <strong>패치 제출 당시 개발자의 설명</strong>임)</p>
<ul>
<li>&quot;와일드카드 검색에서는 이 함수들이 더 이상 같은 일을 하지 않기 때문에 extract query와 extract value 함수를 분리했다&quot; — Alexander Korotkov, pgsql-hackers, 2011-01-29</li>
<li>&quot;트라이그램을 추출할 수 없는 와일드카드는 전체 인덱스 스캔을 유발하는데, 이는 느리지만 정확하다(slow but correct)&quot; — Alexander Korotkov, pgsql-hackers, 2011-01-08</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[카프카 개념 정리]]></title>
            <link>https://velog.io/@k_joon_/%EC%B9%B4%ED%94%84%EC%B9%B4-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@k_joon_/%EC%B9%B4%ED%94%84%EC%B9%B4-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Sat, 29 Aug 2026 12:11:27 GMT</pubDate>
            <description><![CDATA[<h2 id="1-카프카란">1. 카프카란?</h2>
<p>카프카는 여러 시스템 사이에서 데이터(메시지)를 비동기로 주고받게 해주는 메시지 중계 시스템임.
A 서버가 이벤트를 카프카에 던지면, B, C 서버가 각자 원하는 시점에 그걸 받아가는 구조임.</p>
<p>핵심은 Producer와 Consumer가 서로를 직접 알 필요가 없다는 것. 둘 다 카프카만 바라보고 통신하기 때문에 시스템 간 결합도가 낮아짐 (느슨한 결합).</p>
<h2 id="2-producer--consumer">2. Producer / Consumer</h2>
<ul>
<li>Producer: 카프카에 메시지를 만들어서 보내는 쪽</li>
<li>Consumer: 카프카에서 메시지를 꺼내가는 쪽</li>
</ul>
<p>Java 코드로 보면 이런 형태임.</p>
<pre><code class="language-java">// Producer
Properties props = new Properties();
props.put(&quot;bootstrap.servers&quot;, &quot;localhost:9092&quot;);
props.put(&quot;key.serializer&quot;, &quot;org.apache.kafka.common.serialization.StringSerializer&quot;);
props.put(&quot;value.serializer&quot;, &quot;org.apache.kafka.common.serialization.StringSerializer&quot;);

KafkaProducer&lt;String, String&gt; producer = new KafkaProducer&lt;&gt;(props);
ProducerRecord&lt;String, String&gt; record =
    new ProducerRecord&lt;&gt;(&quot;order-topic&quot;, &quot;user123&quot;, &quot;주문 발생!&quot;);
producer.send(record);
producer.close();</code></pre>
<pre><code class="language-java">// Consumer
Properties props = new Properties();
props.put(&quot;bootstrap.servers&quot;, &quot;localhost:9092&quot;);
props.put(&quot;group.id&quot;, &quot;delivery-group&quot;);
props.put(&quot;key.deserializer&quot;, &quot;org.apache.kafka.common.serialization.StringDeserializer&quot;);
props.put(&quot;value.deserializer&quot;, &quot;org.apache.kafka.common.serialization.StringDeserializer&quot;);

KafkaConsumer&lt;String, String&gt; consumer = new KafkaConsumer&lt;&gt;(props);
consumer.subscribe(List.of(&quot;order-topic&quot;));

while (true) {
    ConsumerRecords&lt;String, String&gt; records = consumer.poll(Duration.ofMillis(100));
    for (ConsumerRecord&lt;String, String&gt; r : records) {
        System.out.println(&quot;받은 메시지: &quot; + r.value());
    }
}
</code></pre>
<p>Consumer는 <code>while(true)</code> 무한루프 안에서 <code>poll()</code>을 계속 호출하는 구조임. 즉, Consumer 하나당 이 루프를 담당할 스레드가 하나 필요함. Consumer 3개를 띄우면 스레드도 3개 필요한 셈.</p>
<h2 id="3-topic">3. Topic</h2>
<p>메시지를 아무데나 던지지 않고 &quot;주제별 우편함&quot;에 넣음. 이 우편함이 Topic임.</p>
<p>Topic 이름은 그냥 문자열이라 자유롭게 정할 수 있음 (<code>order-topic</code>이든 <code>ooorder-topic</code>이든 상관없음, 카프카 입장에선 그냥 이름표일 뿐). 단, Producer가 쓰는 이름과 Consumer가 구독하는 이름이 정확히 일치해야 서로 통함.</p>
<p>Topic 존재 여부 같은 메타데이터는 Broker(클러스터) 내부에 저장됨.</p>
<h2 id="4-partition">4. Partition</h2>
<p>Topic 하나를 여러 조각으로 쪼갠 게 Partition임. 쪼개는 이유는 병렬 처리 때문.</p>
<p><code>order-topic</code>이 Partition 3개면 메시지가 p0, p1, p2로 분산 저장됨. Consumer 여러 개가 각자 다른 Partition을 동시에 읽을 수 있어서 처리 속도가 올라감.</p>
<p><strong>같은 key로 보낸 메시지는 항상 같은 Partition에 들어감.</strong> 카프카가 key를 해시 계산해서 어느 Partition에 넣을지 정하는데, 같은 key는 항상 같은 계산 결과가 나오기 때문임.</p>
<pre><code class="language-java">new ProducerRecord&lt;&gt;(&quot;order-topic&quot;, &quot;user123&quot;, &quot;주문A&quot;);
new ProducerRecord&lt;&gt;(&quot;order-topic&quot;, &quot;user123&quot;, &quot;주문B&quot;);
// 위 두 메시지는 key가 같아서 무조건 같은 Partition에 들어감</code></pre>
<p>왜 이게 중요하냐면, 같은 Partition 안에서는 메시지 순서가 보장됨. 카프카는 여러 Partition에 병렬로 쓰고 읽다 보니 전체 순서 보장은 애초에 포기하고, 대신 &quot;같은 key끼리의 순서&quot;만 보장하는 현실적인 타협을 함.</p>
<h2 id="5-consumer-group">5. Consumer Group</h2>
<p>같은 역할(기능)을 하는 Consumer들의 묶음임 (<code>group.id</code>로 묶임).</p>
<ul>
<li>같은 그룹 안 Consumer들: Partition을 나눠서 가져감 (분업, 처리량 증가, 중복 처리 방지)</li>
<li>다른 그룹 Consumer들: 같은 Topic을 각자 독립적으로 처음부터 끝까지 다 읽음</li>
</ul>
<p>하나의 Topic에 그룹은 여러 개 붙을 수 있음. 예를 들어 <code>order-topic</code> 하나에 &quot;배송그룹&quot;, &quot;통계그룹&quot;, &quot;알림그룹&quot;이 각자 따로 붙어서 전체 메시지를 독립적으로 다 읽어갈 수 있음.</p>
<p><strong>같은 그룹 안에서는 Partition 하나에 Consumer 1개만 붙을 수 있음.</strong> Consumer가 Partition보다 많으면 남는 Consumer는 놀게 됨. &quot;기능&quot;은 컨슈머 개별 단위가 아니라 그룹 단위임 - 같은 그룹 안의 Consumer들은 다 같은 코드를 실행하는 복제 인스턴스일 뿐임.</p>
<h2 id="6-broker--kafka-클러스터">6. Broker &amp; Kafka 클러스터</h2>
<p>Broker는 카프카 서버 한 대를 말함. <strong>Kafka(클러스터)는 실체가 있는 하나의 프로그램이 아니라, Broker 여러 대가 서로 통신하면서 하나처럼 작동하는 그룹을 부르는 개념적 단위</strong>임.</p>
<pre><code>Kafka 클러스터
├── Broker 1
├── Broker 2
├── Broker 3 (컨트롤러 역할 겸임)</code></pre><p>Broker를 여러 대 쓰는 이유는 두 가지임.</p>
<ol>
<li>분산 저장: Partition들을 여러 Broker에 나눠 저장해서 부하 분산</li>
<li>장애 대비: Replication으로 복제해두면 Broker 하나 죽어도 서비스 유지</li>
</ol>
<p>한 Broker에 같은 Topic의 Partition이 다 몰리면 안 됨. 그 Broker가 죽으면 Topic 전체가 통째로 죽어버리기 때문. 그래서 카프카는 같은 Topic의 Partition들을 최대한 다른 Broker에 흩어서 배치함.</p>
<p>Controller는 Broker들 중 하나(또는 몇 개)가 맡는 역할로, 클러스터 메타데이터(어떤 Topic/Partition이 어디 있는지, ISR 목록, 리더가 누군지)를 관리함. 예전엔 Zookeeper라는 별도 프로그램이 이 역할을 했지만, 최신 버전은 Broker 자체가 컨트롤러 역할까지 하는 KRaft 방식으로 바뀜.</p>
<h2 id="7-replication--isr">7. Replication &amp; ISR</h2>
<p>Replication: 같은 Partition 데이터를 다른 Broker에도 복사해두는 것.</p>
<pre><code>p0: broker1(원본/리더), broker2(복제본), broker3(복제본)</code></pre><p>원본(리더) 있는 Broker가 죽으면, 복제본 있는 Broker가 새 리더 역할을 이어받음.</p>
<p><strong>ISR(In-Sync Replica)</strong>: 복제본들 중에서 데이터가 최신이고 정상 동작 중인 것만 모은 그룹임. 리더 Broker가 각 팔로워(follower)로부터 주기적으로 &quot;따라잡았다&quot;는 응답을 받아서, 일정 시간(<code>replica.lag.time.max.ms</code>, 기본 10초) 안에 응답이 없으면 그 팔로워를 ISR에서 제외함.</p>
<p>원본이 죽었을 때 새 리더는 반드시 ISR 안에 있는 복제본 중에서 뽑힘 (뒤처진 복제본은 데이터 유실 위험이 있어서 후보에서 제외됨).</p>
<h2 id="8-offset--commit">8. Offset &amp; Commit</h2>
<p>Offset: Consumer가 Partition에서 어디까지 읽었는지 기록하는 위치 번호 (책갈피 개념).</p>
<p>Consumer가 직접 들고 있는 게 아니라, 카프카 내부의 <code>__consumer_offsets</code>라는 특수 Topic에 저장됨.</p>
<pre><code>배송그룹 - order-topic-p0 - offset: 15
배송그룹 - order-topic-p1 - offset: 22
통계그룹 - order-topic-p0 - offset: 8</code></pre><p>Commit: Consumer가 &quot;여기까지 처리 완료했다&quot;고 카프카에 offset을 보고하는 행위임. poll로 &quot;읽는 것&quot;과 commit으로 &quot;완료 보고하는 것&quot;은 별개임.</p>
<ul>
<li>자동 커밋(<code>enable.auto.commit=true</code>): 일정 주기(기본 5초)마다 알아서 commit. 편하지만, 처리 도중 죽으면 처리 안 된 메시지가 이미 읽은 걸로 처리돼서 유실 가능</li>
<li>수동 커밋(<code>enable.auto.commit=false</code>): 처리 로직 끝난 후 직접 commit. 번거롭지만 안전해서 실무에서 주로 씀</li>
</ul>
<pre><code class="language-java">ConsumerRecords&lt;String, String&gt; records = consumer.poll(Duration.ofMillis(100));
for (var r : records) {
    처리로직(r);
}
consumer.commitSync(); // 처리 다 끝난 후 직접 commit</code></pre>
<h2 id="9-메시지-전달-보장-delivery-guarantee">9. 메시지 전달 보장 (Delivery Guarantee)</h2>
<ul>
<li>At-most-once: 최대 한 번 처리. 유실 가능하지만 중복은 없음</li>
<li>At-least-once: 최소 한 번 처리. 중복 가능하지만 유실은 없음 (실무 기본값)</li>
<li>Exactly-once: 정확히 한 번 처리. 이상적이지만 구현이 복잡함</li>
</ul>
<p>결제, 재고 차감처럼 중복되면 절대 안 되는 로직은 At-least-once를 쓰되 멱등성(Idempotency) 처리로 방어함.</p>
<pre><code class="language-java">if (이미_처리된_orderId_인가(orderId)) {
    return; // 중복이니까 스킵
}
결제처리();
처리완료_기록(orderId);</code></pre>
<h2 id="10-rebalancing">10. Rebalancing</h2>
<p>Consumer Group 안에서 Consumer가 추가되거나 죽으면 Partition 담당을 다시 나누는 과정임.</p>
<p>리밸런싱 도중엔 그룹 전체가 잠깐 멈춤(Stop-the-world). 문제는 이게 불필요하게 자주 일어날 때임. Consumer가 heartbeat(살아있다는 신호)를 제때 못 보내면 카프카가 &quot;죽었다&quot;고 오해해서 리밸런싱을 발동시킴.</p>
<p>heartbeat가 늦어지는 대표적인 원인은 poll() 루프 안에서 처리 로직이 너무 오래 걸리는 경우임. 그래서 무거운 작업(DB 저장, API 호출)은 별도 스레드풀에 위임해서 poll 루프 자체는 가볍게 유지함.</p>
<pre><code class="language-java">ExecutorService executorService = Executors.newFixedThreadPool(5);

while (true) {
    var records = consumer.poll(Duration.ofMillis(100));
    for (var r : records) {
        executorService.submit(() -&gt; 처리로직(r)); // 다른 스레드에 위임
    }
    consumer.commitSync();
}</code></pre>
<p>스레드가 분리되면 보통 DB 커넥션도 스레드별로 따로 잡기 때문에, 각 메시지 처리가 서로 독립적인 트랜잭션으로 진행됨.</p>
<h2 id="11-acks--mininsyncreplicas">11. acks &amp; min.insync.replicas</h2>
<p>acks: Producer가 메시지 저장을 얼마나 철저히 확인하고 넘어갈지 정하는 옵션.</p>
<ul>
<li>acks=0: 확인 안 함. 제일 빠르지만 Broker가 못 받아도 모름 (유실 위험 큼)</li>
<li>acks=1: 리더 Broker가 저장한 것만 확인. 리더가 저장 직후 죽고 복제 전이면 유실 가능</li>
<li>acks=all(-1): ISR에 있는 모든 복제본이 저장 완료할 때까지 기다림. 제일 안전하지만 느림</li>
</ul>
<p>min.insync.replicas: <code>acks=all</code>과 짝꿍 개념. ISR 안에 최소 몇 대가 살아있어야 쓰기를 허용할지 정하는 안전장치. 예를 들어 <code>min.insync.replicas=2</code>인데 ISR에 살아있는 Broker가 1대뿐이면 Producer가 쓰기 시도해도 에러가 나서 아예 못 씀 - 위험한 상태로 쓰느니 차라리 막는 방식.</p>
<hr>
<h2 id="전체-구조-한눈에-보기">전체 구조 한눈에 보기</h2>
<pre><code>[Producer 1]         [Producer 2]
      \                   /
       v                 v
  ┌───────────────────────────────────────────┐
  │              Kafka 클러스터                  │
  │  ┌───────────┐  ┌───────────┐  ┌───────────┐│
  │  │ Broker 1  │  │ Broker 2  │  │ Broker 3  ││
  │  │ p0 (리더) │  │ p1 (리더) │  │ p2 (리더) ││
  │  │ p1 (복제) │  │ p2 (복제) │  │ p0 (복제) ││
  │  │ ISR: O    │  │ ISR: O    │  │ ISR: O    ││
  │  └───────────┘  └───────────┘  └───────────┘│
  └───────────────────────────────────────────┘
         /            |             \
        v             v              v
 ┌─────────────────────────┐   ┌─────────────────┐
 │   배송그룹 (Group A)      │   │ 통계그룹 (Group B)│
 │  C1:p0   C2:p1   C3:p2   │   │   C1: p0,p1,p2   │
 │  각자 offset 관리          │   │   혼자 다 담당      │
 └─────────────────────────┘   └─────────────────┘</code></pre><ul>
<li>Producer들이 Topic에 메시지를 씀 (key 있으면 같은 key는 같은 Partition으로)</li>
<li>Broker 3대가 Partition을 리더/복제본으로 나눠 저장 (ISR로 복제 상태 관리)</li>
<li>배송그룹은 Consumer 3개가 Partition을 나눠서 분업 처리</li>
<li>통계그룹은 배송그룹과 무관하게 독립적으로 전체 Partition을 다 읽음</li>
<li>모든 Consumer는 처리 후 offset을 <code>__consumer_offsets</code>에 commit해서 진행 상황을 기록함</li>
</ul>
<h1 id="기본-실습">기본 실습</h1>
<p>이론만 보니까 계속 뜬구름 잡는 느낌이라, EC2에 올려둔 카프카를 <strong>완전히 초기화하고 처음부터</strong> 해봤음. 터미널에서 직접 쳐보니 이론에서 흐릿했던 게 명확해짐.</p>
<p>환경은 EC2 위에 도커로 브로커 1대(<code>apache/kafka:3.9.0</code>)만 띄운 상태임.</p>
<hr>
<h2 id="1-토픽-만들기">1. 토픽 만들기</h2>
<p>빈 브로커에서 시작해서 토픽을 하나 만들었음.</p>
<pre><code class="language-bash">docker exec keeping-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create --topic test.hello --partitions 3 --replication-factor 1</code></pre>
<table>
<thead>
<tr>
<th>부분</th>
<th>뜻</th>
</tr>
</thead>
<tbody><tr>
<td><code>docker exec keeping-kafka</code></td>
<td>어느 컨테이너에서 실행할지 (Docker 층)</td>
</tr>
<tr>
<td><code>/opt/kafka/bin/kafka-topics.sh</code></td>
<td>카프카가 기본 제공하는 관리 스크립트</td>
</tr>
<tr>
<td><code>--bootstrap-server localhost:9092</code></td>
<td>어느 브로커에 요청할지 (Kafka 층)</td>
</tr>
<tr>
<td><code>--create</code></td>
<td>무엇을 할지 (동사)</td>
</tr>
<tr>
<td><code>--partitions 3</code></td>
<td>줄 3개</td>
</tr>
<tr>
<td><code>--replication-factor 1</code></td>
<td>사본 없음</td>
</tr>
</tbody></table>
<h3 id="--bootstrap-server를-오해하고-있었음"><code>--bootstrap-server</code>를 오해하고 있었음</h3>
<p>&quot;저 주소에서 명령을 실행한다&quot;인 줄 알았는데 아니었음. <strong>요청을 보낼 상대</strong>임.</p>
<pre><code>1. 스크립트는 컨테이너 안에서 실행된다        ← 여기서 돈다
2. 스크립트가 localhost:9092 에 TCP 연결      ← 여기로 전화 건다
3. &quot;test.hello 만들어줘&quot; 요청을 보낸다
4. 브로커가 실제로 만든다                     ← 진짜 일하는 건 브로커
5. 응답 받아서 &quot;Created topic&quot; 출력</code></pre><p>MySQL로 바꿔 생각하면 똑같음.</p>
<pre><code>mysql            -h 서버주소             -e &quot;CREATE TABLE ...&quot;
kafka-topics.sh  --bootstrap-server ...  --create ...
                 ^^^^^^^^^^^^^^^^^^^^^^
                 = -h 와 같은 자리</code></pre><p><code>mysql -e &quot;CREATE TABLE&quot;</code>을 친다고 CLI가 테이블을 만드는 게 아님. 서버한테 요청하는 거고 만드는 건 서버임.</p>
<p><strong>즉 <code>kafka-topics.sh</code>는 브로커가 아니라 클라이언트임.</strong> 우리 앱의 Producer/Consumer와 똑같은 방식으로 브로커에 붙음. 특권 같은 거 없음.</p>
<h3 id="실행하니-경고가-떴음">실행하니 경고가 떴음</h3>
<pre><code>WARNING: Due to limitations in metric names, topics with a period (&#39;.&#39;)
or underscore (&#39;_&#39;) could collide.
Created topic test.hello.</code></pre><p>왜 뜨냐면 — <strong>Prometheus 지표 이름에는 <code>.</code>을 못 쓰기 때문</strong>임. 자동으로 <code>_</code>로 바뀜.</p>
<pre><code>order.topic.v1   →   order_topic_v1
order_topic_v1   →   order_topic_v1
     ^^^ 원래 다른 토픽인데     ^^^ 똑같아짐</code></pre><p>두 토픽의 숫자가 하나로 합쳐져서 구분이 안 됨. <strong><code>.</code>만 쓰거나 <code>_</code>만 쓰거나 하나로 통일</strong>해야 함.</p>
<hr>
<h2 id="2---describe로-토픽-들여다보기">2. <code>--describe</code>로 토픽 들여다보기</h2>
<pre><code class="language-bash">docker exec keeping-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 --describe --topic test.hello</code></pre>
<pre><code>Topic: test.hello  TopicId: dzJdmv9JSzGoDx_vnr0nSA  PartitionCount: 3  ReplicationFactor: 1
Configs: min.insync.replicas=1,segment.bytes=268435456
    Topic: test.hello  Partition: 0  Leader: 1  Replicas: 1  Isr: 1
    Topic: test.hello  Partition: 1  Leader: 1  Replicas: 1  Isr: 1
    Topic: test.hello  Partition: 2  Leader: 1  Replicas: 1  Isr: 1</code></pre><p><strong>첫 줄은 토픽 전체 요약임.</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>뜻</th>
</tr>
</thead>
<tbody><tr>
<td><code>TopicId</code></td>
<td>카프카가 붙인 고유 ID. 같은 이름으로 지웠다 다시 만들면 ID가 바뀜 (다른 토픽 취급)</td>
</tr>
<tr>
<td><code>PartitionCount: 3</code></td>
<td>우리가 준 값</td>
</tr>
<tr>
<td><code>min.insync.replicas=1</code></td>
<td>브로커 설정이 여기 박힌 것</td>
</tr>
</tbody></table>
<p><strong>나머지 3줄은 파티션 하나씩임.</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>뜻</th>
</tr>
</thead>
<tbody><tr>
<td><code>Leader: 1</code></td>
<td>1번 브로커가 이 파티션의 대표. 쓰기·읽기가 여기로 감</td>
</tr>
<tr>
<td><code>Replicas: 1</code></td>
<td>사본을 <strong>놓기로 한</strong> 브로커 목록 (계획)</td>
</tr>
<tr>
<td><code>Isr: 1</code></td>
<td><strong>실제로 최신 상태인</strong> 사본 목록 (현실)</td>
</tr>
</tbody></table>
<h3 id="replicas와-isr의-차이">Replicas와 Isr의 차이</h3>
<p>지금은 둘 다 <code>1</code>이라 차이가 안 보이는데, 브로커 3대라면 이렇게 됨.</p>
<pre><code>정상일 때
Partition: 0    Leader: 1    Replicas: 1,2,3    Isr: 1,2,3

브로커 3이 죽으면
Partition: 0    Leader: 1    Replicas: 1,2,3    Isr: 1,2
                             ^^^^^^^ 계획은 그대로     ^^^ 살아있는 건 둘</code></pre><p><code>min.insync.replicas</code>는 <strong>Isr 개수</strong>를 봄. 그래서 이론에서 본 &quot;위험한 상태로 쓰느니 차라리 막는다&quot;가 여기서 판정됨.</p>
<hr>
<h2 id="3-rf를-3으로-주면-어떻게-되나">3. RF를 3으로 주면 어떻게 되나</h2>
<p>브로커가 1대인데 일부러 RF=3으로 만들어봤음.</p>
<pre><code class="language-bash">docker exec keeping-kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create --topic test.fail --partitions 3 --replication-factor 3</code></pre>
<pre><code>Error while executing topic command : Unable to replicate the partition 3 time(s):
The target replication factor of 3 cannot be reached because only 1 broker(s) are registered.</code></pre><p>처음엔 RF를 &quot;파티션당 브로커 3개를 부여&quot;로 이해했는데 아니었음. <strong>파티션 하나의 데이터를 3벌 복사해서 서로 다른 브로커에 흩어 놓는 것</strong>임.</p>
<p><strong>브로커 1대일 때 RF=3을 시도하면</strong></p>
<pre><code>          브로커1
파티션0    사본, 사본, 사본  ← 같은 디스크에 3벌</code></pre><p>이러면 의미가 없음. <strong>복제의 목적은 &quot;브로커가 죽어도 살아남기&quot;</strong> 인데, 같은 브로커에 놓으면 목적이 사라짐. 그래서 카프카가 아예 거부함.</p>
<pre><code>규칙: RF ≤ 브로커 수</code></pre><h3 id="파티션은-토픽-소속-배치는-브로커">파티션은 토픽 소속, 배치는 브로커</h3>
<p>여기서 헷갈렸던 게 정리됐음. <strong>파티션 0,1,2는 브로커 수와 무관하게 항상 똑같음.</strong> 달라지는 건 &quot;어디에 놓이느냐&quot;뿐임.</p>
<table>
<thead>
<tr>
<th></th>
<th>파티션 구성</th>
<th>배치</th>
</tr>
</thead>
<tbody><tr>
<td>브로커 1대</td>
<td>0, 1, 2</td>
<td>전부 브로커1에</td>
</tr>
<tr>
<td>브로커 3대 (RF=1)</td>
<td>0, 1, 2</td>
<td>브로커1→0, 브로커2→1, 브로커3→2</td>
</tr>
<tr>
<td>브로커 3대 (RF=3)</td>
<td>0, 1, 2</td>
<td>셋 다 각 브로커에 사본으로</td>
</tr>
</tbody></table>
<p>표로 보면 명확함.</p>
<pre><code>              브로커1        브로커2        브로커3
파티션0        사본 ★        사본          사본        ← 가로 = 같은 파티션의 사본들
파티션1        사본          사본 ★        사본
파티션2        사본          사본          사본 ★
              ↑
              세로 = 이 브로커가 보관 중인 것들</code></pre><p>가로로 읽으면 &quot;파티션 0은 3대에 흩어져 있다&quot; (토픽 관점)
세로로 읽으면 &quot;브로커1은 파티션 0,1,2를 하나씩 갖고 있다&quot; (브로커 관점)</p>
<p><strong>둘 다 맞음. 같은 표를 다른 방향으로 읽는 것뿐임.</strong></p>
<pre><code>파티션의 소속(이름)  →  토픽    (&quot;test.hello의 0번&quot;)    ← 절대 안 바뀜
파티션의 위치(저장)  →  브로커  (&quot;브로커2에 있음&quot;)       ← 옮겨질 수 있음</code></pre><hr>
<h2 id="4-메시지-보내고-받기">4. 메시지 보내고 받기</h2>
<p>터미널 2개를 띄웠음.</p>
<p><strong>창 A — 컨슈머</strong></p>
<pre><code class="language-bash">docker exec -it keeping-kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic test.hello --from-beginning</code></pre>
<p>실행하면 <strong>아무것도 안 나오고 멈춤.</strong> 처음엔 고장난 줄 알았는데 정상임. 메시지를 기다리는 중임. 이론에서 본 <code>while(true) { poll() }</code> 루프가 이렇게 생겼음.</p>
<p><strong>창 B — 프로듀서</strong></p>
<pre><code class="language-bash">docker exec -it keeping-kafka /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 --topic test.hello</code></pre>
<p><code>&gt;</code> 프롬프트가 뜨고, 아무거나 치고 Enter를 누르면 <strong>창 A에 바로 나타남.</strong> Enter 한 번 = 메시지 하나임.</p>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/1bb62498-541b-4480-867c-ef181c10bab7/image.png" alt=""></p>
<pre><code>창B 프로듀서  →  브로커가 파티션 하나 골라 저장  →  창A 컨슈머가 읽어감</code></pre><h3 id="어느-파티션에-갔는지-보기">어느 파티션에 갔는지 보기</h3>
<p>컨슈머에 옵션을 붙이면 파티션 번호까지 보여줌.</p>
<pre><code class="language-bash">docker exec -it keeping-kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic test.hello --from-beginning \
  --property print.partition=true \
  --property print.key=true \
  --property print.offset=true \
  --property key.separator=&quot; | &quot;</code></pre>
<pre><code>Partition:1 | Offset:0 | null | ㅎㅇ
Partition:1 | Offset:1 | null | 안녕
Partition:1 | Offset:2 | null | 두번째
Partition:1 | Offset:3 | null | 세번째</code></pre><h3 id="키가-없는데-왜-계속-파티션-1이지">키가 없는데 왜 계속 파티션 1이지?</h3>
<p>이론에서는 &quot;키가 없으면 라운드로빈&quot;이라고 배웠는데 전부 1번으로만 갔음. 찾아보니 <strong>요즘 카프카(3.3+)는 sticky(끈적이) 방식</strong>을 씀.</p>
<pre><code>파티션 하나를 골라서 → 그 파티션에 batch.size(기본 16KB)가 쌓일 때까지 계속 거기로
                    → 그 다음 다른 파티션으로</code></pre><p>메시지가 &quot;ㅎㅇ&quot; 같은 몇 바이트라 16KB를 채우려면 한참 걸림. 그래서 계속 1번에만 감. 옛날 카프카는 진짜 라운드로빈이었는데 <strong>네트워크 효율 때문에</strong> 뭉쳐 보내도록 바뀐 것임.</p>
<hr>
<h2 id="5-키를-주면-파티션이-정해진다">5. 키를 주면 파티션이 정해진다</h2>
<p>프로듀서를 키 모드로 다시 띄웠음.</p>
<pre><code class="language-bash">docker exec -it keeping-kafka /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 --topic test.hello \
  --property parse.key=true --property key.separator=:</code></pre>
<p>이제 <code>키:값</code> 형식으로 침.</p>
<pre><code>&gt;42:첫번째결제
&gt;77:다른고객
&gt;12:또다른고객
&gt;10:근데 왜 파티션이 같지?</code></pre><p><img src="https://velog.velcdn.com/images/k_joon_/post/42649b09-c52e-4485-9297-445c94415afe/image.png" alt=""></p>
<pre><code>Partition:1 | Offset:5 | 42 | 첫번째결제
Partition:1 | Offset:6 | 77 | 다른고객
Partition:1 | Offset:8 | 12 | 또다른고객
Partition:1 | Offset:9 | 10 | 근데 왜 파티션이 같지?</code></pre><p><strong>키를 다르게 줬는데 또 전부 파티션 1임.</strong> 이상해서 카프카가 쓰는 해시 함수(murmur2)를 직접 구현해서 계산해봤음.</p>
<pre><code>키 42  →  파티션 1
키 77  →  파티션 1
키 12  →  파티션 1
키 10  →  파티션 1</code></pre><p><strong>우연이었음.</strong> 하필 고른 4개가 전부 1로 떨어졌음. 확률 1/81짜리를 뽑은 거였음.</p>
<h3 id="갈라지는-키를-계산해서-다시-해봤음">갈라지는 키를 계산해서 다시 해봤음</h3>
<table>
<thead>
<tr>
<th>파티션</th>
<th>키</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>1, 5, 7, 8, 11</td>
</tr>
<tr>
<td>1</td>
<td>4, 6, 10, 12, 13</td>
</tr>
<tr>
<td>2</td>
<td>2, 3, 9, 16, 29</td>
</tr>
</tbody></table>
<pre><code>&gt;1:파티션0으로가야함
&gt;2:파티션2로가야함
&gt;3:파티션2로가야함
&gt;4:파티션1로가야함</code></pre><pre><code>Partition:0 | Offset:0  | 1 | ...
Partition:2 | Offset:0  | 2 | ...
Partition:2 | Offset:1  | 3 | ...
Partition:1 | Offset:10 | 4 | ...</code></pre><p><strong>계산과 정확히 일치했음.</strong></p>
<h3 id="key--3이-아니라-hashkey--3임"><code>key % 3</code>이 아니라 <code>hash(key) % 3</code>임</h3>
<p>이것도 오해하고 있었음. 실제 수식은 이럼.</p>
<pre><code>파티션 = ( murmur2(키의 바이트) &amp; 0x7fffffff ) % 파티션수</code></pre><p>계산 과정을 찍어보면 이럼.</p>
<pre><code>키 &quot;1&quot;   바이트 [49]      murmur2 = 2301521807  →  양수화 154038159   %3 = 0
키 &quot;2&quot;   바이트 [50]      murmur2 =  648968168  →  양수화 648968168   %3 = 2
키 &quot;3&quot;   바이트 [51]      murmur2 = 3146801791  →  양수화 999318143   %3 = 2
키 &quot;4&quot;   바이트 [52]      murmur2 = 1514888353  →  양수화 1514888353  %3 = 1
키 &quot;42&quot;  바이트 [52,50]   murmur2 =  417700972  →  양수화 417700972   %3 = 1</code></pre><p><strong>왜 그냥 나누지 않냐면 두 가지 이유가 있음.</strong></p>
<p>첫째, <strong>키가 숫자가 아닐 수 있음.</strong> <code>&quot;user-abc-123&quot;</code>, UUID, <code>&quot;seoul-store-01&quot;</code> 같은 걸 어떻게 <code>% 3</code> 하나. 키는 숫자가 아니라 그냥 바이트 덩어리임. <code>&quot;42&quot;</code>도 숫자 42가 아니라 <strong>문자 &#39;4&#39;,&#39;2&#39; = 바이트 [52, 50]</strong> 임.</p>
<p>둘째, <strong>그냥 나누면 쏠림.</strong> userId가 전부 짝수라거나 ID를 100 단위로 발급한다면 규칙적으로 편향됨. 해시는 입력이 규칙적이어도 결과를 골고루 흩뜨림. 그게 존재 이유임.</p>
<h3 id="같은-키를-반복해서-보내보면">같은 키를 반복해서 보내보면</h3>
<pre><code>&gt;1:첫번째
&gt;1:두번째
&gt;1:세번째</code></pre><pre><code>Partition:0 | Offset:2 | 1 | 첫번째
Partition:0 | Offset:3 | 1 | 두번째
Partition:0 | Offset:4 | 1 | 세번째</code></pre><p><strong>전부 같은 파티션, offset만 올라감.</strong> 해시는 같은 입력에 항상 같은 값을 내니까 당연한 결과임. 이론에서 본 &quot;같은 key는 같은 Partition&quot;이 이렇게 확인됨.</p>
<hr>
<h2 id="6-offset은-파티션마다-독립적임">6. offset은 파티션마다 독립적임</h2>
<p>여기서 하나 정리됨. <strong>offset은 키와 무관함.</strong></p>
<pre><code>키       →  어느 파티션에 갈지만 정한다
파티션   →  들어온 순서대로 offset을 붙인다   ← 키 안 봄</code></pre><p>실제 출력이 증거임.</p>
<pre><code>Partition:1 | Offset:0  | null      ┐
Partition:1 | Offset:1  | null      │ 키가 전부 다른데
Partition:1 | Offset:4  | null      │ (null, null, 42, 77, 4)
Partition:1 | Offset:5  | 42        │
Partition:1 | Offset:6  | 77        │ 같은 파티션이라
Partition:1 | Offset:10 | 4         ┘ offset은 그냥 순서대로 올라감</code></pre><p>파티션은 &quot;줄&quot;이고, offset은 &quot;그 줄에서 몇 번째&quot;일 뿐임. 누가 왔는지는 안 따짐.</p>
<pre><code>파티션 0:  [offset 0] [offset 1] [offset 2] ...
파티션 1:  [offset 0] [offset 1] ...
파티션 2:  [offset 0] [offset 1] [offset 2] ...
           ↑ 각자 0부터 독립적으로 센다</code></pre><p>정확히 말하면 이렇게 됨.</p>
<pre><code>같은 키     →  같은 파티션에 간다       (키의 역할)
같은 파티션 →  offset 순서대로 쌓인다   (파티션의 역할)
─────────────────────────────────────
결과: 같은 키끼리는 순서가 보장된다</code></pre><p>둘이 합쳐져서 순서 보장이 나오는 거지, 키가 offset을 정하는 게 아님.</p>
<hr>
<h2 id="7-컨슈머-그룹과-lag">7. 컨슈머 그룹과 LAG</h2>
<p>지금까지 쓴 컨슈머는 <strong>이름 없는 임시 그룹</strong>이라 오프셋이 안 남음. 이름을 줬음.</p>
<pre><code class="language-bash">docker exec -it keeping-kafka /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 --topic test.hello \
  --group demo-group \
  --property print.partition=true --property print.offset=true --property print.key=true</code></pre>
<p><code>--group demo-group</code> 하나만 추가됐음.</p>
<h3 id="--from-beginning을-빼니-기존-메시지가-안-나왔음"><code>--from-beginning</code>을 빼니 기존 메시지가 안 나왔음</h3>
<p>새 그룹이라 오프셋 기록이 없는데, 콘솔 컨슈머의 기본값이 <strong><code>latest</code>(지금부터)</strong> 였음.</p>
<pre><code>earliest  →  맨 처음부터 읽어라
latest    →  지금 이후 새 것만 읽어라   ← 콘솔 컨슈머 기본값</code></pre><hr>
<h2 id="8-하이라이트--컨슈머를-죽여놓고-메시지를-보내봤음">8. 하이라이트 — 컨슈머를 죽여놓고 메시지를 보내봤음</h2>
<p>이게 오늘의 핵심이었음.</p>
<p><strong>1) 컨슈머를 껐음</strong> (Ctrl+C)</p>
<p><strong>2) 프로듀서로 4개를 보냈음</strong></p>
<pre><code>&gt;1:죽은동안1
&gt;1:죽은동안2
&gt;1:죽은동안3
&gt;1:죽은동안4</code></pre><p>에러 없이 <code>&gt;</code>만 계속 떴음. <strong>브로커는 잘 받았음.</strong> 컨슈머가 없는 건 브로커가 신경 안 씀.</p>
<p><strong>3) LAG을 확인했음</strong></p>
<pre><code class="language-bash">docker exec keeping-kafka /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 --describe --group demo-group</code></pre>
<pre><code>Consumer group &#39;demo-group&#39; has no active members.
GROUP        TOPIC        PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
demo-group   test.hello   2          3               3               0
demo-group   test.hello   0          6               10              4    ← 4개 밀림
demo-group   test.hello   1          11              11              0</code></pre><p><img src="https://velog.velcdn.com/images/k_joon_/post/982f9a09-364a-4e7f-b0d7-35cc5d466d2e/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>칸</th>
<th>뜻</th>
</tr>
</thead>
<tbody><tr>
<td><code>CURRENT-OFFSET</code></td>
<td>컨슈머가 어디까지 읽었나 (책갈피 위치)</td>
</tr>
<tr>
<td><code>LOG-END-OFFSET</code></td>
<td>브로커에 어디까지 쌓였나</td>
</tr>
<tr>
<td><strong><code>LAG</code></strong></td>
<td><strong>둘의 차이 = 아직 안 읽은 개수</strong></td>
</tr>
</tbody></table>
<p>키를 <code>1</code>로만 보냈으니 전부 파티션 0으로 갔고, 거기만 LAG 4임.</p>
<p><strong><code>has no active members</code></strong> — 컨슈머는 죽었는데 <strong>메시지는 멀쩡히 브로커에 있음.</strong></p>
<p><strong>4) 컨슈머를 다시 켰음</strong></p>
<pre><code>Partition:0 | Offset:6 | 1 | 죽은동안1
Partition:0 | Offset:7 | 1 | 죽은동안2
Partition:0 | Offset:8 | 1 | 죽은동안3
Partition:0 | Offset:9 | 1 | 죽은동안4</code></pre><p><a href="https://velog.velcdn.com/images/k_joon_/post/8b0d5122-3ea6-4f50-93ae-61b6f7470a8a/image.png"></a></p>
<p><strong>하나도 안 사라졌음.</strong> 책갈피(offset 6)부터 이어서 읽었음.</p>
<p><strong>5) LAG이 0으로 돌아왔음</strong></p>
<pre><code>demo-group   test.hello   0          10              10              0
                                     ^^ OffSet이 따라잡음!
</code></pre><h3 id="이게-왜-중요하냐면">이게 왜 중요하냐면</h3>
<p>같은 상황을 HTTP로 했다면 이렇게 됐을 것임.</p>
<pre><code>A서버 → B서버 HTTP POST → B서버 죽어있음 → 타임아웃 → 서킷 열림
                                          → log.warn → 끝
                                          4건 전부 소멸</code></pre><p><strong>Kafka는 속도 차이를 &quot;지연&quot;으로 바꾸고, HTTP는 속도 차이를 &quot;유실&quot;로 바꿈.</strong> 이 한 문장이 오늘 실습의 결론임.</p>
<p>컨슈머가 없어도 오프셋 기록이 브로커에 남아 있다는 게 핵심임.</p>
<pre><code>컨슈머   = 실행 중인 프로세스     ← 껐다 켰다 하는 것
오프셋   = 브로커 디스크의 기록   ← 계속 남아 있는 것</code></pre><p>도서관으로 생각하면 이해가 쉬움. 책을 읽다가 집에 갔음. <strong>사람은 없지만 책갈피는 책에 꽂혀 있음.</strong> 내일 다시 와서 책갈피 자리부터 읽으면 됨.</p>
<pre><code>컨슈머 = 사람 / 오프셋 = 책갈피 / 브로커 = 도서관</code></pre><h3 id="lag으로-상태를-읽는-법">LAG으로 상태를 읽는 법</h3>
<table>
<thead>
<tr>
<th>LAG</th>
<th>뜻</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>실시간으로 잘 따라가는 중</td>
</tr>
<tr>
<td>일정하게 유지</td>
<td>생산 속도 = 소비 속도. 밀린 채로 균형</td>
</tr>
<tr>
<td><strong>계속 오름</strong></td>
<td><strong>컨슈머가 못 따라간다</strong></td>
</tr>
<tr>
<td>갑자기 큼</td>
<td>컨슈머가 죽어 있었다</td>
</tr>
</tbody></table>
<hr>
<h2 id="9-오늘-정리된-것">9. 오늘 정리된 것</h2>
<p>이론으로 볼 땐 흐릿했는데 직접 해보니 명확해진 것들임.</p>
<table>
<thead>
<tr>
<th></th>
<th>이론으로 볼 때</th>
<th>직접 해보니</th>
</tr>
</thead>
<tbody><tr>
<td><strong><code>--bootstrap-server</code></strong></td>
<td>실행할 곳인 줄</td>
<td><strong>요청 보낼 상대.</strong> MySQL의 <code>-h</code>와 같음</td>
</tr>
<tr>
<td><strong><code>kafka-topics.sh</code></strong></td>
<td>카프카의 일부</td>
<td><strong>클라이언트임.</strong> 브로커와 별개 프로그램</td>
</tr>
<tr>
<td><strong>RF</strong></td>
<td>복제 개수</td>
<td><strong>서로 다른 브로커에 놓아야 의미 있음.</strong> RF ≤ 브로커 수</td>
</tr>
<tr>
<td><strong>파티션</strong></td>
<td>토픽을 쪼갠 것</td>
<td><strong>소속은 토픽, 배치는 브로커.</strong> 두 축임</td>
</tr>
<tr>
<td><strong>키 → 파티션</strong></td>
<td><code>key % 파티션수</code></td>
<td><strong><code>murmur2(키바이트) % 파티션수</code></strong></td>
</tr>
<tr>
<td><strong>키 없을 때</strong></td>
<td>라운드로빈</td>
<td><strong>sticky.</strong> 16KB 찰 때까지 한 파티션에 몰림</td>
</tr>
<tr>
<td><strong>offset</strong></td>
<td>메시지 번호</td>
<td><strong>파티션마다 독립.</strong> 키와 무관</td>
</tr>
<tr>
<td><strong>컨슈머 그룹</strong></td>
<td>컨슈머 묶음</td>
<td><strong>오프셋이 저장되는 단위.</strong> 컨슈머가 죽어도 기록은 남음</td>
</tr>
<tr>
<td><strong>LAG</strong></td>
<td>밀린 정도</td>
<td><code>LOG-END-OFFSET − CURRENT-OFFSET</code></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[전략패턴 / 팩토리패턴]]></title>
            <link>https://velog.io/@k_joon_/%EC%A0%84%EB%9E%B5%ED%8C%A8%ED%84%B4-%ED%8C%A9%ED%86%A0%EB%A6%AC%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@k_joon_/%EC%A0%84%EB%9E%B5%ED%8C%A8%ED%84%B4-%ED%8C%A9%ED%86%A0%EB%A6%AC%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Sat, 29 Aug 2026 08:27:02 GMT</pubDate>
            <description><![CDATA[<h1 id="전략-패턴과-팩토리-패턴-정리">전략 패턴과 팩토리 패턴 정리</h1>
<h2 id="1-왜-공부하게-됐냐">1. 왜 공부하게 됐냐</h2>
<p>검색 기능 구현하다가 <code>PostSearchStrategy</code>라는 인터페이스를 이미 쓰고 있었는데, 정작 &quot;이게 왜 전략패턴인지&quot;, &quot;if-else로 짜면 왜 안 되는지&quot;는 제대로 설명 못 하는 상태였음. 그래서 개념부터 다시 정리해봄.</p>
<h2 id="2-전략-패턴이-뭐냐">2. 전략 패턴이 뭐냐</h2>
<p>행위(알고리즘)를 인터페이스로 통일해서, 갈아끼울 수 있게 만드는 패턴임.</p>
<p>구성 요소는 3개임.</p>
<ul>
<li><strong>Strategy</strong> (인터페이스) — 규격</li>
<li><strong>ConcreteStrategy</strong> (구현체 여러 개) — 실제 알고리즘들</li>
<li><strong>Context</strong> (전략을 사용하는 쪽) — 전략을 직접 만들지 않고, 주입받아서 호출만 함</li>
</ul>
<p>비유하면 리모컨 규격 같은 거임. 삼성 TV든 LG TV든 &quot;전원/채널/볼륨&quot; 버튼이 똑같은 규격(인터페이스)을 따르면, 어떤 회사 TV를 쓰든 리모컨 다루는 법을 새로 안 배워도 됨. Spring AI에서 <code>ChatModel</code> 인터페이스 하나로 Claude/Gemini/OpenAI를 똑같이 다루는 것도 같은 원리임.</p>
<h2 id="3-if-else로-하면-안-되나-ocp">3. if-else로 하면 안 되나? (OCP)</h2>
<p>물론 if-else로도 똑같은 결과를 낼 수 있음. 차이는 <strong>새로운 케이스가 추가될 때</strong> 드러남.</p>
<pre><code class="language-java">// if-else 버전
SearchCursorResponse search(String type, ...) {
    if (type.equals(&quot;gin&quot;)) {
        // GIN 검색 로직
    } else if (type.equals(&quot;pgvector&quot;)) {
        // pgvector 검색 로직
    }
    // 새 검색 방식(예: ES) 추가되면 여기다 또 분기 추가해야 함
}</code></pre>
<p>새 검색 방식(예: Elasticsearch)이 추가될 때마다 이 메서드 자체를 열어서 수정해야 함. 이게 바로 <strong>OCP(Open-Closed Principle, 개방-폐쇄 원칙)</strong> 를 어기는 거임 — &quot;확장에는 열려있고, 변경에는 닫혀있어야 한다&quot;는 원칙인데, if-else는 확장할 때마다 기존 코드를 계속 변경하게 됨.</p>
<p>전략패턴으로 짜면 새 전략 클래스만 추가하면 되고, 기존 코드는 안 건드림. 그래서 OCP를 지킴.</p>
<h2 id="4-실제-프로젝트-코드">4. 실제 프로젝트 코드</h2>
<p>내 프로젝트에서 게시글 검색 기능을 GIN trigram 방식이랑 pgvector(시맨틱 검색) 방식 두 가지로 지원하는데, 이걸 전략패턴으로 구현해놓음.</p>
<h3 id="strategy-인터페이스">Strategy 인터페이스</h3>
<pre><code class="language-java">public interface PostSearchStrategy {

    /**
     * @param canViewPrivate THERAPIST/ADMIN이면 true. PRIVATE 검색 결과를 일반 형태로 응답에 노출.
     *                       USER이면 false. PRIVATE도 결과에 포함되지만 SearchResultAssembler가
     *                       contentPreview를 마스킹하고 accessLocked=true 표시.
     */
    SearchCursorResponse search(
            PostSearchCondition condition,
            BigDecimal lastScore,
            Long lastId,
            int size,
            boolean canViewPrivate
    );
}</code></pre>
<h3 id="concretestrategy-1--gin-trigram-기반">ConcreteStrategy 1 — GIN trigram 기반</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = &quot;app.search.strategy&quot;, havingValue = &quot;gin&quot;, matchIfMissing = true)
public class GinTrigramSearchStrategy implements PostSearchStrategy {

    private final TherapyPostRepository therapyPostRepository;
    private final SearchResultAssembler assembler;

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public SearchCursorResponse search(
            PostSearchCondition condition,
            BigDecimal lastScore,
            Long lastId,
            int size,
            boolean canViewPrivate
    ) {
        entityManager.createNativeQuery(&quot;SET LOCAL pg_trgm.word_similarity_threshold = 0.1&quot;)
                .executeUpdate();

        String rawKeyword = condition.getKeyword().trim().toLowerCase();
        String escapedKeyword = condition.getEscapedKeyword().trim().toLowerCase();
        String area = condition.getTherapyArea() != null ? condition.getTherapyArea().name() : null;
        String type = condition.getPostType() != null ? condition.getPostType().name() : null;

        int limit = size + 1;
        boolean firstPage = (lastScore == null &amp;&amp; lastId == null);

        List&lt;Object[]&gt; rows = firstPage
                ? therapyPostRepository.searchIdsByRelevanceFirstPage(
                        rawKeyword, escapedKeyword, area, type, limit)
                : therapyPostRepository.searchIdsByRelevanceNextPage(
                        rawKeyword, escapedKeyword, area, type, lastScore, lastId, limit);

        return assembler.assemble(rows, size, canViewPrivate);
    }
}</code></pre>
<h3 id="concretestrategy-2--pgvector-기반-시맨틱-검색">ConcreteStrategy 2 — pgvector 기반 (시맨틱 검색)</h3>
<pre><code class="language-java">@Component
@ConditionalOnProperty(name = &quot;app.search.strategy&quot;, havingValue = &quot;pgvector&quot;)
public class PgVectorSearchStrategy implements PostSearchStrategy {

    private static final BigDecimal FALLBACK_MIN_SCORE = new BigDecimal(&quot;0.2&quot;);
    private static final int FALLBACK_THRESHOLD = 3;

    private final TherapyPostRepository therapyPostRepository;
    private final EmbeddingService embeddingService;
    private final SearchResultAssembler assembler;

    @Value(&quot;${app.search.vector.min-score}&quot;)
    private BigDecimal minScore;

    @Override
    public SearchCursorResponse search(
            PostSearchCondition condition,
            BigDecimal lastScore,
            Long lastId,
            int size,
            boolean canViewPrivate
    ) {
        float[] queryEmbedding = embeddingService.embed(condition.getKeyword().trim());
        String embeddingStr = EmbeddingService.toVectorString(queryEmbedding);

        // ... 검색 로직, 결과 부족하면 min-score 완화해서 폴백 재검색

        return assembler.assemble(rows, size, canViewPrivate);
    }
}</code></pre>
<p>여기서 포인트는 <code>@ConditionalOnProperty</code>임. <code>app.search.strategy</code> 설정값에 따라 Spring이 앱 시작 시점에 둘 중 <strong>하나만</strong> 빈으로 등록함. 그래서 <code>PostSearchStrategy</code> 타입 빈이 항상 하나만 존재하고, 이걸 사용하는 쪽(<code>PostService</code>)은 어떤 구현체가 들어올지 몰라도 그냥 인터페이스만 보고 호출하면 됨.</p>
<pre><code class="language-java">class PostService {
    private final PostSearchStrategy strategy; // 어떤 구현체인지 모름, DI로 주입만 받음

    SearchCursorResponse search(...) {
        return strategy.search(...);
    }
}</code></pre>
<h2 id="5-전략패턴의-장점-정리">5. 전략패턴의 장점 정리</h2>
<ol>
<li><strong>OCP 준수</strong> — 새 전략 추가해도 기존 코드 안 건드림</li>
<li><strong>조건문 제거</strong> — if-else/switch 덩어리가 클래스별로 분산됨</li>
<li><strong>런타임 교체 가능</strong> — (단, <code>@ConditionalOnProperty</code>는 앱 시작 시점 고정이라 재시작해야 바뀜. 진짜 런타임 교체를 하려면 팩토리패턴이 필요함 → 6번에서 이어짐)</li>
<li><strong>테스트 용이</strong> — 각 전략을 독립적으로 테스트 가능 (<code>GinTrigramSearchStrategyTest</code>, <code>PgVectorSearchStrategyTest</code>처럼 따로 테스트 가능)</li>
</ol>
<h2 id="6-팩토리-패턴이-뭐냐">6. 팩토리 패턴이 뭐냐</h2>
<p>객체를 생성하는 로직을 한 클래스로 모아두는 패턴임. &quot;공장&quot;처럼, 여러 곳에서 각자 조립하지 말고 한 군데서 찍어내라는 거임.</p>
<h3 id="왜-필요하냐--new가-여기저기-흩어지는-문제">왜 필요하냐 — new가 여기저기 흩어지는 문제</h3>
<pre><code class="language-java">// OrderService.java
PaymentMethod pm = new CardPayment(apiKey, timeout, retryCount);

// RefundService.java
PaymentMethod pm = new CardPayment(apiKey, timeout, retryCount);

// SubscriptionService.java
PaymentMethod pm = new CardPayment(apiKey, timeout, retryCount);</code></pre>
<p><code>CardPayment</code> 생성자에 파라미터(<code>region</code>)가 하나 추가되면, 저 <code>new CardPayment(...)</code>가 있는 곳을 전부 찾아서 고쳐야 함.</p>
<pre><code class="language-java">class PaymentFactory {
    PaymentMethod create() {
        return new CardPayment(apiKey, timeout, retryCount, region);
    }
}

// A, B, C 전부
PaymentMethod pm = paymentFactory.create();</code></pre>
<p>이제 생성자가 바뀌어도 <code>PaymentFactory</code> 한 곳만 고치면 끝임.</p>
<h3 id="타입별로-다른-객체를-만들어야-할-때">타입별로 다른 객체를 만들어야 할 때</h3>
<p>팩토리가 진짜 힘을 발휘하는 건 &quot;여러 구현체 중 뭘 만들지 결정&quot;해야 할 때임.</p>
<pre><code class="language-java">class PaymentFactory {
    PaymentMethod create(String type) {
        return switch (type) {
            case &quot;card&quot; -&gt; new CardPayment();
            case &quot;cash&quot; -&gt; new CashPayment();
            default -&gt; throw new IllegalArgumentException();
        };
    }
}</code></pre>
<p>여기 안에 분기(switch)가 있는데, 이건 괜찮음. 팩토리의 역할 자체가 &quot;타입에 따라 다른 객체를 만드는 것&quot;이라서, 이 분기는 팩토리가 짊어져야 할 책임임. 문제였던 건 이 분기가 비즈니스 로직(Context) 안에 섞여있는 거였지, 분기 자체가 나쁜 게 아니었음.</p>
<h2 id="7-전략패턴-vs-팩토리패턴--관계-정리">7. 전략패턴 vs 팩토리패턴 — 관계 정리</h2>
<p>둘이 답하는 질문 자체가 다름.</p>
<ul>
<li>전략패턴: <strong>여러 구현을 어떻게 똑같이 다룰까</strong> (규격 통일)</li>
<li>팩토리패턴: <strong>그중 뭘 쓸지 누가 결정할까</strong> (생성 + 선택)</li>
</ul>
<p>관리자가 검색 방식을 런타임에 전환하는 기능을 넣는다고 가정하면 둘이 같이 필요해짐.</p>
<pre><code class="language-java">// ① 전략패턴 영역: 인터페이스 + 구현체들
interface PostSearchStrategy {
    SearchCursorResponse search(...);
}
class GinTrigramSearchStrategy implements PostSearchStrategy { ... }
class PgVectorSearchStrategy implements PostSearchStrategy { ... }

// ② 팩토리패턴 영역: &quot;뭘 만들지 결정&quot;하는 로직
class SearchStrategyFactory {
    private final GinTrigramSearchStrategy gin;
    private final PgVectorSearchStrategy pgvector;

    PostSearchStrategy create(String type) {
        return switch (type) {
            case &quot;gin&quot; -&gt; gin;
            case &quot;pgvector&quot; -&gt; pgvector;
        };
    }
}

// ③ Context: 사용하는 쪽
class PostService {
    private final SearchStrategyFactory factory;
    private final AdminConfigRepository configRepo;

    SearchCursorResponse search(...) {
        String type = configRepo.getCurrentSearchType(); // 관리자가 저장해둔 값
        PostSearchStrategy strategy = factory.create(type); // 팩토리가 결정+생성
        return strategy.search(...); // Context는 전략을 실행만 함
    }
}</code></pre>
<h3 id="4가지-조합으로-비교">4가지 조합으로 비교</h3>
<p><strong>둘 다 없음 — 분기가 Context 안에 다 있음</strong></p>
<pre><code class="language-java">class PostService {
    SearchCursorResponse search(...) {
        String type = configRepo.getCurrentSearchType();
        if (type.equals(&quot;gin&quot;)) {
            // GIN 검색 로직 통째로
        } else {
            // pgvector 검색 로직 통째로
        }
    }
}</code></pre>
<p><strong>전략패턴만 있음 — 인터페이스는 있지만, 고르는 로직은 Context가 직접 함</strong></p>
<pre><code class="language-java">class PostService {
    private final GinTrigramSearchStrategy gin;
    private final PgVectorSearchStrategy pgvector;

    SearchCursorResponse search(...) {
        String type = configRepo.getCurrentSearchType();
        PostSearchStrategy strategy = type.equals(&quot;gin&quot;) ? gin : pgvector; // 분기 여전히 Context 안
        return strategy.search(...);
    }
}</code></pre>
<p><strong>팩토리패턴만 있음 — 인터페이스가 없어서 억지스러워짐</strong></p>
<pre><code class="language-java">class GinTrigramSearchStrategy { ... } // interface 구현 안 함
class PgVectorSearchStrategy { ... }   // 서로 무관한 별개 클래스

class SearchFactory {
    Object create(String type) { // 공통 타입이 없어서 반환 타입이 애매해짐
        return type.equals(&quot;gin&quot;) ? new GinTrigramSearchStrategy(...) : new PgVectorSearchStrategy(...);
    }
}
// PostService는 반환받은 걸 형변환해서 각각 다르게 호출해야 함 → 사실상 못 씀</code></pre>
<p><strong>둘 다 있음 — 위에서 본 형태, 가장 깔끔함</strong></p>
<p>Context는 &quot;설정값 읽기 → 팩토리한테 만들어달라고 요청 → 받은 전략 실행&quot;만 함. 분기도 없고, 구체 클래스도 모름.</p>
<h2 id="8-결론">8. 결론</h2>
<ul>
<li>전략패턴 = 여러 구현체를 인터페이스로 규격화 (밖에서 보기엔 똑같이 다룰 수 있게)</li>
<li>팩토리패턴 = 그 규격화된 것들 중 뭘 쓸지 결정해서 만들어주는 역할</li>
<li>둘 다 &quot;없어도 동작은 함&quot;. 필수가 아니라 <strong>코드를 깔끔하게 유지하기 위한 선택</strong>임. 다만 새 케이스가 늘어나거나, 런타임에 전략을 바꿔야 하는 요구사항이 생기는 순간부터 그 가치가 확 드러남.</li>
<li>내 프로젝트는 현재 전략패턴만 쓰고 있고(Spring <code>@ConditionalOnProperty</code>가 팩토리 역할을 대신함, 단 앱 재시작 시에만 결정됨), 나중에 관리자가 런타임에 검색 엔진을 토글하는 기능을 넣으면 그때 진짜 팩토리패턴이 필요해질 거임.
<del>~</del></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring AI]]></title>
            <link>https://velog.io/@k_joon_/Spring-AI</link>
            <guid>https://velog.io/@k_joon_/Spring-AI</guid>
            <pubDate>Sat, 29 Aug 2026 06:00:25 GMT</pubDate>
            <description><![CDATA[<h2 id="spring-ai-왜-등장했나">Spring AI, 왜 등장했나</h2>
<p>기존 Spring 환경에서 LLM/AI 기능을 추가하려면 Python 기반 환경을 따로 띄워야 했음. 싸피 프로젝트에서도 이렇게 AI를 직접 구현하고(내용은 잘 기억 안 남) 연동해서 띄웠던 걸로 기억함.</p>
<p>근데 이제는 이미 만들어진 AI를 불러다 쓸 수 있음. 또한 직접 모든 코드를 짜서 부르는 게 아니라, 어느 정도 그 과정 자체가 간편화됨. 그게 바로 Spring AI의 등장.</p>
<ul>
<li>Python 서버 필요 없음</li>
<li>LangChain 별도 구현 불필요</li>
<li>프롬프트 / LLM 호출 / 응답 처리 모두 Spring 내부에서 해결 가능</li>
</ul>
<p>또 모델이 굉장히 많은데, 과거에는 각 모델의 API를 직접 호출해 기능을 만들어야 했음. 모델마다 엔드포인트가 다르고 여러 API를 쓰는 게 불편했음. ChatGPT / Claude / Gemini 등 각각 다르게 써야 했는데, Spring AI의 등장으로 하나의 규격으로 쓰는 게 편리해짐.</p>
<p>즉 이것도 나름 전략 패턴이라고 할 수 있지 않을까? ㅇㅇ</p>
<hr>
<h2 id="실제-코드-embeddingservice">실제 코드 (EmbeddingService)</h2>
<pre><code class="language-java">// src/main/java/.../post/service/search/EmbeddingService.java
private final EmbeddingModel embeddingModel;   // Spring AI 인터페이스

public float[] embed(String text) { // 캐시활용
    return queryEmbeddingCache.get(text, text2 -&gt; embeddingModel.embed(text2));
}

public float[] embedWithoutCache(String text) { // 노캐시
    return embeddingModel.embed(text);
}

@Retryable(retryFor = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
@Transactional
public void generateAndSave(Long postId, String text) { // 임베딩한 데이터 DB에 저장
    generationTimer.record(() -&gt; {
        float[] embedding = embedWithoutCache(text);
        therapyPostRepository.updateContentEmbedding(postId, toVectorString(embedding));
    });
    successCounter.increment();
}</code></pre>
<p><code>EmbeddingModel</code>은 스프링이 AI를 자동 구성으로 만들어주는 빈. 직접 <code>new</code>하지 않고 생성자 주입을 받으면 됨.
그리고 만약 똑같은 검색을 자주 한다면 비용을 절약하기 위해 카페인캐시를 두었음. </p>
<p>설정은 이렇게:</p>
<pre><code class="language-yaml">spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY:}
      embedding:
        options:
          model: text-embedding-3-small
    model:
      chat: none        # OpenAI의 다른 기능(챗, 이미지 등)은 전부 꺼둠
      embedding: none    # 기본은 꺼두고, 검색 기능 프로필에서만 켜는 구조
      image: none
      moderation: none</code></pre>
<hr>
<h2 id="spring-ai가-없다면">Spring AI가 없다면?</h2>
<p>Spring AI가 대신해주는 부분을 보여주기 위한 비교용 코드. (실제 저장소엔 없음, 가상 예시)</p>
<pre><code class="language-java">@Component
public class OpenAiEmbeddingClient {
    private final RestClient restClient;   // RestClient 빌드
    private final ObjectMapper objectMapper;
    private final String apiKey;
    private final String model;

    public OpenAiEmbeddingClient(@Value(&quot;${OPENAI_API_KEY}&quot;) String apiKey,
                                  @Value(&quot;${openai.embedding.model:text-embedding-3-small}&quot;) String model) {
        this.apiKey = apiKey;
        this.model = model;
        this.objectMapper = new ObjectMapper();
        Duration timeout = Duration.ofSeconds(10);           // 타임아웃 설정
        this.restClient = RestClient.builder()
                .baseUrl(&quot;https://api.openai.com/v1&quot;)
                .requestFactory(ClientHttpRequestFactories.get(
                        ClientHttpRequestFactorySettings.DEFAULTS
                                .withConnectTimeout(timeout)
                                .withReadTimeout(timeout)))
                .build();
    }

    public float[] embed(String text) {
        Map&lt;String, Object&gt; body = Map.of(          // JSON 요청 바디 구성
                &quot;model&quot;, model,
                &quot;input&quot;, text
        );
        String response = restClient.post()
                .uri(&quot;/embeddings&quot;)
                .header(&quot;Authorization&quot;, &quot;Bearer &quot; + apiKey)
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(String.class);
        return parseEmbedding(response);             // 응답 파싱
    }

    private float[] parseEmbedding(String response) {
        try {
            JsonNode root = objectMapper.readTree(response);
            JsonNode values = root.path(&quot;data&quot;).get(0).path(&quot;embedding&quot;);
            float[] embedding = new float[values.size()];
            for (int i = 0; i &lt; values.size(); i++) {
                embedding[i] = (float) values.get(i).asDouble();
            }
            return embedding;
        } catch (Exception e) {
            throw new RuntimeException(&quot;OpenAI embedding response parsing failed&quot;, e);
        }
    }
}</code></pre>
<p>위의 코드가 아래 한 줄로 대체됨:</p>
<pre><code class="language-java">public float[] embedWithoutCache(String text) {
    return embeddingModel.embed(text);
}</code></pre>
<table>
<thead>
<tr>
<th>항목</th>
<th>직접 구현 시</th>
<th>Spring AI 사용 시</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP 클라이언트 생성/타임아웃 설정</td>
<td><code>RestClient.builder()...</code> 직접 작성</td>
<td>자동 구성이 내부적으로 처리</td>
</tr>
<tr>
<td>인증 헤더 붙이기</td>
<td><code>&quot;Authorization&quot;, &quot;Bearer &quot; + apiKey</code> 직접 작성</td>
<td><code>spring.ai.openai.api-key</code> 설정만 하면 끝</td>
</tr>
<tr>
<td>요청 바디 JSON 스키마</td>
<td>OpenAI 스펙(model, input 필드명 등)을 코드로 숙지·작성</td>
<td><code>embeddingModel.embed(text)</code> 한 줄</td>
</tr>
<tr>
<td>응답 파싱</td>
<td><code>data[0].embedding</code> 경로를 알고 JsonNode로 직접 파싱</td>
<td><code>float[]</code>를 그냥 리턴받음</td>
</tr>
<tr>
<td>에러 처리(4xx/5xx, 재시도 대상 판단)</td>
<td>직접 예외 매핑</td>
<td>라이브러리가 표준화된 예외 제공</td>
</tr>
</tbody></table>
<hr>
<h2 id="내-서버에서-켜려면">내 서버에서 켜려면</h2>
<p>application.yaml을 수정하거나:</p>
<pre><code class="language-yaml">spring.ai.model.embedding: none -&gt; openai
app.search.embedding.enabled: false -&gt; true
app.search.strategy: gin -&gt; vector</code></pre>
<p>또는 deploy.yml을 변경하면 됨:</p>
<pre><code>&quot;aws secretsmanager get-secret-value --secret-id community/melonne/openai/staging --region ap-northeast-2 --query SecretString --output text &gt;&gt; /tmp/staging.env&quot;,
&quot;echo SPRING_AI_MODEL_EMBEDDING=openai &gt;&gt; /tmp/staging.env&quot;,
&quot;echo APP_SEARCH_EMBEDDING_ENABLED=true &gt;&gt; /tmp/staging.env&quot;,
&quot;echo APP_SEARCH_STRATEGY=vector &gt;&gt; /tmp/staging.env&quot;,</code></pre><p>근데 application.yaml에서 비밀 아닌 값(embedding on/off, strategy 등)은 그냥 처리하고, OpenAI API 키 어디서 가져올지만 deploy.yml에 명시하면 될 듯.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[시멘틱검색]]></title>
            <link>https://velog.io/@k_joon_/%EC%8B%9C%EB%A9%98%ED%8B%B1%EA%B2%80%EC%83%89</link>
            <guid>https://velog.io/@k_joon_/%EC%8B%9C%EB%A9%98%ED%8B%B1%EA%B2%80%EC%83%89</guid>
            <pubDate>Fri, 28 Aug 2026 06:15:42 GMT</pubDate>
            <description><![CDATA[<h1 id="pgvector-실습-정리---거리-함수와-hnsw-인덱스">pgvector 실습 정리 - 거리 함수와 HNSW 인덱스</h1>
<h2 id="0-환경-설정">0. 환경 설정</h2>
<p>로컬 PostgreSQL(Windows, 18.1)에 pgvector를 설치하려고 했으나, 소스 빌드에 Visual Studio Build Tools(<code>cl</code>, <code>nmake</code>)가 필요한데 없었음. 새로 설치하기엔 용량/시간이 부담스러워서 <strong>Docker</strong>로 우회.</p>
<pre><code class="language-bash">docker run -d --name pgvector-demo -e POSTGRES_PASSWORD=secret -p 5433:5432 pgvector/pgvector:pg17</code></pre>
<ul>
<li>기존 로컬 Postgres 18은 전혀 건드리지 않음</li>
<li>완전히 별개의 컨테이너에 pgvector가 이미 설치된 Postgres 17을 새로 띄운 것</li>
<li>pgAdmin에서 <code>localhost:5433</code>으로 새 서버 등록해서 연결</li>
</ul>
<p><strong>확장 활성화</strong></p>
<pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS vector;

SELECT * FROM pg_extension WHERE extname = &#39;vector&#39;;</code></pre>
<table>
<thead>
<tr>
<th>extname</th>
<th>extversion</th>
</tr>
</thead>
<tbody><tr>
<td>vector</td>
<td>0.8.6</td>
</tr>
</tbody></table>
<hr>
<h2 id="1-벡터-테이블-만들고-데이터-넣기">1. 벡터 테이블 만들고 데이터 넣기</h2>
<pre><code class="language-sql">CREATE TABLE items (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  embedding VECTOR(3)
);

INSERT INTO items (name, embedding) VALUES
  (&#39;사과&#39;,  &#39;[1, 0, 0]&#39;),
  (&#39;배&#39;,    &#39;[0.9, 0.1, 0]&#39;),
  (&#39;바나나&#39;, &#39;[0.1, 0.9, 0]&#39;),
  (&#39;당근&#39;,  &#39;[0, 0.1, 0.9]&#39;);</code></pre>
<hr>
<h2 id="2-거리-함수-3종-비교-l2--내적--코사인">2. 거리 함수 3종 비교 (L2 / 내적 / 코사인)</h2>
<p>pgvector가 지원하는 거리 연산자:</p>
<ul>
<li><code>&lt;-&gt;</code> : <strong>L2(유클리드) 거리</strong> — 두 점 사이의 실제 직선거리. 방향과 크기 둘 다 반영</li>
<li><code>&lt;#&gt;</code> : <strong>내적(Inner Product)</strong> — 방향이 얼마나 같은지 <strong>+ 크기가 얼마나 큰지</strong>를 동시에 봄. <code>크기가 서로 같은지</code>가 아니라 <code>|A|×|B|×cos(θ)</code>라서 크기가 결과에 곱으로 반영됨. 그래서 벡터가 정규화(길이=1)되어 있지 않으면 유사도 검색 용도로는 안 맞음. 정규화만 되어있으면 코사인이랑 순위가 같아짐</li>
<li><code>&lt;=&gt;</code> : <strong>코사인 거리</strong> — <strong>방향만</strong> 봄. 크기는 완전히 무시. <code>1 - cos(θ)</code></li>
</ul>
<p><strong>코사인 거리로 &#39;사과&#39;와 가장 비슷한 항목 찾기</strong></p>
<pre><code class="language-sql">SELECT name, embedding &lt;=&gt; &#39;[1, 0, 0]&#39; AS distance
FROM items
ORDER BY embedding &lt;=&gt; &#39;[1, 0, 0]&#39;
LIMIT 3;</code></pre>
<table>
<thead>
<tr>
<th>name</th>
<th>distance</th>
</tr>
</thead>
<tbody><tr>
<td>사과</td>
<td>0</td>
</tr>
<tr>
<td>배</td>
<td>0.0061</td>
</tr>
<tr>
<td>바나나</td>
<td>0.8896</td>
</tr>
</tbody></table>
<p><strong>셋 다 같이 비교</strong></p>
<pre><code class="language-sql">SELECT 
  name,
  embedding &lt;-&gt; &#39;[1, 0, 0]&#39; AS l2_distance,
  embedding &lt;#&gt; &#39;[1, 0, 0]&#39; AS neg_inner_product,
  embedding &lt;=&gt; &#39;[1, 0, 0]&#39; AS cosine_distance
FROM items
ORDER BY embedding &lt;=&gt; &#39;[1, 0, 0]&#39;;</code></pre>
<table>
<thead>
<tr>
<th>name</th>
<th>L2 (<code>&lt;-&gt;</code>)</th>
<th>-내적 (<code>&lt;#&gt;</code>)</th>
<th>코사인 (<code>&lt;=&gt;</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>사과</td>
<td>0</td>
<td>-1</td>
<td>0</td>
</tr>
<tr>
<td>배</td>
<td>0.1414</td>
<td>-0.9</td>
<td>0.0061</td>
</tr>
<tr>
<td>바나나</td>
<td>1.2728</td>
<td>-0.1</td>
<td>0.8896</td>
</tr>
<tr>
<td>당근</td>
<td>1.3491</td>
<td>~0</td>
<td>1</td>
</tr>
</tbody></table>
<p>3개 다 순위가 똑같이 나옴. 이 예제 벡터들의 크기가 다 비슷(≈1)해서 그런 것. 크기가 크게 다르면 코사인 vs L2/내적 순위가 갈릴 수 있음.</p>
<p><strong>속도 순서</strong>: 내적 &gt; 코사인 &gt; L2 (제곱·루트 연산 없어서 내적이 이론상 제일 빠름). 근데 이 차이는 실무에서 체감될 정도는 아니고, 병목은 거리 함수 자체가 아니라 인덱스 탐색 쪽.</p>
<p><strong>내가 쓴 선택</strong>: OpenAI 임베딩은 코사인 거리 기준으로 최적화되어 있어서 코사인 채택. (참고로 OpenAI 임베딩은 이미 정규화되어 나오기 때문에, 내적으로 바꿔도 결과는 같고 속도만 이득 볼 여지는 있음)</p>
<hr>
<h2 id="3-인덱스가-왜-필요한가---seq-scan의-한계">3. 인덱스가 왜 필요한가 - Seq Scan의 한계</h2>
<p>인덱스 없이 조회하면:</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT name FROM items
ORDER BY embedding &lt;=&gt; &#39;[1, 0, 0]&#39;
LIMIT 3;</code></pre>
<pre><code>Limit  (cost=30.59..30.60 rows=3 width=40) (actual time=0.077..0.078 rows=3 loops=1)
  -&gt;  Sort  (cost=30.59..32.62 rows=810 width=40) (actual time=0.075..0.076 rows=3 loops=1)
        Sort Key: ((embedding &lt;=&gt; &#39;[1,0,0]&#39;::vector))
        Sort Method: quicksort  Memory: 25kB
        -&gt;  Seq Scan on items  (cost=0.00..20.12 rows=810 width=40) (actual time=0.063..0.065 rows=4 loops=1)
Planning Time: 0.239 ms
Execution Time: 0.103 ms</code></pre><p><code>Seq Scan</code> = 테이블 전체를 순차적으로 다 훑은 것. 지금은 4개뿐이라 0.1ms지만, 데이터가 늘어나면 이게 병목이 됨.</p>
<hr>
<h2 id="4-hnsw-인덱스">4. HNSW 인덱스</h2>
<p><img src="https://velog.velcdn.com/images/k_joon_/post/c31b39fb-cfd7-497d-bbeb-c5b811ced022/image.png" alt=""></p>
<h3 id="4-1-뭔지">4-1. 뭔지</h3>
<p><strong>Hierarchical Navigable Small World</strong></p>
<ul>
<li>여러 층으로 된 그래프를 만들어서, 상위층에서 방향을 잡고 아래층으로 내려오면서 점점 정밀하게 좁혀나감</li>
<li>처음엔 대강, 뒤로 갈수록 촘촘하게</li>
</ul>
<p><strong>검색 과정</strong></p>
<ol>
<li>맨 위층 진입</li>
<li>해당 층에서 그리디 탐색. 현재 노드의 이웃들 중 쿼리 벡터와 더 가까운 노드가 있으면 이동(양옆으로 이동). 더 가까운 이웃이 없으면 멈춤</li>
<li>그 위치 그대로 아래층으로 넘김</li>
<li>아래층에서 다시 그리디 탐색 반복</li>
</ol>
<p><strong>삽입 과정</strong>
벡터가 몇 층까지 올라갈지는 삽입 시점에 랜덤하게 결정됨.</p>
<h3 id="4-2-b-tree랑-뭐가-다른가">4-2. B-Tree랑 뭐가 다른가</h3>
<table>
<thead>
<tr>
<th></th>
<th>B-Tree</th>
<th>HNSW</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>1차원 정렬 가능한 값</td>
<td>다차원 벡터 (전순서 없음)</td>
</tr>
<tr>
<td>구조</td>
<td>정렬 기준으로 균등 분기하는 트리</td>
<td>노드를 잇는 그래프 (트리 아님)</td>
</tr>
<tr>
<td>탐색</td>
<td>크다/작다로 절반씩 배제</td>
<td>이웃이 더 가까운지로 그리디 이동</td>
</tr>
<tr>
<td>정확도</td>
<td>100% 정확</td>
<td>근사치</td>
</tr>
<tr>
<td>&quot;레벨/층&quot;의 의미</td>
<td>값의 범위를 좁혀가는 단계</td>
<td>연결의 밀도(고속도로 vs 골목길) 차이일 뿐, 값의 순서와 무관</td>
</tr>
</tbody></table>
<h3 id="4-3-의문-정렬-안-된-걸-위→아래로-내려가는-게-의미-있나">4-3. 의문: &quot;정렬 안 된 걸 위→아래로 내려가는 게 의미 있나?&quot;</h3>
<p>핵심을 찌른 질문이었음. 결론부터 말하면 <strong>레이어는 정렬된 계층이 아니라 &quot;같은 그래프의 다른 밀도 버전&quot;</strong>임.</p>
<ul>
<li>같은 벡터(노드)가 여러 층에 동시에 존재함 (좌표 안 바뀜)</li>
<li>위층에서 하는 일 = &quot;정렬 좁히기&quot;가 아니라 그냥 그리디 탐색으로 로컬 미니멈까지 이동하는 것</li>
<li>그 로컬 미니멈 좌표를 그대로 한 층 아래로 넘기고, 아래층(더 촘촘한 그래프)에서 다시 미세조정</li>
</ul>
<p>즉 &quot;내려간다&quot;는 게 순서 개념이 아니라, <strong>같은 위치에서 참고하는 지도만 더 촘촘한 걸로 바꿔가는 것</strong>. 위층은 노드가 적어서 한 번의 이동으로 멀리 점프 가능(고속도로), 아래층은 촘촘해서 미세 조정(골목길). 이걸로 총 이동 횟수(홉 수)를 줄이는 게 목적 — 스킵 리스트(Skip List)가 O(log N)을 만드는 원리랑 동일.</p>
<p>풀스캔 안 하고 굳이 층을 나누는 이유도 이거임. 한 층짜리 그래프만 타도 풀스캔보다는 빠르지만, 노드가 많으면 시작점에서 목적지까지 거쳐야 할 홉 수가 많아짐. 성긴 위층에서 큰 걸음으로 먼저 근처까지 가면 총 홉 수가 훨씬 줄어듦.</p>
<h3 id="4-4-local-minimum-문제">4-4. Local Minimum 문제</h3>
<p><strong>제기한 문제 상황</strong>: 맨 위층 진입 노드가 사실 &quot;뒤에서 세 번째로 먼 벡터&quot;이고, 그 아래층에서 양옆 이웃들이 전부 그 노드보다도 더 멀다면? → 그리디는 거기서 멈춰버리고, 진짜 가까운 벡터를 영영 못 찾음.</p>
<p>이건 순수 그리디(제일 가까운 이웃 1개만 보고 이동)를 쓰면 실제로 생기는 문제가 맞음. HNSW는 두 가지 보완책으로 이걸 완화함.</p>
<p><strong>보완책 1 — 크기 <code>ef</code>짜리 후보 목록(우선순위 큐)을 유지</strong></p>
<ul>
<li>현재까지 찾은 가장 가까운 <code>ef</code>개 노드를 후보 목록에 넣어둠</li>
<li>그중 아직 이웃 탐색 안 한 노드 중 제일 가까운 걸 골라서 그 이웃들을 전부 검사</li>
<li>검사한 이웃 중 후보 목록보다 가까운 게 있으면 추가 (넘치면 제일 먼 거 버림)</li>
<li>더 이상 개선 없을 때까지 반복</li>
</ul>
<p>→ &quot;지금 서 있는 노드&quot;만 보고 판단 안 하고, 여러 갈래를 동시에 들고 다니면서 막다른 길이어도 다른 갈래로 계속 파고들 수 있음.</p>
<p><strong>보완책 2 — 그래프를 만들 때부터 다양한 방향의 이웃을 고름</strong></p>
<ul>
<li>그냥 &quot;가장 가까운 M개&quot;로만 연결하면 특정 방향으로만 그래프가 뻗어서 막다른 길이 많이 생김</li>
<li>그래서 &quot;가깝지만 서로 다른 방향&quot;의 이웃들을 우선 선택하는 규칙을 씀 (지름길 역할)</li>
</ul>
<p>→ 이 두 가지로 확률을 크게 낮추지만 100% 보장은 안 됨. 그래서 HNSW가 &quot;근사(Approximate)&quot; 인덱스인 것.</p>
<h3 id="4-5-파라미터">4-5. 파라미터</h3>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>시점</th>
<th>의미</th>
<th>크면</th>
<th>작으면</th>
</tr>
</thead>
<tbody><tr>
<td><code>m</code></td>
<td>인덱스 생성</td>
<td>노드당 최대 이웃 수 (기본 16)</td>
<td>정확도↑, 메모리↑</td>
<td>메모리 절약, local minimum 취약</td>
</tr>
<tr>
<td><code>ef_construction</code></td>
<td>인덱스 생성</td>
<td>이웃 M개를 고를 때 비교할 후보 수 (기본 64)</td>
<td>인덱스 품질↑, 생성 시간↑</td>
<td>생성 빠름, 품질↓</td>
</tr>
<tr>
<td><code>ef_search</code></td>
<td>검색 시</td>
<td>그리디 탐색 중 유지하는 후보 수 (기본 40)</td>
<td>정확도↑(recall↑), 느려짐</td>
<td>빠름, 놓칠 확률↑</td>
</tr>
</tbody></table>
<pre><code class="language-sql">SET hnsw.ef_search = 100;  -- 세션 단위로 조절 가능</code></pre>
<h3 id="단점">단점</h3>
<p>대규모 데이터에는 메모리 비용이 엄청나다고함.
동적 삭제가 어려움. 그래프에서 특정 노드를 삭제하면 연결된 링크들이 끊어져 크래프가 파편화될 위험이 있음. 이를 복구하기 위해 주변 노드들을 재연결하는 비용이 큼. 하지만 게시글 삭제는 그렇게 빈번하지 않기에 ㄱㅊ</p>
<hr>
<h2 id="5-실습--옵티마이저가-인덱스를-언제-타는가">5. 실습 — 옵티마이저가 인덱스를 언제 타는가</h2>
<h3 id="5-1-인덱스-생성">5-1. 인덱스 생성</h3>
<pre><code class="language-sql">CREATE INDEX ON items
USING hnsw (embedding vector_cosine_ops);</code></pre>
<h3 id="5-2-4개-행--여전히-seq-scan">5-2. 4개 행 — 여전히 Seq Scan</h3>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT name FROM items
ORDER BY embedding &lt;=&gt; &#39;[1, 0, 0]&#39;
LIMIT 3;</code></pre>
<pre><code>-&gt;  Seq Scan on items  (cost=0.00..1.05 rows=4 width=40) (actual time=0.041..0.043 rows=4 loops=1)
Execution Time: 0.080 ms</code></pre><p>데이터가 너무 적어서 옵티마이저가 &quot;인덱스 타는 것보다 그냥 다 훑는 게 싸다&quot;고 판단.</p>
<h3 id="5-3-1000개-10000개--아직도-seq-scan">5-3. 1,000개, 10,000개 — 아직도 Seq Scan</h3>
<p>데이터를 늘려도(1천 개, 1만 개) 여전히 Seq Scan. row 수가 아직 충분치 않다는 뜻.</p>
<pre><code class="language-sql">INSERT INTO items (name, embedding)
SELECT &#39;item_&#39; || i, ARRAY[random(), random(), random()]::vector
FROM generate_series(1, 1000) AS i;</code></pre>
<table>
<thead>
<tr>
<th>행 수</th>
<th>3차원 기준 스캔 방식</th>
</tr>
</thead>
<tbody><tr>
<td>4개</td>
<td>Seq Scan</td>
</tr>
<tr>
<td>1,004개</td>
<td>Seq Scan</td>
</tr>
<tr>
<td>10,000개</td>
<td>Seq Scan</td>
</tr>
</tbody></table>
<h3 id="5-4-10만-개--드디어-index-scan-등장">5-4. 10만 개 — 드디어 Index Scan 등장</h3>
<pre><code class="language-sql">INSERT INTO items (name, embedding)
SELECT &#39;item_&#39; || i, ARRAY[random(), random(), random()]::vector
FROM generate_series(1, 100000) AS i;</code></pre>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT name FROM items
ORDER BY embedding &lt;=&gt; &#39;[1, 0, 0]&#39;
LIMIT 3;</code></pre>
<pre><code>Limit  (cost=112.10..112.72 rows=3 width=18) (actual time=0.543..0.547 rows=3 loops=1)
  -&gt;  Index Scan using items_embedding_idx on items  (cost=112.10..21088.08 rows=101004 width=18) (actual time=0.541..0.544 rows=3 loops=1)
        Order By: (embedding &lt;=&gt; &#39;[1,0,0]&#39;::vector)
Execution Time: 0.588 ms</code></pre><p><code>Index Scan using items_embedding_idx</code> 등장.</p>
<h3 id="5-5-강제로-seq-scan-시켜서-직접-비교">5-5. 강제로 Seq Scan 시켜서 직접 비교</h3>
<pre><code class="language-sql">SET enable_indexscan = off;

EXPLAIN ANALYZE
SELECT name FROM items
ORDER BY embedding &lt;=&gt; &#39;[1, 0, 0]&#39;
LIMIT 3;

SET enable_indexscan = on;</code></pre>
<pre><code>-&gt;  Seq Scan on items  (cost=0.00..2104.55 rows=101004 width=18) (actual time=0.018..21.724 rows=101004 loops=1)
Execution Time: 41.322 ms</code></pre><p><strong>결과 비교 (3차원, 10만 행)</strong></p>
<table>
<thead>
<tr>
<th>방식</th>
<th>Execution Time</th>
</tr>
</thead>
<tbody><tr>
<td>Seq Scan</td>
<td>41.3 ms</td>
</tr>
<tr>
<td>HNSW Index Scan</td>
<td>0.588 ms</td>
</tr>
</tbody></table>
<p>약 <strong>70배</strong> 차이.</p>
<hr>
<h2 id="6-차원을-늘리면-어떻게-달라지나--1536차원-실험">6. 차원을 늘리면 어떻게 달라지나 — 1536차원 실험</h2>
<p>OpenAI <code>text-embedding-3-small</code>이 1536차원이라 이 규모로 실제 실험.</p>
<pre><code class="language-sql">CREATE TABLE items_1536 (
  id BIGSERIAL PRIMARY KEY,
  name TEXT,
  embedding VECTOR(1536)
);

INSERT INTO items_1536 (name, embedding)
SELECT 
  &#39;item_&#39; || i,
  (SELECT array_agg(random()) FROM generate_series(1, 1536))::vector
FROM generate_series(1, 10000) AS i;

CREATE INDEX ON items_1536 USING hnsw (embedding vector_cosine_ops);

ANALYZE items_1536;</code></pre>
<h3 id="6-1-그런데-여전히-seq-scan">6-1. 그런데 여전히 Seq Scan</h3>
<p>같은 1만 개 행인데도(3차원일 땐 이 규모에서 Seq Scan 나온 것과 같음) 여전히 Seq Scan.</p>
<pre><code>-&gt;  Seq Scan on items_1536  (cost=0.00..209.00 rows=10000 width=17) (actual time=0.243..160.112 rows=10000 loops=1)
Execution Time: 164.662 ms</code></pre><p><strong>차원별 Seq Scan 속도 비교 (1만 행 기준)</strong></p>
<table>
<thead>
<tr>
<th>차원</th>
<th>Execution Time</th>
</tr>
</thead>
<tbody><tr>
<td>3차원</td>
<td>5.5 ms</td>
</tr>
<tr>
<td>1536차원</td>
<td>164.7 ms</td>
</tr>
</tbody></table>
<p>같은 행 수인데 차원이 커지니 거리 계산 자체가 무거워져서 약 <strong>30배</strong> 차이. → <strong>차원이 클수록 같은 행 수에서도 Seq Scan이 훨씬 불리해짐</strong>을 확인.</p>
<h3 id="6-2-강제로-index-scan-시켜서-비교">6-2. 강제로 Index Scan 시켜서 비교</h3>
<pre><code class="language-sql">SET enable_seqscan = off;

EXPLAIN ANALYZE
SELECT name FROM items_1536
ORDER BY embedding &lt;=&gt; &#39;[...1536차원 벡터...]&#39;
LIMIT 3;

SET enable_seqscan = on;</code></pre>
<pre><code>-&gt;  Index Scan using items_1536_embedding_idx on items_1536  (cost=10000000088.10..10000037872.00 rows=10000 width=17) (actual time=1.183..1.200 rows=3 loops=1)
        Order By: (embedding &lt;=&gt; &#39;[...]&#39;::vector)
Planning Time: 0.413 ms
JIT:
  Functions: 5
  Timing: ... Inlining 170.228 ms, Optimization 287.415 ms, Emission 142.291 ms, Total 603.745 ms
Execution Time: 1062.026 ms</code></pre><p><strong>진짜 스캔 시간 vs 전체 Execution Time</strong></p>
<table>
<thead>
<tr>
<th>구간</th>
<th>시간</th>
</tr>
</thead>
<tbody><tr>
<td>Index Scan 실제 스캔 (<code>actual time</code>)</td>
<td><strong>1.2 ms</strong></td>
</tr>
<tr>
<td>전체 Execution Time</td>
<td>1062 ms</td>
</tr>
</tbody></table>
<p>전체 시간이 크게 나온 건 인덱스 문제가 아니라 <strong>JIT 컴파일 오버헤드</strong>(약 600ms) 때문. 진짜 봐야 할 숫자는 Index Scan 노드의 <code>actual time = 1.2ms</code>.</p>
<p><strong>정리 (1536차원, 1만 행)</strong></p>
<table>
<thead>
<tr>
<th>방식</th>
<th>진짜 스캔 시간</th>
</tr>
</thead>
<tbody><tr>
<td>Seq Scan</td>
<td>~160 ms</td>
</tr>
<tr>
<td>HNSW Index Scan</td>
<td><strong>~1.2 ms</strong></td>
</tr>
</tbody></table>
<p>약 <strong>133배</strong> 차이. 3차원 실험(70배)보다 차이가 더 크게 벌어짐 → 차원이 클수록 인덱스의 이득이 더 커진다는 뜻.</p>
<hr>
<h2 id="7-왜-옵티마이저가-스스로-인덱스를-안-탔나">7. 왜 옵티마이저가 스스로 인덱스를 안 탔나?</h2>
<h3 id="7-1-cost-추정-문제">7-1. Cost 추정 문제</h3>
<p><code>EXPLAIN</code> 결과의 cost를 보면:</p>
<pre><code>Index Scan: cost=10000000088.10..10000037872.00
Seq Scan  : cost=0.00..209.00</code></pre><p>HNSW 인덱스 스캔에는 <code>10000000000</code>이라는 거대한 고정 startup cost가 붙어 있음. 이건 그래프 진입점까지 가는 오버헤드를 실제보다 훨씬 크게 잡아둔 pgvector HNSW의 cost 모델 특성. 옵티마이저는 이 견적만 보고 &quot;시작부터 이게 더 비싸다&quot;고 판단해서 Seq Scan을 고름 — 실제로는 견적이 틀린 것(실제 실행하면 인덱스가 압도적으로 빠름).</p>
<p>즉, <strong>데이터 양이 아니라 옵티마이저의 cost 추정 자체가 부정확해서</strong> 생기는 문제. 실무 대응은 <code>enable_seqscan = off</code> 세션 설정, <code>ef_search</code>/<code>probes</code> 튜닝, 통계 재조정 등.</p>
<h3 id="7-2-jit란">7-2. JIT란?</h3>
<p><strong>Just-In-Time Compilation.</strong> Postgres는 원래 실행 계획을 &quot;해석(interpret)&quot; 방식으로 돎 — 매 행마다 연산자 찾고 타입 체크하고 실행. PostgreSQL 11부터, 특정 조건에서 표현식 계산 부분을 그 자리에서 <strong>기계어로 직접 컴파일</strong>해서 반복 계산을 빠르게 만드는 기능이 생김. 이게 JIT.</p>
<ul>
<li>목적: 반복되는 계산을 빠르게. 100만 행에 대해 매번 &quot;연산자 찾기→타입 체크→실행&quot;을 반복하는 대신, 표현식을 한 번 네이티브 함수로 컴파일해두고 그 함수를 100만 번 호출</li>
<li><strong>근데 우리 경우엔 역효과</strong>: 실제 스캔되는 행이 딱 3개(LIMIT 3)라서, 컴파일 투자(≈600ms) 대비 실행 이득이 전혀 없었음. 벡터 리터럴이 워낙 커서(1536개 숫자) 표현식이 복잡하다고 오판해 JIT를 켠 것으로 보임</li>
<li>즉 &quot;1062ms&quot;는 인덱스 성능과 무관한 환경적 잡음이고, 진짜 인덱스 스캔 성능은 <code>1.2ms</code></li>
</ul>
<hr>
<h2 id="8-ef_search-실습---후보-목록-크기">8. ef_search 실습 - 후보 목록 크기</h2>
<p><code>ef_search</code>는 그리디 탐색 중 유지하는 <strong>후보 목록(우선순위 큐)의 크기</strong>. 이 값이 실제로 검색 품질에 어떤 영향을 주는지 직접 확인해봄.</p>
<h3 id="8-1-먼저-인덱스-없이seq-scan--기준값">8-1. 먼저 인덱스 없이(Seq Scan) — 기준값</h3>
<pre><code class="language-sql">SET hnsw.ef_search = 1;

EXPLAIN ANALYZE
SELECT name FROM items_1536
ORDER BY embedding &lt;=&gt; &#39;[1536차원 벡터]&#39;
LIMIT 10;</code></pre>
<pre><code>-&gt;  Seq Scan on items_1536  (cost=0.00..209.00 rows=10000 width=17) (actual time=0.563..432.460 rows=10000 loops=1)
Execution Time: 440.846 ms</code></pre><p><code>ef_search</code>는 <strong>HNSW 인덱스를 탈 때만</strong> 적용되는 파라미터라서, Seq Scan 상태에서는 아무 의미가 없음. Seq Scan은 항상 정확하게 10개를 다 찾아냄(당연히 전체를 다 보니까). 이 결과는 그냥 비교 기준일 뿐.</p>
<h3 id="8-2-강제로-index-scan-태워서-ef_search1-적용">8-2. 강제로 Index Scan 태워서 ef_search=1 적용</h3>
<pre><code class="language-sql">SET enable_seqscan = off;
SET hnsw.ef_search = 1;

EXPLAIN ANALYZE
SELECT name FROM items_1536
ORDER BY embedding &lt;=&gt; &#39;[같은 벡터]&#39;
LIMIT 10;</code></pre>
<pre><code>Limit  (cost=10000000084.80..10000000122.59 rows=10 width=17) (actual time=125.572..125.574 rows=1 loops=1)
  -&gt;  Index Scan using items_1536_embedding_idx on items_1536  (cost=10000000084.80..10000037872.00 rows=10000 width=17) (actual time=0.721..0.722 rows=1 loops=1)
        Order By: (embedding &lt;=&gt; &#39;[...]&#39;::vector)</code></pre><p><strong><code>LIMIT 10</code>을 걸었는데 <code>rows=1</code>밖에 못 찾음.</strong></p>
<p>이게 핵심 결과임. <code>ef_search=1</code>이면 후보 목록에 딱 1개만 담을 수 있으니, 그리디 탐색이 후보 하나 찾자마자 &quot;더 이상 볼 자리가 없다&quot;고 판단하고 바로 멈춰버린 것. 나머지 9개는 그래프의 다른 영역에 분명히 있었을 텐데, 탐색 범위 자체가 너무 좁아서 아예 못 감. 이게 바로 이론으로만 알고 있던 <strong>local minimum / recall 저하 문제가 숫자로 재현된 장면</strong>.</p>
<h3 id="8-3-ef_search를-200으로-올리면">8-3. ef_search를 200으로 올리면</h3>
<pre><code class="language-sql">SET hnsw.ef_search = 200;

EXPLAIN ANALYZE
SELECT name FROM items_1536
ORDER BY embedding &lt;=&gt; &#39;[같은 벡터]&#39;
LIMIT 10;

SET enable_seqscan = on;</code></pre>
<pre><code>Limit  (cost=10000000098.28..10000000136.06 rows=10 width=17) (actual time=72.346..72.415 rows=10 loops=1)
  -&gt;  Index Scan using items_1536_embedding_idx on items_1536  (cost=10000000098.28..10000037872.00 rows=10000 width=17) (actual time=0.700..0.767 rows=10 loops=1)
        Order By: (embedding &lt;=&gt; &#39;[...]&#39;::vector)</code></pre><p>이번엔 <code>rows=10</code>으로 요청한 만큼 정상 반환됨.</p>
<h3 id="8-4-정리">8-4. 정리</h3>
<table>
<thead>
<tr>
<th>ef_search</th>
<th>Index Scan이 실제 찾은 행 수 (<code>rows=</code>)</th>
<th>Index Scan 자체 소요 시간 (<code>actual time</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><strong>1개</strong> (LIMIT 10인데 미달)</td>
<td>0.72ms</td>
</tr>
<tr>
<td>200</td>
<td><strong>10개</strong> (정상)</td>
<td>0.70~0.77ms</td>
</tr>
</tbody></table>
<p>시간은 둘이 거의 차이 없는데(둘 다 워낙 빠른 인덱스라), <strong>정확도(찾은 개수)는 극명하게 갈림</strong>. <code>ef_search</code>가 너무 작으면 빠른 대신 요청한 개수도 못 채울 만큼 품질이 무너질 수 있다는 걸 확인.</p>
<blockquote>
<p>참고: 위 결과들의 <code>Execution Time</code>(125ms, 72ms)은 JIT 컴파일 오버헤드가 섞여서 부풀려진 값. 진짜 봐야 할 숫자는 <code>Index Scan</code> 노드의 <code>actual time</code>(0.7ms대, 둘 다 비슷)과 <code>rows=</code> 값의 차이.</p>
</blockquote>
<p><strong>실무 규칙</strong>: <code>ef_search</code>는 항상 <code>LIMIT</code> 값보다 여유 있게 커야 함. 위 실험이 정확히 그 규칙을 어긴 경우(<code>LIMIT 10</code> vs <code>ef_search 1</code>)를 보여준 것.</p>
<h2 id="ivfflat-inverted-file-index---역색인">IVFFlat (Inverted File Index - 역색인)</h2>
<p>벡터 공간을 미리 N개의 클러스터로 나눠놓고, 검색할 때 쿼리와 가까운 클러스터 몇 개만 뒤지는 방식. HNSW가 그래프를 걸어가며 찾는 거였다면, IVFFlat은 덩어리(클러스터) 단위로 수색 범위를 좁히는 방식.</p>
<p>K-means로 <code>lists</code>개 클러스터를 미리 만들어두고, 검색 시 쿼리와 가까운 클러스터 <code>probes</code>개만 골라서 그 안에서 브루트포스로 거리 계산.</p>
<h3 id="hnsw-vs-ivfflat">HNSW vs IVFFlat</h3>
<table>
<thead>
<tr>
<th></th>
<th>HNSW</th>
<th>IVFFlat</th>
</tr>
</thead>
<tbody><tr>
<td>원리</td>
<td>다층 그래프를 그리디하게 탐색</td>
<td>클러스터링 후 가까운 클러스터만 뒤짐</td>
</tr>
<tr>
<td>정확도(recall)</td>
<td>더 높음</td>
<td>상대적으로 낮음</td>
</tr>
<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>데이터 삽입 순서</td>
<td>상관없음, 언제든 삽입 가능</td>
<td>클러스터가 데이터 분포 기준으로 미리 학습되어야 해서, 데이터 다 넣고 나서 인덱스 만드는 게 이상적 (분포가 크게 바뀌면 재구축 필요)</td>
</tr>
<tr>
<td>파라미터</td>
<td><code>m</code>, <code>ef_construction</code>, <code>ef_search</code></td>
<td><code>lists</code>, <code>probes</code></td>
</tr>
</tbody></table>
<p>정확도와 속도 둘 다 HNSW가 우위라 유사도 검색엔 HNSW가 적합. 반대로 메모리가 빠듯하거나 데이터가 거의 안 바뀌는 정적 데이터셋이라면 IVFFlat 고려.</p>
<p>HNSW는 빠른 쿼리 성능에 최적화되어 있으며 인메모리 그래프 구조를 사용하여 더 많은 메모리가 필요함. IVFFlat은 메모리 사용량이 적고 학습 단계를 통해 벡터를 클러스터로 구성하지만 성능은 데이터셋 및 워크로드에 따라 달라질 수 있음. 
워크로드 : 실제 운영 환경에서 시스템에 걸리는 부하의 패턴/성격을 말함.</p>
<h2 id="추가-openai-임베딩-비용">추가 OpenAI 임베딩 비용</h2>
<p>100자 글 하나에 0.0001$</p>
<p>1만개 게시글 : 1$</p>
<p>검색이 10만개 들어오더라도 1$ 미만.
충<del>분</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GIN(2)]]></title>
            <link>https://velog.io/@k_joon_/GIN2</link>
            <guid>https://velog.io/@k_joon_/GIN2</guid>
            <pubDate>Thu, 27 Aug 2026 23:07:45 GMT</pubDate>
            <description><![CDATA[<h1 id="gin--trigram-실습-노트-2--유사도-함수--gist">GIN + trigram 실습 노트 (2) — 유사도 함수 / GiST</h1>
<h2 id="13-similarity-vs-word_similarity-개념">13. similarity() vs word_similarity() 개념</h2>
<p><code>similarity()</code>는 이런 식으로 계산됨:</p>
<pre><code>similarity = 공통 trigram 개수 / 전체 trigram 개수 (합집합)</code></pre><h2 id="14-similarity-검증">14. similarity() 검증</h2>
<pre><code class="language-sql">SELECT similarity(&#39;postgresql&#39;, &#39;postgres&#39;);</code></pre>
<p>결과:</p>
<pre><code>0.6666667</code></pre><p>손으로 검증:</p>
<pre><code>&#39;postgresql&#39; 토큰(11개): p, po, pos, ost, stg, tgr, gre, res, esq, sql, ql
&#39;postgres&#39;   토큰(9개) : p, po, pos, ost, stg, tgr, gre, res, es
공통(8개): p, po, pos, ost, stg, tgr, gre, res

similarity = 8 / (11 + 9 - 8) = 8 / 12 = 0.667</code></pre><pre><code class="language-sql">SELECT similarity(&#39;공원 코스&#39;, &#39;공원 산책&#39;);</code></pre>
<p>결과:</p>
<pre><code>0.33333334</code></pre><h2 id="15-word_similarity-개념-및-비교">15. word_similarity() 개념 및 비교</h2>
<p><strong>word_similarity()</strong>: 긴 문자열 안에서 검색어와 가장 잘 맞는 부분 구간만 잘라내서 유사도를 계산하는 함수. 짧은 검색어로 긴 텍스트를 부분 검색할 때 사용됨.</p>
<pre><code class="language-sql">SELECT similarity(&#39;공원&#39;, &#39;서울 공원 코스는 정말 좋은 산책로입니다&#39;);</code></pre>
<p>결과:</p>
<pre><code>0.13043478</code></pre><pre><code class="language-sql">SELECT word_similarity(&#39;공원&#39;, &#39;서울 공원 코스는 정말 좋은 산책로입니다&#39;);</code></pre>
<p>결과:</p>
<pre><code>1</code></pre><p><strong>결론</strong>: <code>similarity()</code>는 두 문자열이 전체적으로 얼마나 닮았는지를 보고, <code>word_similarity()</code>는 짧은 검색어가 긴 문자열 어딘가에 얼마나 잘 그대로 존재하는지를 봄.</p>
<h2 id="16-구조도-정리">16. 구조도 정리</h2>
<pre><code>GIN (인덱스 자료구조, 역인덱스)
  └─ gin_trgm_ops (pg_trgm 확장이 제공하는 연산자 클래스)
       ├─ 토큰화 방식: trigram
       └─ 이 토큰 덕분에 인덱스를 탈 수 있는 연산자
            ├─ LIKE / ILIKE / ~ (정규식)
            └─ %  (유사도 임계값 검색)

pg_trgm 확장이 제공하는 유사도 계산 함수 (인덱스와 별개, 순수 계산)
  ├─ similarity(a, b)       → 전체 문자열 기준 유사도
  └─ word_similarity(a, b)  → 부분 매칭 기준 유사도</code></pre><p>B-Tree에서는 <code>LIKE &#39;%...%&#39;</code>는 꿈도 못 꿈. 앞에 <code>%</code>가 있으면 풀스캔 때려버림. GIN trigram에서는 <code>LIKE &#39;%...%&#39;</code>가 인덱스 타버림 (검색어 길이 조건은 앞서 실습에서 확인함).</p>
<h2 id="17--연산자--유사도-임계값-검색">17. % 연산자 — 유사도 임계값 검색</h2>
<p><code>%</code> 연산자 : 특정 문자열과 전체적으로 비슷한지를 묻는 연산자.</p>
<pre><code class="language-sql">SHOW pg_trgm.similarity_threshold;</code></pre>
<p>결과:</p>
<pre><code>0.3</code></pre><pre><code class="language-sql">SELECT * FROM route WHERE name % &#39;공원코스&#39;;</code></pre>
<p>결과:</p>
<pre><code>1    &quot;서울대공원코스&quot;</code></pre><p><strong>LIKE &#39;%...%&#39; vs % 차이</strong></p>
<ul>
<li><code>LIKE</code> : 그 문자열이 글자 그대로, 순서대로 포함되어 있는가</li>
<li><code>%</code> : trigram 집합끼리 겹치는 비율이 임계값(기본 0.3) 이상인가</li>
</ul>
<p>즉, <code>%</code>는 내부적으로 <code>similarity()</code>를 사용하고 있는 거임. <code>similarity()</code>가 계산한 값을 임계값보다 높은지 필터링해서 보여주는 연산자 형태임.</p>
<h2 id="18--연산자--word_similarity-임계값-검색">18. &lt;% 연산자 — word_similarity 임계값 검색</h2>
<p><code>&lt;%</code>는 내부적으로 <code>word_similarity()</code>를 쓰는 것.</p>
<pre><code class="language-sql">SHOW pg_trgm.word_similarity_threshold;</code></pre>
<p>결과:</p>
<pre><code>0.6</code></pre><pre><code class="language-sql">SELECT * FROM route WHERE &#39;공원&#39; &lt;% name;</code></pre>
<p>결과:</p>
<pre><code>5000     &quot;서울5000번 공원 코스&quot;
10000    &quot;서울10000번 공원 코스&quot;
15000    &quot;서울15000번 공원 코스&quot;
20000    &quot;서울20000번 공원 코스&quot;
25000    &quot;서울25000번 공원 코스&quot;
30000    &quot;서울30000번 공원 코스&quot;
35000    &quot;서울35000번 공원 코스&quot;
40000    &quot;서울40000번 공원 코스&quot;
45000    &quot;서울45000번 공원 코스&quot;
50000    &quot;서울50000번 공원 코스&quot;
50006    &quot;하늘 공원 가자&quot;</code></pre><p>같은 결과를 <code>word_similarity()</code> 함수로 직접 재현:</p>
<pre><code class="language-sql">SELECT * FROM route WHERE word_similarity(&#39;공원&#39;, name) &gt;= 0.6;</code></pre>
<p>결과:</p>
<pre><code>(위와 완전히 동일한 11건)</code></pre><p>인덱스 타는지 확인:</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM route WHERE &#39;공원&#39; &lt;% name;</code></pre>
<p>결과:</p>
<pre><code>&quot;Bitmap Heap Scan on route  (cost=34.45..52.76 rows=5 width=587) (actual time=0.080..0.137 rows=11.00 loops=1)&quot;
&quot;  Filter: (&#39;공원&#39;::text &lt;% (name)::text)&quot;
&quot;  Heap Blocks: exact=10&quot;
&quot;  Buffers: shared hit=18&quot;
&quot;  -&gt;  Bitmap Index Scan on idx_route_name_trgm  (cost=0.00..34.45 rows=5 width=0) (actual time=0.040..0.040 rows=11.00 loops=1)&quot;
&quot;        Index Cond: ((name)::text %&gt; &#39;공원&#39;::text)&quot;
&quot;        Index Searches: 1&quot;
&quot;        Buffers: shared hit=8&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=1&quot;
&quot;Planning Time: 0.502 ms&quot;
&quot;Execution Time: 0.177 ms&quot;</code></pre><p>인덱스 당연히 탐. (<code>Index Cond</code>에 <code>%&gt;</code>로 연산자가 바뀌어 나오는데, 이건 GIN 인덱스가 컬럼을 왼쪽에 두고 비교해야 해서 <code>&lt;%</code>의 좌우를 뒤집은 짝 연산자(<code>%&gt;</code>)로 자동 치환된 거임 — 계산 결과는 동일함.)</p>
<h2 id="19-tsvector">19. tsvector</h2>
<p>trigram과 다르게 형태소 분석기를 거쳐 단어 단위로 쪼갬.</p>
<pre><code class="language-sql">SELECT to_tsvector(&#39;english&#39;, &#39;The cats are running fast&#39;);</code></pre>
<p>결과:</p>
<pre><code>&#39;cat&#39;:2 &#39;fast&#39;:5 &#39;run&#39;:4</code></pre><p>근데 한국어는 기본으로 지원 안 함.</p>
<pre><code class="language-sql">SELECT to_tsvector(&#39;simple&#39;, &#39;자전거를 타고 공원에 갔습니다&#39;);</code></pre>
<p>결과:</p>
<pre><code>&#39;갔습니다&#39;:4 &#39;공원에&#39;:3 &#39;자전거를&#39;:1 &#39;타고&#39;:2</code></pre><p>띄어쓰기 기준으로만 자름 (형태소 분석 없음 — &quot;공원에&quot;가 &quot;공원&quot;으로 안 바뀜).</p>
<p><code>mecab-ko</code> 같은 외부 형태소 분석기를 확장으로 연결하면 진짜 한국어 형태소 분석이 가능함 (별도 형태소 분석 엔진을 서버에 설치하고, PostgreSQL이 그걸 불러쓸 수 있게 연결하는 확장 — <code>mecab-ko</code> 자체나 <code>pg_mecab</code> 같은 프로젝트 — 을 별도로 빌드/설치해야 함. 안 해봐서 잘 모르는 영역).</p>
<blockquote>
<p><strong>정정할 부분</strong>: &quot;trigram 쓰면 mecab-ko 없이도 되니까 trigram이 더 이득&quot;이라고 적었는데, 이건 좀 과한 일반화임. trigram은 부분 문자열/오타 허용 검색엔 강하지만, <code>tsvector</code>(형태소 분석) 쪽이 잘하는 게 따로 있음 — 예를 들어 관련도 순 랭킹(<code>ts_rank</code>), 불용어 처리, &quot;정확한 단어 단위&quot; 매칭의 정밀도 같은 거임. 그러니까 &quot;trigram이 항상 이득&quot;이 아니라, <strong>부분 검색/오타 허용이 핵심이면 trigram, 랭킹/정확한 단어 매칭이 핵심이면 형태소 분석(mecab-ko + tsvector)</strong> 이렇게 용도에 따라 갈리는 문제임. 이력서에서도 trigram과 pgvector(임베딩)를 Strategy 패턴으로 전환 가능하게 설계하신 것도 결국 &quot;trigram 하나로 다 되는 게 아니다&quot;라는 전제가 깔려있는 설계였음.</p>
</blockquote>
<h2 id="20-gist-generalized-search-tree">20. GiST (Generalized Search Tree)</h2>
<p>B-Tree처럼 계층적으로 좁혀나가는 트리이지만, B-Tree처럼 &quot;정확히 같다/크다/작다&quot;가 아니라 &quot;이 영역 안에 있다/겹친다/가깝다&quot; 같이 추상적이고 애매한 관계를 다루는 걸로 일반화된 구조임.</p>
<p><strong>동작 방식</strong>: 트리의 각 노드가 &quot;이 아래 자식들은 전부 이런 특성 범위 안에 있다&quot;는 경계 정보를 들고 있음.</p>
<p>더 구체적으로: 특정 데이터를 찾기 위해 들어오면, 아래 자식 노드들 각각에 &quot;이걸 넣으면 그 자식의 서명을 얼마나 더 넓게 확장해야 하나?&quot;를 계산함 (확장 비용). 확장 비용이 가장 적은 쪽으로 데이터를 넣음.</p>
<p>(서명 = 문자열 하나를 고정된 크기의 비트 뭉치로 압축한 요약본)</p>
<p>찾을 목적 데이터(F), 왼쪽 자식(L), 오른쪽 자식(R)이라고 하면, F의 서명은 <code>01001101...</code> 이런 식으로 32비트 정도의 비트 패턴으로 되어있음. F를 L, R 각각과 비교해서 관련 없는 쪽은 무시하고 관련 있는 쪽으로 이동함.</p>
<p>검색 단계에서는 <code>&lt;-&gt;</code> 연산자 사용.</p>
<pre><code class="language-sql">CREATE INDEX idx_route_name_gist
ON route USING gist (name gist_trgm_ops);</code></pre>
<p>결과:</p>
<pre><code>CREATE INDEX
Query returned successfully in 1 secs 632 msec.</code></pre><p>확실히 오래 걸림. 왜냐면 얘는 넣을 때마다 비용 계산해서 어디로 갈지 정함 (서명으로 압축해서 어디로 갈지 정함):</p>
<ol>
<li>그 행의 trigram 집합을 서명(고정 크기 비트로 압축)으로 만듦 — 계산 작업</li>
<li>트리 루트부터 시작해서, 왼쪽/오른쪽(또는 여러 자식) 중 &quot;이 서명을 넣으면 어디가 제일 덜 넓어지나&quot;를 계산 — 비교 연산</li>
<li>리프까지 내려가서 삽입</li>
<li>만약 그 노드가 꽉 차 있으면 분할(split) 알고리즘까지 돌림 — 이것도 추가 계산</li>
</ol>
<h2 id="21---정렬-검색">21. &lt;-&gt; 정렬 검색</h2>
<pre><code class="language-sql">SELECT name, name &lt;-&gt; &#39;대공원코스&#39; AS distance
FROM route
ORDER BY name &lt;-&gt; &#39;대공원코스&#39;
LIMIT 5;</code></pre>
<p>결과:</p>
<pre><code>&quot;서울대공원코스&quot;          0.6
&quot;서울5000번 공원 코스&quot;    0.94736844
&quot;서울40000번 공원 코스&quot;   0.94736844
&quot;서울20000번 공원 코스&quot;   0.94736844
&quot;서울50000번 공원 코스&quot;   0.94736844</code></pre><p><code>&lt;-&gt;</code>는 <code>1 - similarity(a, b)</code>로 계산됨. 즉 거리 = 1 - 유사도.</p>
<pre><code class="language-sql">SELECT similarity(&#39;대공원코스&#39;, &#39;서울5000번 공원 코스&#39;);</code></pre>
<p>결과:</p>
<pre><code>0.05263158</code></pre><p>(<code>1 - 0.94736844 = 0.05263156</code> — 반올림 오차 범위 내에서 정확히 일치함)</p>
<h2 id="22-gist-인덱스-타는지-확인">22. GiST 인덱스 타는지 확인</h2>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT name, name &lt;-&gt; &#39;대공원코스&#39; AS distance
FROM route
ORDER BY name &lt;-&gt; &#39;대공원코스&#39;
LIMIT 5;</code></pre>
<p>결과:</p>
<pre><code>&quot;Limit  (cost=0.28..0.72 rows=5 width=27) (actual time=12.772..13.100 rows=5.00 loops=1)&quot;
&quot;  Buffers: shared hit=500&quot;
&quot;  -&gt;  Index Scan using idx_route_name_gist on route  (cost=0.28..4452.42 rows=50007 width=27) (actual time=12.770..13.096 rows=5.00 loops=1)&quot;
&quot;        Order By: ((name)::text &lt;-&gt; &#39;대공원코스&#39;::text)&quot;
&quot;        Index Searches: 1&quot;
&quot;        Buffers: shared hit=500&quot;
&quot;Planning Time: 0.357 ms&quot;
&quot;Execution Time: 13.753 ms&quot;</code></pre><p><code>idx_route_name_gist</code> 인덱스 정확히 탐.</p>
<h2 id="23-gin-vs-gist-정리">23. GIN vs GiST 정리</h2>
<table>
<thead>
<tr>
<th></th>
<th>GIN</th>
<th>GiST</th>
</tr>
</thead>
<tbody><tr>
<td>인덱스 생성/쓰기 (INSERT, UPDATE)</td>
<td>빠름</td>
<td>상대적으로 느림</td>
</tr>
<tr>
<td>LIKE, % 같은 필터 검색</td>
<td>매우 빠름, 정확</td>
<td>상대적으로 느림 (근사치라 recheck 필요)</td>
</tr>
<tr>
<td>ORDER BY ... LIMIT (Top-K 정렬)</td>
<td>못 함 (전체 계산 필요)</td>
<td>빠름 (트리 타면서 가지치기)</td>
</tr>
</tbody></table>
<p>→ 정렬이 필요 없는 필터링만 필요하면 GIN, 유사도 순 정렬(Top-K)이 필요하면 GiST가 적합함. 실무에서는 두 인덱스를 같은 컬럼에 동시에 걸어두고, 쿼리 종류에 따라 옵티마이저가 알아서 골라 쓰게 하는 것도 가능함.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GIN(1)]]></title>
            <link>https://velog.io/@k_joon_/GIN1</link>
            <guid>https://velog.io/@k_joon_/GIN1</guid>
            <pubDate>Thu, 27 Aug 2026 19:06:21 GMT</pubDate>
            <description><![CDATA[<h1 id="gin--trigram-실습-노트">GIN + trigram 실습 노트</h1>
<h2 id="1-버전-확인">1. 버전 확인</h2>
<pre><code class="language-sql">SELECT version();</code></pre>
<p>결과:</p>
<pre><code>PostgreSQL 18.1 on x86_64-windows, compiled by msvc-19.44.35221, 64-bit</code></pre><h2 id="2-확장-설치-여부-확인">2. 확장 설치 여부 확인</h2>
<pre><code class="language-sql">SELECT * FROM pg_available_extensions WHERE name IN (&#39;pg_trgm&#39;, &#39;pg_bigm&#39;);</code></pre>
<p>결과:</p>
<pre><code>&quot;pg_trgm&quot;    &quot;1.6&quot;    [null]    &quot;text similarity measurement and index searching based on trigrams&quot;</code></pre><p>pg_trgm은 있는데 설치가 안 된 상황임. 설치해줌.</p>
<pre><code class="language-sql">CREATE EXTENSION pg_trgm;</code></pre>
<p>결과:</p>
<pre><code>Query returned successfully in 819 msec.</code></pre><h2 id="3-show_trgm으로-토큰-확인">3. show_trgm으로 토큰 확인</h2>
<pre><code class="language-sql">SELECT show_trgm(&#39;hello&#39;);
SELECT show_trgm(&#39;공원&#39;);</code></pre>
<p>결과:</p>
<pre><code>hello -&gt; {&quot;  h&quot;,&quot; he&quot;,ell,hel,llo,&quot;lo &quot;}
공원   -&gt; {0xbd6381,0xdce49d,0xf4d1c9}</code></pre><h2 id="4-로케일--인코딩-확인">4. 로케일 / 인코딩 확인</h2>
<pre><code class="language-sql">SELECT datname, datcollate, datctype FROM pg_database WHERE datname = current_database();</code></pre>
<p>결과:</p>
<pre><code>&quot;trgm_lab&quot;    &quot;Korean_Korea.949&quot;    &quot;Korean_Korea.949&quot;</code></pre><p>한글 로케일임. 로케일이란 정렬/문자 판단의 규칙이라고 볼 수 있음. 해당 글자를 기반으로 판단 규칙을 정하는 거라고 보면 됨.</p>
<pre><code class="language-sql">SHOW server_encoding;</code></pre>
<p>결과:</p>
<pre><code>UTF8</code></pre><p>인코딩은 그 글자를 컴퓨터 메모리/디스크에 실제로 몇 바이트, 어떤 비트 패턴으로 저장할 것인가임. 즉, 인코딩으로 저장되어 있는 걸 로케일의 규칙으로 어떻게 다룰지 보는 거임.</p>
<h2 id="5-실험-테이블-준비">5. 실험 테이블 준비</h2>
<p>이제 정말 테스트할 차례임.</p>
<blockquote>
<p><code>WHERE name LIKE &#39;%공원%&#39;</code>을 실행했을 때, PostgreSQL이 진짜로 인덱스를 이용해서 그 10개만 콕 집어낼 수 있는가?</p>
</blockquote>
<pre><code class="language-sql">CREATE TABLE route(
    route_id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    location VARCHAR(255),
    distance_km NUMERIC(5,2),
    description TEXT
);</code></pre>
<p>결과:</p>
<pre><code>CREATE TABLE
Query returned successfully in 204 msec.</code></pre><pre><code class="language-sql">INSERT INTO route(name)
SELECT
    CASE WHEN i % 5000 = 0 THEN &#39;서울&#39; || i || &#39;번 공원 코스&#39;
         ELSE &#39;테스트코스&#39; || i || &#39;번&#39; END
FROM generate_series(1, 50000) AS i;</code></pre>
<p>결과:</p>
<pre><code>INSERT 0 50000
Query returned successfully in 421 msec.</code></pre><h2 id="6-인덱스-없는-상태에서-먼저-확인">6. 인덱스 없는 상태에서 먼저 확인</h2>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM route WHERE name LIKE &#39;%공원%&#39;;</code></pre>
<p>결과:</p>
<pre><code>&quot;Seq Scan on route  (cost=0.00..993.00 rows=5 width=587) (actual time=0.734..6.919 rows=10.00 loops=1)&quot;
&quot;  Filter: ((name)::text ~~ &#39;%공원%&#39;::text)&quot;
&quot;  Rows Removed by Filter: 49990&quot;
&quot;  Buffers: shared hit=368&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=26&quot;
&quot;Planning Time: 4.045 ms&quot;
&quot;Execution Time: 6.950 ms&quot;</code></pre><p>풀스캔 났음. 예상대로임.</p>
<h2 id="7-gin-trigram-인덱스-생성-후-확인">7. GIN trigram 인덱스 생성 후 확인</h2>
<p>인덱스 만듦 (<code>idx_route_name_trgm</code>이라는 이름으로 인덱스 생성, GIN 방식으로 <code>name</code>을 <code>gin_trgm_ops</code> 규칙으로 쪼개서 넣겠다는 거임).</p>
<pre><code class="language-sql">CREATE INDEX idx_route_name_trgm
ON route USING gin (name gin_trgm_ops);</code></pre>
<p>결과:</p>
<pre><code>CREATE INDEX
Query returned successfully in 634 msec.</code></pre><p>다시 실행계획 확인:</p>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM route WHERE name LIKE &#39;%공원%&#39;;</code></pre>
<p>결과:</p>
<pre><code>&quot;Seq Scan on route  (cost=0.00..993.00 rows=5 width=587) (actual time=0.739..5.960 rows=10.00 loops=1)&quot;
&quot;  Filter: ((name)::text ~~ &#39;%공원%&#39;::text)&quot;
&quot;  Rows Removed by Filter: 49990&quot;
&quot;  Buffers: shared hit=368&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=25 dirtied=1&quot;
&quot;Planning Time: 4.475 ms&quot;
&quot;Execution Time: 6.022 ms&quot;</code></pre><p>엥? 인덱스를 만들었는데도 여전히 Seq Scan임. 옵티마이저가 왜 인덱스 안 타는 게 빠르다고 판단했는지 가설을 세워봄.</p>
<ol>
<li>데이터가 짧아서? (가장 유력)</li>
<li>한국어라서?</li>
</ol>
<h2 id="8-enable_seqscan-강제로-꺼서-확인">8. enable_seqscan 강제로 꺼서 확인</h2>
<p>무조건 인덱스를 쓰도록 강제해서 인덱스가 실제로 어떻게 동작하는지 봄.</p>
<pre><code class="language-sql">SET enable_seqscan = OFF;

EXPLAIN ANALYZE
SELECT * FROM route WHERE name LIKE &#39;%공원%&#39;;

SET enable_seqscan = ON;</code></pre>
<p>결과:</p>
<pre><code>&quot;Bitmap Heap Scan on route  (cost=11065.49..11083.80 rows=5 width=587) (actual time=11.270..19.633 rows=10.00 loops=1)&quot;
&quot;  Recheck Cond: ((name)::text ~~ &#39;%공원%&#39;::text)&quot;
&quot;  Rows Removed by Index Recheck: 49990&quot;
&quot;  Heap Blocks: exact=368&quot;
&quot;  Buffers: shared hit=517&quot;
&quot;  -&gt;  Bitmap Index Scan on idx_route_name_trgm  (cost=0.00..11065.49 rows=5 width=0) (actual time=10.606..10.606 rows=50000.00 loops=1)&quot;
&quot;        Index Cond: ((name)::text ~~ &#39;%공원%&#39;::text)&quot;
&quot;        Index Searches: 1&quot;
&quot;        Buffers: shared hit=149&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=1&quot;
&quot;Planning Time: 0.231 ms&quot;
&quot;Execution Time: 19.677 ms&quot;</code></pre><p>성능이 안 좋음. <code>Bitmap Index Scan on idx_route_name_trgm</code>에서 후보로 <strong>50000행을 다 올려버림</strong> (<code>rows=50000.00</code>). 그 뒤 <code>Rows Removed by Index Recheck: 49990</code>으로 제거함. 즉, 5만 건 중에 아무것도 걸러내지 못하고 결국 다 후보로 올려버린 거임. 옵티마이저가 인덱스 대신 Seq Scan을 고른 이유가 납득됨 — 이렇게 쓸 거면 인덱스 탈 이유가 없음.</p>
<blockquote>
<p>원래는 &quot;데이터가 짧아서(공원이면 세 개밖에 토큰이 안 만들어져서)&quot;라고 생각했는데, 이건 틀린 이유임. 뒤에서 확인하지만 진짜 이유는 개수가 적어서가 아니라, 그 세 개 토큰(<code>&quot;  공&quot;</code>, <code>&quot; 공원&quot;</code>, <code>&quot;공원 &quot;</code>)이 전부 <strong>공백이 낀 조각</strong>이라서임.</p>
</blockquote>
<h2 id="9-한국어라서-그런가-영어로-검증">9. 한국어라서 그런가? 영어로 검증</h2>
<pre><code class="language-sql">INSERT INTO route (name)
SELECT &#39;ab-item-&#39; || i FROM generate_series(1,5) AS i;</code></pre>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM route WHERE name LIKE &#39;%ab%&#39;;</code></pre>
<p>결과:</p>
<pre><code>&quot;Bitmap Heap Scan on route  (cost=11147.88..11166.20 rows=5 width=587) (actual time=22.237..22.240 rows=5.00 loops=1)&quot;
&quot;  Recheck Cond: ((name)::text ~~ &#39;%ab%&#39;::text)&quot;
&quot;  Rows Removed by Index Recheck: 50000&quot;
&quot;  Heap Blocks: exact=368&quot;
&quot;  Buffers: shared hit=518&quot;
&quot;  -&gt;  Bitmap Index Scan on idx_route_name_trgm  (cost=0.00..11147.88 rows=5 width=0) (actual time=11.344..11.345 rows=50005.00 loops=1)&quot;
&quot;        Index Cond: ((name)::text ~~ &#39;%ab%&#39;::text)&quot;
&quot;        Index Searches: 1&quot;
&quot;        Buffers: shared hit=150&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=1&quot;
&quot;Planning Time: 0.201 ms&quot;
&quot;Execution Time: 22.277 ms&quot;</code></pre><p>언어 문제 아님. 영어도 똑같이 다 올려버림 (50005개).</p>
<h2 id="10-검색어를-길게-하면-어떻게-되나">10. 검색어를 길게 하면 어떻게 되나</h2>
<pre><code class="language-sql">UPDATE route SET name = &#39;서울대공원코스&#39; WHERE route_id = 1;</code></pre>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM route WHERE name LIKE &#39;%대공원코%&#39;;</code></pre>
<p>결과:</p>
<pre><code>&quot;Bitmap Heap Scan on route  (cost=25.77..44.08 rows=5 width=587) (actual time=0.099..0.100 rows=1.00 loops=1)&quot;
&quot;  Recheck Cond: ((name)::text ~~ &#39;%대공원코%&#39;::text)&quot;
&quot;  Heap Blocks: exact=1&quot;
&quot;  Buffers: shared hit=7&quot;
&quot;  -&gt;  Bitmap Index Scan on idx_route_name_trgm  (cost=0.00..25.76 rows=5 width=0) (actual time=0.044..0.044 rows=1.00 loops=1)&quot;
&quot;        Index Cond: ((name)::text ~~ &#39;%대공원코%&#39;::text)&quot;
&quot;        Index Searches: 1&quot;
&quot;        Buffers: shared hit=6&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=1&quot;
&quot;Planning Time: 0.231 ms&quot;
&quot;Execution Time: 0.155 ms&quot;</code></pre><p>후보가 1개. 정확히 찾아냄. 속도도 매우 빠름. 한글이 안 먹힌 것도 아니고 정상 작동함.</p>
<p><code>&quot;대공원코&quot;</code> -&gt; <code>&quot;  대&quot;</code> / <code>&quot; 대공&quot;</code> / <code>&quot;대공원&quot;</code> / <code>&quot;공원코&quot;</code> / <code>&quot;원코 &quot;</code> 이러면 5개 토큰이고, 아까 <code>&quot;공원&quot;</code>은 3개 토큰이었음.</p>
<h2 id="11-왜-그런지-파보기--공백-패딩-가설">11. 왜 그런지 파보기 — 공백 패딩 가설</h2>
<p>공원은 공백이 계속 껴있음. 그래서 필터링이 다 안 되는 거 아닐까 싶었음. 만약 <code>&quot;하늘 공원 가자&quot;</code>처럼 진짜 공백이 낀 데이터가 있으면 잡히지 않을까 하는 가설을 세워봄.</p>
<p>근데 이것도 좀 이상한 게, 옵티마이저가 데이터에 공백이 있는지 없는지 미리 알고 구분할 수는 없음. 공백이 올 수도 있고 문자열 맨 처음일 수도 있으니까. 그래서 <code>&quot; 공원&quot;</code> 조각 자체가 아예 필수 조건에서 빠져버릴 가능성이 높다고 예상함.</p>
<p>테스트해봄:</p>
<pre><code class="language-sql">INSERT INTO route (name) VALUES (&#39;하늘 공원 가자&#39;);
INSERT INTO route (name) VALUES (&#39;테스트공원목장&#39;);</code></pre>
<pre><code class="language-sql">EXPLAIN ANALYZE
SELECT * FROM route WHERE name LIKE &#39;%공원%&#39;;</code></pre>
<p>결과:</p>
<pre><code>&quot;Bitmap Heap Scan on route  (cost=11147.88..11166.20 rows=5 width=587) (actual time=11.493..18.817 rows=13.00 loops=1)&quot;
&quot;  Recheck Cond: ((name)::text ~~ &#39;%공원%&#39;::text)&quot;
&quot;  Rows Removed by Index Recheck: 49994&quot;
&quot;  Heap Blocks: exact=368&quot;
&quot;  Buffers: shared hit=518 dirtied=1&quot;
&quot;  -&gt;  Bitmap Index Scan on idx_route_name_trgm  (cost=0.00..11147.88 rows=5 width=0) (actual time=10.776..10.776 rows=50008.00 loops=1)&quot;
&quot;        Index Cond: ((name)::text ~~ &#39;%공원%&#39;::text)&quot;
&quot;        Index Searches: 1&quot;
&quot;        Buffers: shared hit=150&quot;
&quot;Planning:&quot;
&quot;  Buffers: shared hit=1&quot;
&quot;Planning Time: 0.271 ms&quot;
&quot;Execution Time: 18.864 ms&quot;</code></pre><p>가설대로 진짜 <code>&quot; 공원&quot;</code> 형태의 데이터를 넣었는데도 50008행을 다 후보로 올려버림. 가설 기각임.</p>
<p>즉 데이터 안에 그 트라이그램이 &quot;존재하냐 안 하냐&quot;가 아니라, PostgreSQL이 검색조건(<code>LIKE &#39;%공원%&#39;</code>)을 trigram 필터로 바꾸는 시점에 이미 결정되는 거임. 2글자로는 순수 토큰이 안 나오는 듯함 (정확히는 &quot;2글자냐 아니냐&quot;가 아니라 &quot;패딩 없는 순수 토큰이 하나라도 나오냐&quot;가 기준임 — <code>&quot;공원&quot;</code>은 2글자라 애초에 순수 토큰이 안 나오는 케이스였던 거).</p>
<h2 id="12-정리--왜-이런-현상이-생기는가">12. 정리 — 왜 이런 현상이 생기는가</h2>
<p><strong>1단계 (검색어만 보고 판단, 데이터 안 봄)</strong></p>
<p>검색어 <code>&#39;공원&#39;</code>을 trigram으로 쪼갬 → <code>&quot;  공&quot;</code>, <code>&quot; 공원&quot;</code>, <code>&quot;공원 &quot;</code> 나옴 → 근데 이 셋 다 공백(위치 마커)이 껴 있어서, &quot;이 조각이 데이터 안에 무조건 있어야 한다&quot;고 확신할 수 있는 게 하나도 없음 → &quot;이 검색은 인덱스로 확실하게 걸러낼 키가 없다&quot;고 결론 남.</p>
<p><strong>2단계 (실제 인덱스/데이터 조회)</strong></p>
<p>1단계에서 &quot;확실한 필터 키가 없다&quot;는 결론이 났으니, 인덱스는 그냥 &quot;다 후보로 줄게, 대신 진짜 맞는지는 네가(PostgreSQL 실행기가) 하나씩 다시 확인해&quot;라는 식으로 동작함 → 그래서 <code>rows=50008</code>처럼 전체가 다 나온 거임.</p>
<p>핵심은 1단계가 데이터를 전혀 안 보고, 오직 <code>&#39;공원&#39;</code>이라는 검색어 텍스트 하나만 가지고 이루어진다는 거임. 그러니까 <code>&quot;하늘 공원 가자&quot;</code>처럼 실제로 딱 맞는 공백 패턴을 가진 데이터가 테이블 안에 들어있어도, 1단계에서 이미 &quot;이 검색어로는 확실한 필터를 못 만든다&quot;고 결론이 나버렸기 때문에, 그 데이터의 존재 여부가 결과에 영향을 줄 수 없는 거임.</p>
]]></description>
        </item>
        <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>
    </channel>
</rss>