<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://gary5876.github.io/portfolio/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gary5876.github.io/portfolio/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-10-10T14:36:11+00:00</updated><id>https://gary5876.github.io/portfolio/feed.xml</id><title type="html">고준서</title><subtitle>백엔드 개발자 고준서. 카카오테크캠퍼스 백엔드 과정 수료, 대학 동아리 지원 서비스 동아리움을 팀에서 10개월 운영하며 통계, 공지, 이메일 알림 전담. 개인 프로젝트로 LLM 학습 서비스와 게임 AI. 코딩 에이전트가 만든 코드를 테스트와 통계로 검증하며 일합니다.
</subtitle><author><name>고준서</name><email>jerry0622@naver.com</email></author><entry><title type="html">훅은 확정이고 프롬프트는 확률이라는데, 훅도 깨집니다</title><link href="https://gary5876.github.io/portfolio/blog/2026/09/05/hooks-are-deterministic-until-they-break/" rel="alternate" type="text/html" title="훅은 확정이고 프롬프트는 확률이라는데, 훅도 깨집니다" /><published>2026-09-05T00:00:00+00:00</published><updated>2026-09-05T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/09/05/hooks-are-deterministic-until-they-break</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/09/05/hooks-are-deterministic-until-they-break/"><![CDATA[<p>안녕하세요, 고준서입니다. agent-research는 “내 분야에 전문성이 있는 AI 에이전트를 어떻게 만드나”를 정리하는 개인 연구 레포예요. 9월 5일에 그 레포에서 문서에 적어만 두던 규칙 하나(“생성된 위키는 손으로 고치지 않는다”)를, 파일 편집 직전에 경로를 검사해 거부하는 훅으로 바꿨습니다. 그리고 그 훅이 같은 날 깨졌어요. 설정에 적힌 스크립트 경로가 상대경로라서, 직전 셸 명령이 다른 디렉터리로 옮겨 간 상태에서는 훅이 스크립트를 못 찾았습니다. 훅은 실행됐는데 실행돼서 실패한 겁니다.</p>

<p>계기는 그날 아침 카카오페이 기술 블로그의 AI 에이전트 글을 Claude Code에게 읽힌 일이었습니다. 거기 나온 원칙 하나가 “훅 실행은 보장되고 프롬프트는 확률”이었어요. 훅이라는 건 AI가 어떤 도구를 쓰기 직전이나 직후에 무조건 실행되는 작은 프로그램입니다. 프롬프트에 “이건 하지 마”라고 적어 두면 AI가 그 지시를 지킬지는 매번 확률의 문제지만, 훅은 조건에 걸리면 예외 없이 막습니다.</p>

<p>이 글에는 왜 그 규칙을 훅으로 바꿨는지, 훅이 어떻게 깨졌고 어떻게 고쳤는지, 그리고 고친 방식의 대안을 비교하지 않았다는 것까지 레포의 07 문서와 커밋으로 적습니다. 훅이 실제 작업 중에 편집을 막은 횟수는 세지 않았어요. 기록에 있는 차단은 검증하려고 일부러 시도한 것뿐입니다.</p>

<h2 id="약속-하나를-차단으로-바꾸기">약속 하나를 차단으로 바꾸기</h2>

<p>이 레포에는 OpenWiki라는 도구가 소스 문서를 읽어 자동으로 만든 위키가 있습니다. 그 위키를 손으로 고치면 다음 생성 때 덮어써지니까, “생성된 위키는 손으로 고치지 않는다”는 규칙을 문서에 적어 두고 있었어요. 적어만 둔 규칙이었죠. 문서에 적힌 규칙은 다섯 개였는데, 왜 이 규칙을 먼저 훅으로 옮겼는지는 07에 이유가 없습니다. 지금 보면 다섯 중 “경로만 보면 판정이 끝나는” 유일한 규칙이긴 한데, 그건 당시 기록이 아니라 지금의 설명이에요.</p>

<p>Claude가 이 규칙을 <code class="language-plaintext highlighter-rouge">PreToolUse</code> 훅으로 옮겼습니다. 파일을 편집하는 도구가 호출되기 직전에 스크립트가 경로를 보고, 생성물 경로면 거부하는 구조예요.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .claude/hooks/block-openwiki-generated-edits.py
</span><span class="n">GENERATED_PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="sa">r</span><span class="sh">"</span><span class="s">(^|/)openwiki/\.claims/</span><span class="sh">"</span><span class="p">,</span>
    <span class="sa">r</span><span class="sh">"</span><span class="s">(^|/)openwiki/.*\.md$</span><span class="sh">"</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="k">if</span> <span class="nf">any</span><span class="p">(</span><span class="n">re</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">file_path</span><span class="p">)</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">GENERATED_PATTERNS</span><span class="p">):</span>
        <span class="n">reason</span> <span class="o">=</span> <span class="p">(</span>
            <span class="sa">f</span><span class="sh">"</span><span class="s">Blocked: </span><span class="sh">'</span><span class="si">{</span><span class="n">file_path</span><span class="si">}</span><span class="sh">'</span><span class="s"> is OpenWiki-generated content. </span><span class="sh">"</span>
            <span class="sh">"</span><span class="s">Never hand-edit openwiki/*.md or openwiki/.claims/*.json </span><span class="sh">"</span>
            <span class="sh">"</span><span class="s">(agent-expertise-framework/04-purpose-and-self-improvement-loop.md §5). </span><span class="sh">"</span>
            <span class="sh">"</span><span class="s">Edit the source doc in agent-expertise-framework/ instead and let OpenWiki regenerate.</span><span class="sh">"</span>
        <span class="p">)</span>
        <span class="nf">print</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span>
            <span class="sh">"</span><span class="s">hookSpecificOutput</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">hookEventName</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">PreToolUse</span><span class="sh">"</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">permissionDecision</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">deny</span><span class="sh">"</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">permissionDecisionReason</span><span class="sh">"</span><span class="p">:</span> <span class="n">reason</span><span class="p">,</span>
            <span class="p">}</span>
        <span class="p">}))</span>
</code></pre></div></div>

<p>문서 07의 실행 기록에 따르면 확인은 세 단계로 했습니다. 가짜 입력을 파이프로 넣어 차단 응답이 나오는지, 실제 편집 도구로 위키 파일을 고치려 했을 때 막히는지(막힌 뒤 파일이 안 바뀐 것을 grep으로 재확인), 그리고 소스 문서 편집은 정상 통과하는지. 처음엔 개인 설정 파일(<code class="language-plaintext highlighter-rouge">settings.local.json</code>, 커밋되지 않음)에 넣었다가 “이 레포에서 작업하는 누구에게나 적용돼야 하는 규칙”이라는 이유로 팀 공유 설정(<code class="language-plaintext highlighter-rouge">settings.json</code>)으로 옮겼다는 것도 거기 적혀 있어요.</p>

<p>같은 세션 초반에 다른 일로 제가 이런 말을 한 적이 있습니다. AI가 비슷한 계정 이름 둘을 같은 계정으로 넘겨짚었을 때였어요.</p>

<blockquote>
  <p>문자열이 다르다는걸 잘 인지했으면 문제 업었을 것 같은데, 그 부분을 툴로 만들어</p>
</blockquote>

<p>“조심하겠다”가 아니라 도구로 막으라는 요구였고, 훅은 그 방향의 첫 구현이었습니다.</p>

<h2 id="그-훅이-같은-날-깨졌습니다">그 훅이 같은 날 깨졌습니다</h2>

<p>훅을 넣고 몇 시간 뒤, 같은 문서(07)에 다른 검증 결과를 적으려고 편집 도구를 쓰자 이런 오류가 났습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3: can't open file '.../.claude/skills/openwiki/.claude/hooks/block-openwiki-generated-edits.py'
</code></pre></div></div>

<p>설정 파일의 명령이 <code class="language-plaintext highlighter-rouge">python3 .claude/hooks/...</code>처럼 상대경로였는데, 직전에 실행한 셸 명령에서 다른 디렉터리로 <code class="language-plaintext highlighter-rouge">cd</code>한 상태가 이어져서 훅이 저장소 루트가 아닌 엉뚱한 곳에서 스크립트를 찾은 거예요. 훅은 “무조건 실행”되긴 했습니다. 실행돼서 실패했을 뿐이죠.</p>

<p>고친 건 명령 앞에 저장소 루트로 이동하는 한 줄이었습니다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cd </span><span class="se">\"</span><span class="s2">$(git rev-parse --show-toplevel)</span><span class="se">\"</span><span class="s2"> &amp;&amp; python3 .claude/hooks/block-openwiki-generated-edits.py"</span><span class="w">
</span></code></pre></div></div>

<p>일부러 저장소 밖 디렉터리에 서서 다시 편집을 시도해 차단되는 것까지 확인했다고 07에 적혀 있습니다.</p>

<p>이 방식 말고도 길은 있었습니다. 스크립트를 절대경로로 적거나, 훅 실행 환경이 주는 프로젝트 경로 변수를 쓰는 방법이요. 어느 쪽이 나은지는 비교하지 않았고, 07에도 비교 기록이 없습니다. <code class="language-plaintext highlighter-rouge">git rev-parse</code>는 저장소 안에서만 답을 주니 저장소 밖에서 훅이 돌면 이 한 줄도 실패하는데, 그 경우가 실제로 생기는지는 확인하지 않았어요.</p>

<p>문서에는 이 사건의 의미를 이렇게 정리해 뒀습니다. “‘구조적으로 불가능’을 다시 ‘프롬프트 수준의 우연’으로 되돌릴 수 있다는 걸 실제로 보여준 사례.” 이번엔 훅이 시끄럽게 실패해서(오류 메시지가 남아서) 바로 잡혔지만, 조용히 통과시키는 방향으로 깨졌다면 규칙은 다시 약속으로 돌아가 있었을 겁니다. 조용히 깨지는 경우를 잡는 장치는 지금도 없어요.</p>

<h2 id="남은-것">남은 것</h2>

<p>이 훅과 문서는 Claude가 쓰고 제가 검토해 9월 5일 커밋(<a href="https://github.com/gary5876/agent-research/commit/a15f7b9">a15f7b9</a>)으로 올렸습니다. 문서에 적어 둔 규칙 다섯 개 중 훅으로 바뀐 건 하나뿐이고, 나머지 넷이 훅으로 바꿀 수 있는 종류인지는 아직 분류하지 않았습니다. 그건 07의 열린 질문 목록에 그대로 남아 있어요.</p>

<p>이 하루에서 지금도 쓰는 기준은 이렇습니다.</p>

<ol>
  <li>훅은 프롬프트보다 확실하지만, 훅 자체가 안 깨졌을 때만 그렇습니다. 훅을 넣으면 훅이 깨지는 경우도 같이 시험해야 합니다.</li>
  <li>훅이 깨질 때는 시끄럽게 깨져야 합니다. 조용히 통과시키는 실패는 규칙을 없애는 것과 같습니다.</li>
  <li>훅 명령은 실행 시점의 현재 디렉터리를 믿으면 안 됩니다. 이번엔 저장소 루트로 먼저 이동하는 것으로 막았고, 다른 방법과는 비교하지 않았습니다.</li>
</ol>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="agent-research" /><category term="ax" /><category term="coding-agents" /><category term="hooks" /><summary type="html"><![CDATA[AI에게 지키라고 적어둔 규칙 하나를 프롬프트 대신 훅으로 강제하기로 한 결정, 그 훅이 같은 날 상대경로 때문에 깨진 일, 그리고 비교하지 않은 것]]></summary></entry><entry><title type="html">AI가 쓴 문장마다 근거가 살아 있는지 기계로 세기</title><link href="https://gary5876.github.io/portfolio/blog/2026/09/02/grounding-metric-claims/" rel="alternate" type="text/html" title="AI가 쓴 문장마다 근거가 살아 있는지 기계로 세기" /><published>2026-09-02T00:00:00+00:00</published><updated>2026-09-02T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/09/02/grounding-metric-claims</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/09/02/grounding-metric-claims/"><![CDATA[<p>안녕하세요, 고준서입니다. agent-research는 “내 분야에 전문성이 있는 AI 에이전트”를 만들어 보려고 9월 초에 파고 있던 개인 연구 레포예요. 9월 2일 오전, 설계를 시작하기 전에 레퍼런스부터 모으라고 시키고 있었습니다.</p>

<blockquote>
  <p>레퍼런스확보부터 마치고 테스트환경 만들어</p>
</blockquote>

<blockquote>
  <p>전체 파이프라인 어떻게 할지 4가지 정도 확보하고, 레퍼런스 갈아끼워가면서 테스트 할 수 있게 환경마련 후 계획세우기</p>
</blockquote>

<p>그날 오후에 나온 것이 <code class="language-plaintext highlighter-rouge">eval/</code> 디렉터리입니다. 설계 문서에는 성공 기준이 글로만 적혀 있었는데, 그중 두 조각을 실제로 돌아가는 코드로 옮긴 거예요. 이 글은 그 두 조각이 뭘 재고 뭘 못 재는지의 기록입니다.</p>

<h2 id="첫-번째-조각-사실-주장이-소스로-추적되는가">첫 번째 조각: 사실 주장이 소스로 추적되는가</h2>

<p>설계 문서의 성공 기준 중 하나는 “응답의 사실 주장 각각이 특정 소스로 추적 가능한 비율이 100%에 가까워야 한다”였습니다. 이 레포의 위키는 OpenWiki라는 도구가 만드는데, 위키의 문장마다 어느 소스 파일 몇 번째 줄에서 왔는지가 <code class="language-plaintext highlighter-rouge">repo://경로#L시작-L끝</code> 형태로 붙어 있어요. 이걸 Claim이라고 부릅니다.</p>

<p>그 기준을 그대로 채점 코드로 옮긴 것이 <code class="language-plaintext highlighter-rouge">grounding_metric.py</code>예요. 평가 프레임워크(DeepEval)의 커스텀 지표 하나인데, 하는 일은 단순합니다. 가리키는 파일이 아직 있는지, 줄 범위가 파일 안에 있는지, 그 줄들이 비어 있지 않은지.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># agent-expertise-framework/02-self-improvement-loop/eval/grounding_metric.py:43-54
</span>        <span class="k">if</span> <span class="ow">not</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">isfile</span><span class="p">(</span><span class="n">full_path</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="nf">_fail</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">source file no longer exists: </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">full_path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">"</span><span class="s">utf-8</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">lines</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="nf">readlines</span><span class="p">()</span>

        <span class="k">if</span> <span class="n">start</span> <span class="o">&lt;</span> <span class="mi">1</span> <span class="ow">or</span> <span class="n">end</span> <span class="o">&gt;</span> <span class="nf">len</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span> <span class="ow">or</span> <span class="n">start</span> <span class="o">&gt;</span> <span class="n">end</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="nf">_fail</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">L</span><span class="si">{</span><span class="n">start</span><span class="si">}</span><span class="s">-L</span><span class="si">{</span><span class="n">end</span><span class="si">}</span><span class="s"> out of bounds (file has </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">lines</span><span class="p">)</span><span class="si">}</span><span class="s"> lines)</span><span class="sh">"</span><span class="p">)</span>

        <span class="n">selected</span> <span class="o">=</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">lines</span><span class="p">[</span><span class="n">start</span> <span class="o">-</span> <span class="mi">1</span> <span class="p">:</span> <span class="n">end</span><span class="p">]).</span><span class="nf">strip</span><span class="p">()</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">selected</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="nf">_fail</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">L</span><span class="si">{</span><span class="n">start</span><span class="si">}</span><span class="s">-L</span><span class="si">{</span><span class="n">end</span><span class="si">}</span><span class="s"> of </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s"> is empty</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>LLM에게 “이 문장이 근거가 있나요”라고 묻는 채점이 아니에요. 파일 시스템만 봅니다. 그래서 API 키가 필요 없고, 같은 입력에는 항상 같은 점수가 나옵니다.</p>

<p>대상은 합성 데이터가 아니라 이 레포의 실제 위키가 가진 Claim 30개 전부였고, 30개 모두 통과했습니다. 반대로 존재하지 않는 줄 번호나 파일을 일부러 넣으면 정확히 0.0으로 떨어지는 것도 따로 확인했다고 README에 적혀 있어요. 통과만 확인하면 “항상 1.0을 주는 지표”와 구분이 안 되니까요.</p>

<h2 id="두-번째-조각-비밀-값-필터를-갈아-끼우며-비교하기">두 번째 조각: 비밀 값 필터를 갈아 끼우며 비교하기</h2>

<p>같은 날 실제로 비밀 값이 대화에 노출된 일이 있었습니다. 경위는 여기 적지 않겠습니다. 그 일 때문에 “응답에 비밀 값이 섞이면 걸러내는 필터”가 필요해졌고, 제가 시킨 대로 후보를 갈아 끼우며 비교할 수 있는 구조로 만들었어요. <code class="language-plaintext highlighter-rouge">ResponseFilter</code>라는 인터페이스 하나에 백엔드를 꽂고, 라벨이 붙은 합성 케이스 5개로 재현율과 오탐을 비교하는 방식입니다. 테스트에 쓴 키는 전부 가짜예요.</p>

<p>꽂은 백엔드는 둘이었습니다. 하나는 의존성 없는 정규식 필터인데, 일부러 얕게 만들었어요. 파일 주석에 이렇게 적혀 있습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># agent-expertise-framework/02-self-improvement-loop/eval/filters/baseline_filter.py:1-6
</span><span class="sh">"""</span><span class="s">Dependency-free regex-only filter — the </span><span class="sh">"</span><span class="s">naive first thing someone writes
after one incident</span><span class="sh">"</span><span class="s"> baseline (ADR-3</span><span class="sh">'</span><span class="s">s original </span><span class="sh">"</span><span class="s">커스텀 regex</span><span class="sh">"</span><span class="s"> alternative that
was rejected in favor of Presidio+NeMo Guardrails+PurpleLlama+portkey-ai/gateway).
Deliberately only covers what the session</span><span class="sh">'</span><span class="s">s one real incident (PEM private key
exposure) and a textbook email regex would catch — no AWS-key pattern, no NER.
The comparison harness (compare_filters.py) is what makes that gap visible.</span><span class="sh">"""</span>
</code></pre></div></div>

<p>다른 하나는 Microsoft의 개인정보 탐지 라이브러리 Presidio예요. 결과는 정규식 필터가 재현율 0.75(4건 중 3건, AWS 키 케이스를 놓침), Presidio가 1.00(4건 중 4건)이었고, 무해한 영문과 한글 텍스트에서 오탐은 둘 다 0이었습니다.</p>

<table>
  <thead>
    <tr>
      <th>백엔드</th>
      <th>재현율</th>
      <th>오탐</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>정규식만 (PEM 키, 이메일)</td>
      <td>0.75 (3/4), AWS 키 놓침</td>
      <td>0</td>
    </tr>
    <tr>
      <td>Presidio</td>
      <td>1.00 (4/4)</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p><em>표 1. 2026-09-02 실행 결과. 합성 케이스 5개.</em></p>

<p>케이스 5개는 이랬습니다. 가짜 서비스 계정 JSON(PEM 키와 이메일이 같이 있는 것), 가짜 AWS 액세스 키 한 줄, 이메일만 있는 문장, 비밀 값이 없는 영문 문장, 비밀 값이 없는 한글 문장. 오탐 0은 뒤의 두 무해 케이스에서 아무것도 안 잡혔다는 뜻이에요.</p>

<p>정규식 필터가 AWS 키를 놓친 건 우연이 아니라 설계예요. 사건 하나를 겪은 사람이 그 사건만 막으려고 짜는 필터가 딱 그 모양이고, 비교 하네스가 있어야 그 구멍이 숫자로 보입니다.</p>

<h2 id="못-재는-것">못 재는 것</h2>

<p>README의 “아직 안 한 것” 절이 이 글의 나머지 반입니다.</p>

<p>첫 번째 지표는 “파일과 줄이 지금 존재하는가”만 봅니다. OpenWiki는 줄 내용의 해시까지 기록해서 내용이 바뀌었는지도 잡는데, 이 지표는 그걸 재구현하지 않았어요. 줄 번호는 그대로인데 내용이 바뀐 경우를 못 잡습니다.</p>

<p>필터 비교도 후보 둘뿐이에요. 문서에 적어 둔 다른 후보들(NeMo Guardrails, PurpleLlama, Guardrails AI, 게이트웨이)은 GPU나 API 키가 필요해서 아직 안 꽂았습니다. 인터페이스만 구현하면 목록에 추가하는 것으로 끝나게 만들어 두긴 했지만, 만들어 둔 것과 돌려 본 것은 다르죠.</p>

<p>그리고 설계 문서의 루프는 여섯 단계인데 지금 있는 건 그중 재료 두 조각입니다. README는 그걸 “루프의 ②부터 ⑥, 배포 전 게이트, 배포 후 모니터링, 원인 라우팅, 재검증은 전혀 연결 안 됐다, 지금 있는 건 루프의 재료 중 두 조각뿐이다”라고 적어 뒀어요. 두 지표 다 위키를 실제로 다시 쓰는 자리에 물려 있지 않다는 뜻이라, 지금까지 잡은 건 일부러 망가뜨려 넣은 테스트 케이스뿐입니다. 실제 위키 생성 과정에서 이 지표가 뭔가를 실제로 걸러낸 사례는 아직 없어요.</p>

<p>이틀 뒤에 제가 다시 물은 건 이거였어요.</p>

<blockquote>
  <p>참고할 자료도 없고, 테스트해본 데이터도 없고 이말이이지?</p>
</blockquote>

<p>코드와 README는 Claude가 쓰고 제가 검토해 9월 5일 커밋(<a href="https://github.com/gary5876/agent-research/commit/a15f7b9">a15f7b9</a>)에 함께 올렸습니다. 실행 결과 숫자는 그날 README에 적힌 그대로예요.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="agent-research" /><category term="ax" /><category term="evaluation" /><category term="grounding" /><summary type="html"><![CDATA[위키의 사실 주장 30개가 소스 파일과 줄 번호로 지금도 추적되는지를 LLM 없이 채점하는 지표와, 비밀 값 필터 두 개를 같은 하네스에서 비교한 기록]]></summary></entry><entry><title type="html">통계 캐시: “스케줄러 필수”라고 써놓고 cache-aside로 뒤집은 이유</title><link href="https://gary5876.github.io/portfolio/blog/2026/08/18/statistics-cache-aside/" rel="alternate" type="text/html" title="통계 캐시: “스케줄러 필수”라고 써놓고 cache-aside로 뒤집은 이유" /><published>2026-08-18T00:00:00+00:00</published><updated>2026-08-18T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/08/18/statistics-cache-aside</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/08/18/statistics-cache-aside/"><![CDATA[<p>안녕하세요, 고준서입니다. 동아리움이라는 대학 동아리 지원 서비스의 백엔드를 팀(백엔드 3인, 프론트엔드 3인)에서 맡고 있고, 지원자 통계 기능은 제가 전담했어요. 지원폼마다 성별, 학부, 학번, 일자별 지원 추이를 집계해서 로그인 안 한 사람도 볼 수 있게 하는 API입니다. 로그인이 없으니 조회 부하를 막을 장치가 캐시뿐이었고, 설계 이슈에는 “스케줄러가 미리 계산해 두는 캐시는 선택이 아니라 필수”라고 적혀 있었습니다. 그런데 8월 11일 새벽에 그걸 뒤집었어요. 스케줄러 없이, 요청이 올 때 저장값이 있으면 쓰고 없으면 그 자리에서 계산해 30분 저장하는 방식(cache-aside)으로요. 이유는 넷이었습니다. 아무도 안 보는 폼은 계산 비용이 0이 되고, 서버가 늘어도 분산 락이 필요 없고, 스케줄러 방식도 결국 요청 시점 계산을 품고 있어서 두 방식이 포함 관계이고, Redis가 죽었을 때 낡은 값 대신 실시간 계산으로 넘어갈 수 있다는 것.</p>

<p>이 글은 그 넷이 어떻게 나왔는지, 이슈의 “필수”를 뒤집을 때 사고 이력을 근거로 삼지 않은 이유, 그리고 TTL과 장애 처리에서 제가 되물은 것을 이슈와 PR, 그날 대화 기록으로 따라갑니다. 조회량이나 지원폼 수, 집계 쿼리 시간 같은 숫자는 이 글에 없어요. 그때 재지 않았고, 규모상 괜찮다는 판단만 있었습니다. 코드 초안과 테스트 골격은 Claude Code가 쓰고 저는 PR을 읽고 되묻고 머지하는 쪽이었어요. 이 결정에 닿기 전에 통계 전체를 한 덩어리로 담은 PR(#336, 50개 파일 4,121줄)을 닫고 관심사별로 14개로 쪼갠 일이 있었는데, 그건 이 결정과 별개라 여기서는 “스케줄러 항목이 그 과정에서 맨 뒤로 밀렸다”까지만 적습니다.</p>

<h2 id="이슈에는-필수라고-적혀-있었습니다">이슈에는 “필수”라고 적혀 있었습니다</h2>

<p>설계 이슈(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/issues/335">#335</a>)의 조회 부하 대책은 이랬습니다.</p>

<blockquote>
  <p>비로그인 조회를 허용하고 별도 조회 빈도 제한을 두지 않으므로, 사전 계산 캐시가 유일한 부하 방어 수단이다. 7번 단계는 선택이 아니라 필수다.</p>
</blockquote>

<p>스케줄러가 10분마다 미리 집계해서 저장해 두고, 조회는 그 저장값만 읽게 하자는 뜻입니다. 8월 10일 밤에 나머지 PR을 다 머지하고 나니 남은 게 이 항목이었고, Claude가 두 가지 안을 내놓았어요. A는 스케줄러 없는 cache-aside, B는 이슈에 적힌 대로 스케줄러가 주기적으로 미리 계산하는 방식. 그러면서 3월에 팀에서 스케줄러가 정상 이미지를 지운 사고(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/commit/38c7f875">핫픽스 커밋</a>)가 있었으니 A를 권한다고 했습니다.</p>

<p>8월 11일 0시 1분에 제가 답했어요.</p>

<blockquote>
  <p>근데 구조적으로도 a가 더 적합해보이는데</p>
</blockquote>

<p>그리고 Claude가 바로 A로 진행하려는 걸 끊었습니다.</p>

<blockquote>
  <p>아니아니 그냥 진짜 둘을 비교해봐. 그렇지않아? 아닌가? 사고이력이든 뭐든 잊고 진짜 시스템설계 입장에서 봐봐</p>
</blockquote>

<p>사고가 났으니까 피하자는 건 이유가 아니라고 생각했거든요. 그 사고는 이미지 정리 스케줄러의 조건문 문제였지 스케줄러라는 방식의 문제가 아니었고, 그걸 근거로 삼으면 다음에 스케줄러가 맞는 자리에서도 피하게 됩니다. 그래서 나온 비교가 첫 문단의 넷입니다. 스케줄러는 아무도 안 보는 지원폼까지 10분마다 계산하는데, 요청 시점에 계산하면 안 보는 폼의 비용은 0이다. 서버가 두 대면 스케줄러는 인스턴스마다 돌아서 분산 락을 또 얹어야 한다. 스케줄러 방식도 결국 저장값이 없는 순간엔 요청 시점 계산이 필요하니 B는 A에 스케줄러를 더한 것이다. 그리고 Redis가 죽었을 때 A는 실시간 계산으로 넘어가면 그만이지만 B는 저장값이 낡은 채로 남는다. 이슈가 걱정했던 “만료 순간에 요청이 몰려 집계 쿼리가 한꺼번에 나가는 문제”는 단일 대학 동아리 서비스 규모에서는 실질 위험이 아니라고 봤고요. 이 마지막 판단이 숫자 없는 판단이라는 건 끝에 다시 적습니다.</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph before["이슈 원안: 스케줄러가 미리 계산"]
        S[스케줄러 10분 주기] --&gt;|모든 지원폼 집계| R1[(Redis)]
        Q1[조회 요청] --&gt;|읽기만| R1
        R1 -.-&gt;|저장값 없음| X[조회 불가]
    end
    subgraph after["채택: 요청 시점 계산"]
        Q2[조회 요청] --&gt; R2[(Redis)]
        R2 --&gt;|있음| Q2
        R2 --&gt;|없음 또는 장애| DB[(DB 집계)]
        DB --&gt;|30분 저장| R2
    end
</code></pre>

<p><em>그림 1. 두 안의 흐름. 위쪽(채택안)은 Redis가 죽어도 DB 집계로 넘어갑니다.</em></p>

<p>그래서 A로 갔고, “필수”라고 적혀 있던 항목은 이슈 코멘트에 “cache-aside TTL 캐시로 대체”로 바뀌었습니다.</p>

<h2 id="저장은-통째로-마스킹은-응답에서">저장은 통째로, 마스킹은 응답에서</h2>

<p>조회 코드는 이게 전부예요.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// StatisticsServiceImpl.java:57-61</span>
<span class="nc">StatisticsResponseDto</span> <span class="n">full</span> <span class="o">=</span> <span class="n">cache</span><span class="o">.</span><span class="na">find</span><span class="o">(</span><span class="n">clubApplyFormId</span><span class="o">).</span><span class="na">orElseGet</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="o">{</span>
    <span class="nc">StatisticsResponseDto</span> <span class="n">computed</span> <span class="o">=</span> <span class="n">calculate</span><span class="o">(</span><span class="n">form</span><span class="o">,</span> <span class="nc">StatisticsDimension</span><span class="o">.</span><span class="na">defaults</span><span class="o">());</span>
    <span class="n">cache</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">clubApplyFormId</span><span class="o">,</span> <span class="n">computed</span><span class="o">);</span>
    <span class="k">return</span> <span class="n">computed</span><span class="o">;</span>
<span class="o">});</span>
</code></pre></div></div>

<p>저장할 때는 마스킹하지 않은 전체 결과를 담고, 재식별 방지 마스킹과 항목 선택은 응답을 만들 때 합니다. 그래야 저장 키가 지원폼 하나로 끝나거든요. 키에는 <code class="language-plaintext highlighter-rouge">statistics:v1:</code> 접두사를 두어서 응답 모양이 바뀌면 v2로 올리게 했고, Jackson이 <code class="language-plaintext highlighter-rouge">calculatedAt</code>의 <code class="language-plaintext highlighter-rouge">+09:00</code>을 UTC로 바꿔 버리는 문제가 있어서 캐시 전용 ObjectMapper에서 <code class="language-plaintext highlighter-rouge">ADJUST_DATES_TO_CONTEXT_TIME_ZONE</code>을 껐습니다.</p>

<h2 id="ttl-30분으로-하고">ttl 30분으로 하고</h2>

<p>같은 날 오전에 PR(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/pull/353">#353</a>)을 보다가 이렇게 물었습니다.</p>

<blockquote>
  <p>ttl 30분으로 하고, 언제 산출된건지 프론트에 표시될 수 있게 넘겨주는건?</p>
</blockquote>

<p>TTL은 저장값을 얼마나 오래 쓰느냐예요. 처음 기본값은 60초였는데, 30분으로 늘리는 대신 응답의 <code class="language-plaintext highlighter-rouge">calculatedAt</code>이 조회 시각이 아니라 그 저장값이 계산된 시각이라는 걸 계약에 명시했습니다. 화면에서 “몇 시 기준”이라고 보여줄 수 있게요. 그러자 CodeRabbit이 바로 하나를 잡았습니다. 저장값을 쓰는 경우에도 지원자가 적어서 가려진 응답에는 <code class="language-plaintext highlighter-rouge">calculatedAt</code>에 지금 시각을 넣고 있었어요. 방금 정한 계약과 정면으로 어긋나는 코드라 그것도 고쳤습니다.</p>

<p>Redis가 죽으면 어떻게 되는지는 8월 18일에 머지 전에 다시 물었습니다.</p>

<blockquote>
  <p>redis 연결 실패의 경우에는 어떻게 나타나?</p>
</blockquote>

<blockquote>
  <p>redis 캐시는 각 동아리별로 다른거 맞지?</p>
</blockquote>

<p>캐시 구현체가 예외를 밖으로 내보내지 않게 돼 있었어요.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// RedisStatisticsCache.java:47-51</span>
<span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// 캐시 장애/역직렬화 실패는 조회를 막지 않는다. 미스로 간주해 실시간 계산으로 폴백한다.</span>
    <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"통계 캐시 조회 실패 clubApplyFormId={} : {}"</span><span class="o">,</span> <span class="n">clubApplyFormId</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">toString</span><span class="o">());</span>
    <span class="k">return</span> <span class="nc">Optional</span><span class="o">.</span><span class="na">empty</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>저장 실패도 같은 식으로 삼키고 다음 요청이 다시 계산합니다. <code class="language-plaintext highlighter-rouge">RedisStatisticsCacheTest</code>의 <code class="language-plaintext highlighter-rouge">find_onError_returnsEmpty</code>, <code class="language-plaintext highlighter-rouge">put_onError_doesNotThrow</code> 두 테스트가 이 동작을 고정하고 있어요. 그걸 확인하고 머지했습니다.</p>

<h2 id="남은-것">남은 것</h2>

<p>같은 지원폼에 같은 순간 요청이 들어오면 각자 계산하고 각자 저장합니다. 규모상 괜찮다는 판단만 있지 잰 적은 없어요. 캐시가 얼마나 맞고 있는지 세는 지표도 없어서, 스케줄러를 안 쓴 이유 중 하나였던 “안 보는 폼은 계산 0”이 실제로 얼마나 0인지도 모릅니다. 그러니 첫 문단의 넷 중 셋은 구조에서 나온 이유이고, 하나(요청이 몰리는 문제가 실질 위험이 아니라는 것)는 아직 숫자로 확인되지 않은 판단입니다.</p>

<p>이슈에 “필수”라고 적어 둔 걸 뒤집은 건 결국 사고 이력 때문이 아니라 비교표 때문이었고, 그 비교를 하게 만든 건 “잊고 봐봐” 한마디였습니다. 다음에 비슷한 자리가 오면 사고 이력은 근거 목록에서 빼고 구조만 놓고 비교할 것이고, “규모상 괜찮다”고 적는 항목에는 언제 재 볼지를 같이 적어 둘 생각입니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="team18-be" /><category term="redis" /><category term="cache" /><category term="spring-boot" /><summary type="html"><![CDATA[이슈에 필수라고 적힌 스케줄러 대신 요청 시점 캐시를 고른 이유 넷과, 그 뒤에 남은 것]]></summary></entry><entry><title type="html">우리 마스킹은 총합에만 조건이 걸려있는데</title><link href="https://gary5876.github.io/portfolio/blog/2026/08/10/statistics-reidentification/" rel="alternate" type="text/html" title="우리 마스킹은 총합에만 조건이 걸려있는데" /><published>2026-08-10T00:00:00+00:00</published><updated>2026-08-10T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/08/10/statistics-reidentification</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/08/10/statistics-reidentification/"><![CDATA[<p>안녕하세요, 고준서입니다. 동아리움이라는 대학 동아리 지원 서비스의 백엔드를 팀(백엔드 3인, 프론트엔드 3인)에서 맡고 있고, 지원자 통계 기능은 제가 전담했어요. 지원폼별 성별, 학부, 학번, 일자별 지원 추이를 지원자에게 공개하는 API인데, 공개 통계는 누가 지원했는지 알아낼 수 있는 자료가 되기도 합니다. 8월 5일부터 10일 사이에 그 부분을 잡았는데, 결론은 항목을 하나씩 가리는 게 아니라 지원자가 3명이 안 되면 분포를 통째로 감추는 것이었어요. 그 기준이 어떻게 정해졌는지, 항목별로 가리던 구현이 왜 틀렸는지는 PR과 제 대화 기록에서 옮겼습니다. 지원폼당 지원자가 보통 몇 명인지 같은 규모 숫자는 이 글에 없어요. 당시 재지 않았거든요. 코드 초안은 Claude Code가 썼고 저는 읽고 되묻는 쪽이었습니다.</p>

<h2 id="아직-프론트-수정이-안이루어진-상태라">아직 프론트 수정이 안이루어진 상태라</h2>

<p>통계를 내려면 지원자 정보에 성별과 학부가 있어야 합니다. 8월 5일에 성별 컬럼을 추가하는 PR(#337)을 보다가 든 생각은 이거였어요.</p>

<blockquote>
  <p>지원자들은 남성 여성만 받게 프론트에서 강제할거니까 문제없겠지? 만약 유저가 악의적으로 다른 입력을 넣은 통신을 시도하면 어떻게 될까?</p>
</blockquote>

<p>그래서 잘못된 값이 오면 400으로 거부하는 회귀 테스트가 붙었습니다. 그런데 필수값으로 조이자마자 다른 문제가 보였어요.</p>

<blockquote>
  <p>아직 프론트 수정이 안이루어진 상태라 null로 올텐데 그건 어떡하지?</p>
</blockquote>

<blockquote>
  <p>gender도 프론트 수정이 안되어있어서 null을 일단은 허용해놔야해</p>
</blockquote>

<p>프론트가 아직 값을 안 보내는 상태에서 처음부터 필수로 조이면 기존 화면의 제출이 전부 막힙니다. 그래서 성별과 학부 둘 다 일단 선택 입력으로 배포하고 필수화는 뒤로 미뤘어요. 학부는 자유 입력 대신 15개 목록의 enum으로 잠갔습니다(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/pull/339">PR #339</a>). “컴퓨터공학과, 컴퓨터공학부, 컴공”이 따로 집계되면 통계가 의미를 잃으니까요. 목록 밖은 <code class="language-plaintext highlighter-rouge">ETC</code>로 받고, 집계에서는 입력 안 한 것만 “미입력”으로 모읍니다(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/pull/345">PR #345</a>).</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/user/entity/Faculty.java:12-13</span>
 <span class="o">*</span> <span class="o">&lt;</span><span class="n">strong</span><span class="o">&gt;</span><span class="n">반드시</span> <span class="o">{</span><span class="nd">@code</span> <span class="nd">@Enumerated</span><span class="o">(</span><span class="nc">EnumType</span><span class="o">.</span><span class="na">STRING</span><span class="o">)}</span><span class="n">으로</span> <span class="n">저장한다</span><span class="o">.&lt;/</span><span class="n">strong</span><span class="o">&gt;</span> <span class="n">순서값으로</span> <span class="n">저장하면</span> <span class="n">상수를</span> <span class="n">추가하거나</span>
 <span class="o">*</span> <span class="n">순서를</span> <span class="n">바꾸는</span> <span class="n">순간</span> <span class="n">기존</span> <span class="n">데이터의</span> <span class="n">의미가</span> <span class="n">바뀐다</span><span class="o">.</span>
</code></pre></div></div>

<p>8월 9일에는 관리자용 통계를 하나 더 두자고 했습니다.</p>

<blockquote>
  <p>그리고 관리자용 통계도 하나 만들고싶어. 지금 지원자들 보라고 있는 비로그인 통계만 있는데 그게 지원자 추청 불가능하게 최소공개기준이라던가 n명 이상 늘어야 갱신된다던가 하는 제약을 걸어놨는데, 만약 이걸 조회하는 인원이 지원자들을 직접 관리하는 동아리 관리자면 제약이 필요 없으니가</p>
</blockquote>

<p>지원자를 직접 관리하는 사람에게 지원자 통계를 가릴 이유가 없죠. 그래서 공개용은 가리고, 관리자용(<code class="language-plaintext highlighter-rouge">getStatisticsForAdmin</code>)은 원본을 그대로 봅니다.</p>

<h2 id="버킷별로-합이-다르니">버킷별로 합이 다르니?</h2>

<p>8월 10일 새벽, 마스킹 PR(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/pull/351">#351</a>)에 CodeRabbit이 Major로 경고를 하나 달았습니다. 성별처럼 항목이 두세 개뿐인 분포에서 한 항목만 가리면, 전체 지원자 수에서 공개된 항목을 빼서 가린 값이 그대로 나온다는 거였어요. 여성 1명, 남성 4명이면 여성만 가려지는데 5에서 4를 빼면 끝이라는 얘기입니다. Claude가 그 경고를 받아 “공개 총합을 생략할지, 두 번째로 작은 항목까지 같이 가릴지, 위험을 감수할지” 세 가지 선택지를 물었고요.</p>

<p>저는 무슨 말인지 몰라서 이렇게 물었습니다.</p>

<blockquote>
  <p>버킷별로 합이 다르니?</p>
</blockquote>

<p>각 분포의 항목 합이 전체 지원자 수와 같으니 역산이 된다는 답이 왔고, 그제야 뭐가 어긋난 건지 보였습니다.</p>

<blockquote>
  <p>도대체 무슨말을 하는거야? 만약 여성이 1명이고 남성이 4명이면 여성만 마스킹하는데, 그 값을 총합에서 추정할 수 있다는 말을 하는거야? 근데 우리 마스킹은 그런 방식이 아니라 총합에만 조건이 걸려있는데</p>
</blockquote>

<p>원래 API 계약에는 “최소 공개 기준에 미달하면 결과가 빈 배열”이라고 적어 뒀었어요. 지원자 수 자체가 적으면 분포를 통째로 안 보여주는 방식입니다. 그런데 구현이 그 계약과 다르게 항목 하나하나를 골라 가리는 방식으로 돼 있었고, 역산 문제는 전부 거기서 나온 거였습니다. 항목을 골라 가리지 않으면 빼기로 복원할 것도 없어요. Claude가 잘못 구현했다고 인정했고, 계약대로 되돌렸습니다.</p>

<p>되돌리면서 하나를 더 짚었습니다.</p>

<blockquote>
  <p>기준값 3이라고 했고, 넌 null인지 0인지 미공개인지 구별을 당연히 해야된다고 생각 안하니?</p>
</blockquote>

<p>항목별로 가리는 구현은 가린 값을 null로 바꿨는데, 그러면 화면에서 “지원자가 적어서 가린 것”, “실제로 그날 0명”, “학부를 입력 안 한 것”이 전부 빈 값으로 보입니다. 셋을 구분하게 했어요. 가린 경우는 응답 최상위에 <code class="language-plaintext highlighter-rouge">masked: true</code>와 빈 결과, 실제 0명은 항목의 <code class="language-plaintext highlighter-rouge">count: 0</code>, 미입력은 <code class="language-plaintext highlighter-rouge">UNKNOWN</code> 항목. 기준값 3은 설정으로 뺐습니다. 왜 3인지는 이슈에도 PR에도 없습니다. <a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/issues/335">이슈 #335</a> 원안은 항목별 임계값 5였고, 총합 기준으로 바꾸면서 3이 됐는데 그 숫자를 고른 이유는 적어 두지 않았어요.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/statistics/config/StatisticsProperties.java:16-20</span>
<span class="nd">@ConfigurationProperties</span><span class="o">(</span><span class="n">prefix</span> <span class="o">=</span> <span class="s">"statistics"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">record</span> <span class="nf">StatisticsProperties</span><span class="o">(</span>
        <span class="nd">@DefaultValue</span><span class="o">(</span><span class="s">"3"</span><span class="o">)</span> <span class="kt">int</span> <span class="n">minTotalApplicants</span><span class="o">,</span>
        <span class="nd">@DefaultValue</span><span class="o">(</span><span class="s">"1800"</span><span class="o">)</span> <span class="kt">long</span> <span class="n">cacheTtlSeconds</span>
<span class="o">)</span> <span class="o">{</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/statistics/service/StatisticsServiceImpl.java:63-70</span>
<span class="c1">// 재식별 방지: 전체 지원자 수가 최소 공개 기준 미만이면 분포를 비공개(masked)한다.</span>
<span class="c1">// 기준은 전체 지원자 수에만 걸리고 개별 버킷에는 걸지 않는다(그래야 버킷 간 뺄셈 역산 문제가 없다).</span>
<span class="k">if</span> <span class="o">(</span><span class="n">full</span><span class="o">.</span><span class="na">totalApplicants</span><span class="o">()</span> <span class="o">&lt;</span> <span class="n">properties</span><span class="o">.</span><span class="na">minTotalApplicants</span><span class="o">())</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nf">maskedResponse</span><span class="o">(</span><span class="n">clubApplyFormId</span><span class="o">,</span> <span class="n">full</span><span class="o">.</span><span class="na">totalApplicants</span><span class="o">(),</span> <span class="n">full</span><span class="o">.</span><span class="na">calculatedAt</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<pre><code class="language-mermaid">flowchart TD
    A[지원자 수 세기] --&gt; B{3명 미만?}
    B --&gt;|예| M["masked: true, 결과 없음"]
    B --&gt;|아니오| C[성별, 학부, 학번, 일자별 분포 전부 공개]
    C --&gt; D["0명인 항목은 count: 0&lt;br/&gt;미입력은 UNKNOWN 항목"]
</code></pre>

<p><em>그림 1. 공개 통계의 판정. 항목을 골라 가리지 않고 전체를 보여주거나 전체를 감춥니다.</em></p>

<p>전체 지원자 수는 가린 경우에도 그대로 내보냅니다. “아직 지원자가 적다”는 것 자체는 지원자에게 쓸모 있는 정보라고 봤어요.</p>

<h2 id="보통-지원자-삭제는-지원기간-끝나고-하기-때문에">보통 지원자 삭제는 지원기간 끝나고 하기 때문에</h2>

<p>머지 직전 CodeRabbit이 하나를 더 짚었습니다. 지원자 수를 판정에서 한 번 읽고 집계에서 또 읽으면, 그 사이에 지원서가 삭제돼 기준 아래로 떨어진 분포가 그대로 공개될 수 있다는 거였어요. 지원자 수를 한 번만 읽어 넘기고, 두 읽기가 같은 시점의 데이터를 보도록 트랜잭션 격리 수준을 명시했습니다(<a href="https://github.com/kakao-tech-campus-3rd-step3/Team18_BE/commit/4be04011">4be04011</a>).</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// domain/statistics/service/StatisticsServiceImpl.java:31-33</span>
<span class="c1">// 최소 공개 기준 판정(지원자 수)과 dimension 집계가 같은 스냅샷을 보도록 반복 읽기로 고정한다.</span>
<span class="c1">// 운영 MySQL(InnoDB)은 기본이 REPEATABLE_READ지만, DB 기본값에 의존하지 않고 요구사항을 명시한다.</span>
<span class="nd">@Transactional</span><span class="o">(</span><span class="n">readOnly</span> <span class="o">=</span> <span class="kc">true</span><span class="o">,</span> <span class="n">isolation</span> <span class="o">=</span> <span class="nc">Isolation</span><span class="o">.</span><span class="na">REPEATABLE_READ</span><span class="o">)</span>
</code></pre></div></div>

<p>운영 DB는 이미 그 격리 수준이 기본이라 실제로 이 일이 나지는 않습니다. 8월 18일에 이 얘기를 다시 봤을 때 저는 이렇게 판단했어요.</p>

<blockquote>
  <p>보통 지원자 삭제는 지원기간 끝나고 하기 때문에 신경쓸 필요 없을 듯. 우리 이슈 확인해보고, 남은거 있는지 확인</p>
</blockquote>

<p>그래도 “같은 시점이어야 한다”는 요구를 DB 기본값에 숨겨 두는 것보다는 코드에 적어 두는 게 낫다고 봐서 명시는 남겼습니다.</p>

<p>이슈에 있던 “마감 5분 전 통계 감추기”는 하지 않았습니다. 공개하는 게 성별, 학부, 학번, 일자별 추이인데 전부 지원자가 바꿀 수 없는 속성이라, 분포를 본다고 자기 속성을 바꿔 통계를 유리하게 만들 수는 없으니까요.</p>

<p>지금 남은 약점은 기준값 3이 작다는 겁니다. 지원자가 4명이면 분포가 그대로 공개되는데, 4명 중 1명뿐인 학부 항목은 사실상 그 사람이에요. 그리고 통계를 반복해서 조회하면 지원 전후의 차이로 한 사람의 속성 조합을 추정할 수 있는데, 이건 총합 기준으로도 막히지 않습니다. 지금은 30분짜리 캐시가 갱신 단위를 뭉개 주는 걸로 완화될 뿐이에요. 그 캐시를 왜 스케줄러 대신 요청 시점 계산으로 갔는지는 <a href="/portfolio/blog/2026/08/18/statistics-cache-aside/">다음 글</a>에 적었습니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="team18-be" /><category term="privacy" /><category term="statistics" /><category term="spring-boot" /><summary type="html"><![CDATA[항목별로 가리면 뺄셈으로 복원된다. 지원자 3명 미만이면 분포를 통째로 감추기로 한 이유와 그 구현이 틀렸던 자리]]></summary></entry><entry><title type="html">RAG 서베이 논문 정독: 언제 도입할 가치가 있는가</title><link href="https://gary5876.github.io/portfolio/blog/2026/07/08/rag-survey-notes/" rel="alternate" type="text/html" title="RAG 서베이 논문 정독: 언제 도입할 가치가 있는가" /><published>2026-07-08T00:00:00+00:00</published><updated>2026-07-08T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/07/08/rag-survey-notes</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/07/08/rag-survey-notes/"><![CDATA[<p>안녕하세요, 고준서입니다. 이건 긴 글이 아니라 읽은 논문을 정리해 둔 노트예요.</p>

<p>RAG 서베이 논문(arXiv:2312.10997)을 원문으로 정독했습니다. 요약본이나 블로그 글로 대충 아는 상태였는데, 한 번은 처음부터 끝까지 읽어 보고 싶었거든요. 논문은 RAG를 Naive, Advanced, Modular 세 단계로 나눠서 각 단계가 앞 단계의 어떤 한계 때문에 나왔는지를 짚습니다. 검색해서 붙이면 끝인 줄 알았던 Naive RAG가 왜 검색 품질과 문맥 길이에서 막히는지, Advanced가 전처리와 후처리로 그걸 어떻게 메우는지, Modular가 왜 아예 파이프라인을 조립식으로 바꾸는지가 순서대로 나와요.</p>

<p>정리하면서 제일 오래 붙들었던 건 “언제 도입할 가치가 있는가”였습니다. 논문 자체가 답을 주는 건 아니지만, 각 단계의 비용과 실패 지점을 나란히 놓고 보면 판단 기준이 보이더라고요. 그 기준표와 흐름 다이어그램, 그리고 원문에서 인용한 부분을 노트로 남겼습니다.</p>

<p>노트 전체는 <a href="https://github.com/gary5876/rag-survey-notes">rag-survey-notes</a> 레포에 있어요. 같은 논문을 읽으실 분께 참고가 되면 좋겠습니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="study" /><category term="rag" /><category term="paper-deep-read" /><summary type="html"><![CDATA[RAG 서베이 논문을 읽고 언제 도입할 가치가 있는지 정리한 노트]]></summary></entry><entry><title type="html">라우트와 OpenAPI가 어긋나면 CI가 막게 한 이유, 그리고 경로만 보는 가드의 한계</title><link href="https://gary5876.github.io/portfolio/blog/2026/06/28/openapi-contract-guard-tests/" rel="alternate" type="text/html" title="라우트와 OpenAPI가 어긋나면 CI가 막게 한 이유, 그리고 경로만 보는 가드의 한계" /><published>2026-06-28T10:32:00+00:00</published><updated>2026-06-28T10:32:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/06/28/openapi-contract-guard-tests</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/06/28/openapi-contract-guard-tests/"><![CDATA[<p>안녕하세요, 고준서입니다. diet-setlog는 6월 말에 GDG on Campus 전남대에서 디자이너 두 분, 개발자 두 명이 11일 동안 만든 식단 기록 앱입니다. 저는 개발 둘 중 한 명으로 설계 문서와 이슈를 쓰고 Claude Code로 구현을 돌린 뒤 PR을 읽고 머지했어요. 이 프로젝트는 API 계약을 <code class="language-plaintext highlighter-rouge">openapi.yaml</code> 한 파일로 정하고 “산문과 YAML이 충돌하면 YAML이 이긴다”고 잠가 둔 상태로 시작했는데, 서버 엔드포인트를 이틀 만에 다 만들고 보니 코드가 그 YAML을 따르는지는 제가 눈으로 보고 있었습니다. 그래서 6월 28일에 Express 라우터가 실제로 응답하는 경로와 메서드를 뽑아 YAML의 <code class="language-plaintext highlighter-rouge">paths</code>와 양방향으로 대조하는 테스트를 넣고 CI에 물렸어요. 문서에 없는 라우트도, 구현 없는 문서 항목도 빨간불이 납니다.</p>

<p>이 글에서 확인할 수 있는 건 왜 이 방식이었는지, 요청 본문까지 검증하는 다른 길은 왜 안 갔는지, 그리고 두 달 뒤 이 가드가 실제로 뭘 잡았고 뭘 놓쳤는지입니다. 이슈와 PR, 테스트 코드 줄 번호로 따라갑니다. 그날 기준 YAML은 13개 경로 17개 동작이었고 지금은 16개 경로 20개 동작이에요. 가드가 CI에서 실패한 횟수는 0번인데, 그게 좋은 뜻인지는 끝에 적습니다.</p>

<h2 id="유일한-기준을-사람이-지키고-있었다">“유일한 기준”을 사람이 지키고 있었다</h2>

<p>배경을 한 문단만 적어 둘게요. 6월 24일부터 26일까지는 코드 없이 문서만 썼고, 문서가 다섯 개쯤 쌓였을 때 “누구나 <code class="language-plaintext highlighter-rouge">docs/plans</code>를 읽고 만들면 완벽히 똑같은 앱이 나오는가?”라고 이슈(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/issues/9">#9</a>)에 물었습니다. 답은 아니었어요. 문서끼리 어긋나는 곳이 8건(끼니 3종 대 4종, 디렉터리 이름, 피드 포함 여부 같은 것)이었고, 같은 날 PR(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/pull/10">#10</a>)에서 <code class="language-plaintext highlighter-rouge">spec-lock.md</code>에 8건을 표로 확정하고 API 계약은 <code class="language-plaintext highlighter-rouge">openapi.yaml</code>을 유일한 기준으로 두었습니다. OpenAPI 3.1 형식이고 그날 기준 13개 경로, 17개 동작이었어요.</p>

<p>6월 26일 저녁부터 27일까지 서버 엔드포인트를 전부 구현했습니다. 세션, 프로필, 홈, 기록, 캘린더, 친구, 피드, 분석. 28일 새벽에는 디자이너 2차 시안에 새로 들어온 카카오 로그인이 추가돼서 <code class="language-plaintext highlighter-rouge">POST /sessions/kakao</code>가 늘었고요. 그러다 이슈(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/issues/71">#71</a>)에 이렇게 적었습니다.</p>

<blockquote>
  <p>지금까지 라우트와 <code class="language-plaintext highlighter-rouge">docs/plans/openapi.yaml</code> 동기화는 <strong>수동</strong>이었다(스펙 미반영/유령 스펙을 잡는 장치 없음).</p>
</blockquote>

<p>새 엔드포인트를 만들면서 YAML에 안 적어도, 반대로 YAML에만 있고 구현이 없어도 아무것도 안 울렸습니다. 이틀 동안 그게 안 벌어진 건 제가 PR마다 YAML을 같이 열어 봤기 때문이지 구조 때문이 아니었어요.</p>

<h2 id="마운트-목록-하나-테스트-둘">마운트 목록 하나, 테스트 둘</h2>

<p>28일 저녁 7시 32분에 머지한 PR(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/pull/72">#72</a>, <a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/commit/40039f4">40039f4</a>)이 그 장치입니다. 코드는 Claude Code가 썼고 PR 본문에 그렇게 남아 있어요.</p>

<p>먼저 마운트 목록을 한 곳으로 모았습니다. 전에는 <code class="language-plaintext highlighter-rouge">index.ts</code>가 라우터를 하나씩 <code class="language-plaintext highlighter-rouge">app.use</code>로 붙였는데, 이제 <code class="language-plaintext highlighter-rouge">routes.ts</code>의 배열 하나가 기준이고 <code class="language-plaintext highlighter-rouge">index.ts</code>는 그걸 순회합니다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// server/src/routes.ts:12-26</span>
<span class="c1">// 마운트 경로 ↔ 라우터 단일 진실원.</span>
<span class="c1">// index.ts(실제 마운트)와 계약 가드 테스트(routes.contract.test.ts)가 공유한다.</span>
<span class="c1">// 새 라우터를 추가하면 여기에만 등록 → index.ts 가 순회 마운트하고, 가드가 openapi.yaml 동기화를 강제한다.</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">routeGroups</span><span class="p">:</span> <span class="nx">ReadonlyArray</span><span class="o">&lt;</span><span class="k">readonly</span> <span class="p">[</span><span class="kr">string</span><span class="p">,</span> <span class="nx">Router</span><span class="p">]</span><span class="o">&gt;</span> <span class="o">=</span> <span class="p">[</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/sessions</span><span class="dl">'</span><span class="p">,</span> <span class="nx">sessionRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/me</span><span class="dl">'</span><span class="p">,</span> <span class="nx">profileRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/home</span><span class="dl">'</span><span class="p">,</span> <span class="nx">homeRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/food-records</span><span class="dl">'</span><span class="p">,</span> <span class="nx">recordsRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/calendar</span><span class="dl">'</span><span class="p">,</span> <span class="nx">calendarRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/friends</span><span class="dl">'</span><span class="p">,</span> <span class="nx">friendsRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/feed</span><span class="dl">'</span><span class="p">,</span> <span class="nx">feedRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/posts</span><span class="dl">'</span><span class="p">,</span> <span class="nx">postsRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/food-analyses</span><span class="dl">'</span><span class="p">,</span> <span class="nx">analysesRouter</span><span class="p">],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">/v1/images</span><span class="dl">'</span><span class="p">,</span> <span class="nx">imagesRouter</span><span class="p">],</span>
<span class="p">];</span>
</code></pre></div></div>

<p>그다음이 테스트예요. Express 라우터는 내부에 <code class="language-plaintext highlighter-rouge">stack</code>이라는 배열로 자기가 아는 경로와 메서드를 들고 있는데, 그걸 훑어서 “실제로 응답하는 엔드포인트” 집합을 만듭니다. YAML 쪽은 <code class="language-plaintext highlighter-rouge">paths</code>를 읽어 같은 모양의 집합을 만들고요. 두 집합을 양방향으로 뺍니다.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// server/src/routes.contract.test.ts:38-43</span>
<span class="kd">function</span> <span class="nf">normalizePath</span><span class="p">(</span><span class="nx">p</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="kd">let</span> <span class="nx">s</span> <span class="o">=</span> <span class="nx">p</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/^</span><span class="se">\/</span><span class="sr">v1/</span><span class="p">,</span> <span class="dl">''</span><span class="p">);</span> <span class="c1">// openapi 의 paths 는 /v1 미포함(servers url 에 있음)</span>
  <span class="nx">s</span> <span class="o">=</span> <span class="nx">s</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/:</span><span class="se">([</span><span class="sr">A-Za-z0-9_</span><span class="se">]</span><span class="sr">+</span><span class="se">)</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">'</span><span class="s1">{$1}</span><span class="dl">'</span><span class="p">);</span> <span class="c1">// :id → {id}</span>
  <span class="k">if </span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="o">&amp;&amp;</span> <span class="nx">s</span><span class="p">.</span><span class="nf">endsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">))</span> <span class="nx">s</span> <span class="o">=</span> <span class="nx">s</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">);</span> <span class="c1">// 끝 슬래시 제거</span>
  <span class="k">return</span> <span class="nx">s</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// server/src/routes.contract.test.ts:78-89</span>
  <span class="nf">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">스펙에 없는 라우트가 없어야 한다(문서 누락 차단)</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">undocumented</span> <span class="o">=</span> <span class="p">[...</span><span class="nx">code</span><span class="p">].</span><span class="nf">filter</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">spec</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">e</span><span class="p">)).</span><span class="nf">sort</span><span class="p">();</span>
    <span class="nf">expect</span><span class="p">(</span>
      <span class="nx">undocumented</span><span class="p">,</span>
      <span class="s2">`openapi.yaml 에 누락된 엔드포인트(코드엔 있음):\n</span><span class="p">${</span><span class="nx">undocumented</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">'</span><span class="se">\n</span><span class="dl">'</span><span class="p">)}</span><span class="s2">`</span><span class="p">,</span>
    <span class="p">).</span><span class="nf">toEqual</span><span class="p">([]);</span>
  <span class="p">});</span>

  <span class="nf">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">라우트 없는 스펙 항목이 없어야 한다(유령 스펙 차단)</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">phantom</span> <span class="o">=</span> <span class="p">[...</span><span class="nx">spec</span><span class="p">].</span><span class="nf">filter</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="o">!</span><span class="nx">code</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">e</span><span class="p">)).</span><span class="nf">sort</span><span class="p">();</span>
    <span class="nf">expect</span><span class="p">(</span><span class="nx">phantom</span><span class="p">,</span> <span class="s2">`구현 없는 openapi.yaml 항목(스펙엔 있음):\n</span><span class="p">${</span><span class="nx">phantom</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">'</span><span class="se">\n</span><span class="dl">'</span><span class="p">)}</span><span class="s2">`</span><span class="p">).</span><span class="nf">toEqual</span><span class="p">([]);</span>
  <span class="p">});</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">:recordId</code>와 <code class="language-plaintext highlighter-rouge">{recordId}</code>처럼 표기가 다른 건 정규화로 맞췄고, 추출기가 헛도는 걸 막으려고 양쪽 집합이 10개 이상이고 <code class="language-plaintext highlighter-rouge">post /sessions/guest</code> 같은 대표 엔드포인트가 양쪽에 다 있는지 먼저 확인하는 테스트를 앞에 두었습니다. 이 테스트는 같은 날 넣은 CI의 서버 잡(<code class="language-plaintext highlighter-rouge">npm run test:coverage</code>)에 포함돼서, 문서에 없는 라우트가 PR에 올라오면 그 자리에서 빨간불이 납니다. 머지 시점에 코드와 YAML은 이미 맞아 있어서 가드는 바로 통과했어요.</p>

<h2 id="왜-경로만-봤나">왜 경로만 봤나</h2>

<p>다른 길은 둘이 있었습니다. YAML에서 zod 스키마를 생성해 요청 검증에 쓰는 것, 그리고 응답을 스키마로 검증하는 미들웨어를 두는 것. 둘 다 하면 경로뿐 아니라 요청과 응답 본문까지 문서와 맞는지 잡힙니다. 그런데 이슈 #71과 PR #72, spec-lock 어디에도 이 둘과 라우터 대조를 비교한 기록은 없어요. spec-lock에는 “OpenAPI/zod 산출물 부재”라고 빈 자리만 적혀 있습니다. 그러니 그날 왜 이쪽을 골랐는지는 기록으로 말할 수 없고, 지금 말하면 이렇습니다. 11일짜리 일정에서 제가 실제로 놓치고 있던 건 “만들었는데 문서에 안 적은 엔드포인트”였고, 그건 경로 대조로 잡힙니다. 본문 검증은 zod 스키마를 YAML과 다시 맞춰야 하는 일이 하나 더 생기는 방식이라 그때 붙일 여유가 없었던 것 같아요. 그게 판단이었는지 그냥 못 본 건지는 구분이 안 됩니다.</p>

<h2 id="못-잡는-것">못 잡는 것</h2>

<p>이 가드가 보는 건 경로와 메서드뿐입니다. 요청 본문 안의 필드는 안 봐요. 9월 16일에 코드와 스펙을 다시 대조했을 때 <code class="language-plaintext highlighter-rouge">POST /v1/food-records</code>가 YAML에 없는 <code class="language-plaintext highlighter-rouge">imageUrl</code> 필드를 받고 있었는데, 가드는 조용했습니다. 경로는 같으니까요. 응답 스키마도 마찬가지로 대조하지 않습니다. 바로 위에서 안 간 길이 잡았을 구멍입니다.</p>

<p>지금 YAML은 16개 경로, 20개 동작입니다. 7월에 팀원이 피드에 날짜와 범위 파라미터를 붙일 때도 경로가 바뀌지 않았으니 가드는 관여할 일이 없었고요. 두 달 동안 가드가 실제로 뭔가를 막은 기록은 없습니다. 그게 코드와 문서가 안 벌어졌다는 뜻인지, 벌어질 만큼 개발이 이어지지 않았다는 뜻인지는 11일짜리 프로젝트로는 알 수 없습니다.</p>

<p>여담 하나. spec-lock의 §2 제목은 지금도 “7개 확정”이고 표는 8줄입니다. 8번째 항목을 나중에 넣으면서 제목을 안 고친 흔적이에요. 문서와 문서가 어긋나는 걸 잡자고 만든 문서에 남은 어긋남이라, 이 글을 쓰면서 발견해 그대로 적어 둡니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="diet-setlog" /><category term="openapi" /><category term="express" /><category term="vitest" /><category term="ci" /><summary type="html"><![CDATA[API 문서를 유일한 기준이라 정해 놓고 사람이 눈으로 지키던 걸, 경로와 메서드만 대조하는 테스트로 CI에 넘긴 결정과 두 달 뒤 확인한 구멍]]></summary></entry><entry><title type="html">친구가 0명인 첫날, 누구를 추천할 것인가</title><link href="https://gary5876.github.io/portfolio/blog/2026/06/26/friend-recommendation-cold-start/" rel="alternate" type="text/html" title="친구가 0명인 첫날, 누구를 추천할 것인가" /><published>2026-06-26T06:00:00+00:00</published><updated>2026-06-26T06:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/06/26/friend-recommendation-cold-start</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/06/26/friend-recommendation-cold-start/"><![CDATA[<p>안녕하세요, 고준서입니다. 6월 말에 GDG on Campus 전남대에서 디자이너 두 분, 개발자 두 명이 모여 diet-setlog라는 앱을 만들었어요. 음식 사진을 찍으면 Gemini가 칼로리를 읽어 주고, 그 기록을 캘린더에 모으고, 친구들과 피드로 서로 인증하는 앱입니다. 저는 개발 둘 중 한 명이었고, 설계 문서와 이슈를 쓰고 Claude Code로 구현을 돌린 뒤 PR을 읽고 머지하는 쪽이었어요. 이 레포는 첫 커밋부터 그렇게 일하기로 세팅했습니다(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/commit/8964a9c">8964a9c</a>).</p>

<p>그중 “친구 추천”을 정하는 데 6월 26일 하루가 걸렸습니다. 결론부터 말하면, 서비스 첫날엔 거의 모든 사용자가 친구 0명이라 공통 친구로는 목록을 채울 수 없고, 그래서 프로필의 목표 유사도를 주력 신호로 두기로 했어요. 이 글에는 오전에 쓴 초안이 어디서 틀렸는지, 무엇을 버리고 무엇을 골랐는지를 그날 커밋 두 개와 결정 기록(ADR)을 따라 적었어요. 코드는 아직 한 줄도 없던 날이라 측정치는 없어요.</p>

<h2 id="초안은-친구-많은-순-글-많은-순">초안은 “친구 많은 순, 글 많은 순”</h2>

<p>친구 추천이라고 하면 친구 추가 화면에 검색어 없이 들어왔을 때 보여 주는 목록을 말합니다. 그날 오전 11시 46분에 올린 API/DB 설계 초안(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/commit/b0d04bc">b0d04bc</a>)의 §4.6에는 정렬이 이렇게 적혀 있었어요.</p>

<blockquote>
  <p>친구수(<code class="language-plaintext highlighter-rouge">friendCount</code>) 많은 순 → 활동수(<code class="language-plaintext highlighter-rouge">postCount</code>=피드에 올린 글 수) 많은 순</p>
</blockquote>

<p>그리고 후보는 내 친구 수로 갈랐습니다. 친구가 0명이면 내 카카오톡 친구 중 앱 가입자, 1명 이상이면 친구의 친구. 그럴듯해 보였고, 같은 커밋에 넣은 리서치 다섯 편(Facebook, LinkedIn, Instagram, X, TikTok, Snapchat이 “알 수도 있는 사람”을 어떻게 뽑는지 공개 자료로 조사한 것)의 인덱스에도 “리서치가 이를 뒷받침한다”고 적혀 있었습니다.</p>

<h2 id="리서치를-다-읽고-나니-두-군데가-틀렸다">리서치를 다 읽고 나니 두 군데가 틀렸다</h2>

<p>세 시간쯤 뒤인 오후 3시 1분에 올린 결정 기록(<a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/blob/develop/docs/decisions/friend-recommendation/0001-recommendation-algorithm.md">ADR 0001</a>, <a href="https://github.com/GDG-jnu-DietSetlog/diet-setlog/commit/22dc5a1">22dc5a1</a>)은 초안을 뒤집는 내용이었어요. 이 문서는 Claude가 초안을 쓰고 제가 읽고 커밋했고, 트레일러에 그렇게 남아 있습니다.</p>

<p>첫째, 어느 서비스나 1순위 신호로 쓰는 “공통 친구 수”가 초안 정렬에 없었습니다. 둘째, <code class="language-plaintext highlighter-rouge">friendCount</code>를 “내가 follow한 수”로 세고 있었어요. 그걸 1순위 정렬키로 쓰면 남을 많이 follow한 사람이 맨 위에 옵니다. 인기와는 반대 방향이죠.</p>

<p>그런데 공통 친구 수를 1순위로 올리는 것만으로는 안 됐습니다. 서비스 첫날엔 친구 관계가 통째로 비어 있어서 거의 모든 사용자가 친구 0명이고, 그러면 공통 친구도 친구의 친구도 전부 빈 결과예요. 초안이 믿었던 카카오톡 친구 매칭은 카카오 비즈앱 검수를 통과해야 켤 수 있어서 v1에서는 못 씁니다. 그러니까 런칭 직후 실제로 작동하는 신호는 활동량과 프로필밖에 없었습니다.</p>

<h2 id="첫날부터-있는-데이터가-뭔지-세어-보기">첫날부터 있는 데이터가 뭔지 세어 보기</h2>

<p>그래서 ADR은 “이상적인 알고리즘”이 아니라 손에 쥔 데이터에서 거꾸로 짰습니다. Prisma 스키마 기준으로 즉시 쓸 수 있는 건 <code class="language-plaintext highlighter-rouge">postCount</code>, 기록의 <code class="language-plaintext highlighter-rouge">eatenAt</code>, 가입일, 그리고 <code class="language-plaintext highlighter-rouge">Profile</code>이었어요. 프로필은 앱을 쓰려면 반드시 채우는 단계라 모든 활성 사용자가 나이, 성별, 현재 체중과 목표 체중, 주당 감량 목표, 하루 칼로리 목표를 갖고 있습니다. 그래프가 비어 있어도 작동하는 유일한 개인화 신호였죠.</p>

<p>결정한 점수식은 이렇습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// docs/decisions/friend-recommendation/0001-recommendation-algorithm.md §3
score(후보) =
    w1 · mutualFriendCount      // 그래프: P0엔 대부분 0, 그래프 자라면 지배
  + w2 · goalSimilarity         // Profile 기반: 런칭기 주력 (우리만의 도메인 신호)
  + w3 · activityRecency        // postCount + 최근 7일 활동 유무
  + w4 · followerCount          // 인기 (followingCount 아님)
  - w5 · alreadyShownPenalty    // 이미 본/거절 디스카운트 (impression discounting)
tie-break: id
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">goalSimilarity</code>는 목표 방향(감량, 유지, 증량)이 같은지에 0.40, 주당 목표 강도가 가까운지에 0.25, 10년 단위 나이대가 같은지에 0.20, 하루 칼로리 목표가 가까운지에 0.15를 두는 0~1 사이 값이에요. 성별은 뺐습니다. 다이어트 동기의 유사성과는 관계가 약하고, 동성끼리만 추천되는 쪽으로 쏠릴 것 같아서요. 그리고 이 값은 정렬에만 쓰고 “왜 추천됐는지”에 상대의 체중이나 목표 수치를 보여 주지 않기로 했습니다.</p>

<p>가중치는 하나로 고정하지 않고 데이터 양에 따라 움직입니다.</p>

<table>
  <thead>
    <tr>
      <th>단계</th>
      <th>관계 그래프</th>
      <th>주력 신호</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>런칭 직후</td>
      <td>거의 빔</td>
      <td>목표 유사도, 활동성 (공통 친구는 자동으로 0이라 뒤로 밀림)</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>
  </tbody>
</table>

<p><em>표 1. 알고리즘은 하나이고 가중치만 이동합니다. 리서치 03 §7의 “하드 스위치가 아니라 블렌딩” 패턴을 그대로 가져왔습니다.</em></p>

<p>후보도 초안의 “0명이면 카카오, 1명 이상이면 친구의 친구”라는 하드 스위치를 버리고, 친구의 친구, 카카오 친구, 목표/활동 폴백 세 소스를 합집합으로 채우되 부족한 만큼 뒤 소스로 메우게 했습니다. 빈 화면을 막는 게 목적이었으니까요.</p>

<p>카운터는 둘로 쪼갰습니다. <code class="language-plaintext highlighter-rouge">followingCount</code>는 내가 follow한 수로 친구의 친구를 찾을 때 쓰고, <code class="language-plaintext highlighter-rouge">followerCount</code>는 나를 follow한 수로 인기 정렬에 씁니다. follow가 일어나면 초안처럼 본인 카운터가 아니라 상대의 <code class="language-plaintext highlighter-rouge">followerCount</code>가 올라갑니다. 지금 서버 코드도 그렇게 돼 있어요.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// server/src/modules/friends/friends.routes.ts:267</span>
<span class="k">await</span> <span class="nx">tx</span><span class="p">.</span><span class="nx">user</span><span class="p">.</span><span class="nf">update</span><span class="p">({</span> <span class="na">where</span><span class="p">:</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">target</span> <span class="p">},</span> <span class="na">data</span><span class="p">:</span> <span class="p">{</span> <span class="na">followerCount</span><span class="p">:</span> <span class="p">{</span> <span class="na">increment</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</span> <span class="p">}</span> <span class="p">});</span>
</code></pre></div></div>

<h2 id="페이징이-같이-바뀌었다">페이징이 같이 바뀌었다</h2>

<p>정렬키를 바꾸니 딸려 온 문제가 하나 있었습니다. 초안은 <code class="language-plaintext highlighter-rouge">(friendCount, postCount, id)</code>로 DB 커서 페이징을 하려 했는데, 새 1순위와 2순위인 공통 친구 수와 목표 유사도는 저장된 컬럼이 아니라 계산값이라 인덱스로 정렬할 수가 없어요. 그래서 구조를 “인덱스로 후보를 싸게 좁히고, 그 소수에만 점수를 계산하고, 정렬된 목록을 Redis에 몇 시간 캐시하고, 페이징은 그 캐시 목록 기준”으로 바꿨습니다. 후보를 좁히는 거친 키가 필요해서 <code class="language-plaintext highlighter-rouge">goalDirection</code>과 <code class="language-plaintext highlighter-rouge">ageBucket</code>을 <code class="language-plaintext highlighter-rouge">User</code>에 비정규화하고 인덱스를 걸었고, 친구의 친구 조회에는 <code class="language-plaintext highlighter-rouge">statement_timeout</code>과 슈퍼노드 컷(친구가 너무 많은 사람은 2단계 확장에서 제외, 기준은 이틀 뒤 spec-lock에서 1,000명으로 확정)을 두었습니다.</p>

<p>이틀 뒤 spec-lock 문서 §8에서 v1은 가중합 대신 정렬키 순서(<code class="language-plaintext highlighter-rouge">mutualFriendCount → goalSimilarity → activityRecency → followerCount → id</code>)로 단일화하고 상수(<code class="language-plaintext highlighter-rouge">NORM_w = 1.0</code>, <code class="language-plaintext highlighter-rouge">NORM_cal = 800</code>, 캐시 TTL 4시간, 시드 사용자 30명)를 박았습니다. 서버의 <code class="language-plaintext highlighter-rouge">friends.routes.ts</code>는 그 정렬키대로 구현돼 있고, 9월에 코드와 스펙을 대조했을 때 추천 상수는 전부 일치했어요.</p>

<p>다만 ADR 끝에 적어 둔 재검토 트리거(그래프가 자라면 가중치 재조정, 친구의 친구 조회가 느려지면 야간 배치로 이행, 추천 수락률이 낮으면 점수식 재검토)는 하나도 오지 않았습니다. 이 앱은 배포까지 가지 않았고, 시드 사용자 30명 위에서만 돌았거든요. 첫날의 빈 그래프를 어떻게 채울지는 정했지만, 그 첫날은 아직 없습니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="diet-setlog" /><category term="recommendation" /><category term="cold-start" /><category term="postgresql" /><category term="redis" /><summary type="html"><![CDATA[친구가 0명인 첫날엔 공통 친구로 목록을 못 채운다. 초안을 버리고 프로필의 목표 유사도를 주력으로 둔 이유]]></summary></entry><entry><title type="html">DSC가 노이즈를 ‘품질 양호’로 읽었다: 데이터 품질 점수의 사각지대와 signal_integrity</title><link href="https://gary5876.github.io/portfolio/blog/2026/06/03/dsc-blind-spot-signal-integrity/" rel="alternate" type="text/html" title="DSC가 노이즈를 ‘품질 양호’로 읽었다: 데이터 품질 점수의 사각지대와 signal_integrity" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/06/03/dsc-blind-spot-signal-integrity</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/06/03/dsc-blind-spot-signal-integrity/"><![CDATA[<p>안녕하세요, 고준서입니다. 4학년 캡스톤은 팀으로 했고, 그중 데이터 품질 점수 엔진과 실험 파이프라인을 제가 맡았습니다. 캡스톤의 질문은 하나였어요. 데이터에 점수를 매기는 도구(DSC)가 있을 때, 그 점수가 실제로 그 데이터로 학습한 모델의 성능을 예측하는가. 정형 데이터 쪽에서는 3개 데이터셋에 오염 5종을 5단계로 넣고 5개 모델을 학습시켜 435건을 돌렸고, 점수와 성능의 상관계수 r이 0.598이 나와서 통과했습니다. 그다음이 이미지였습니다.</p>

<p>이미지에서는 점수가 가우시안 노이즈를 “품질 양호”로 읽었습니다. 지표 열 개가 전부 신호를 빼거나 흐리는 열화만 잡게 만들어져 있어서, 노이즈처럼 가짜 신호를 더하는 열화에는 눈이 없었던 거예요. 이 글에는 그게 언제 어떻게 드러났는지, 노이즈를 직접 재는 지표 하나를 어떻게 골라 넣었는지, 그래서 상관이 얼마나 돌아왔는지를 커밋 세 개(bbd88c7, c0c429b, 888d6ff)를 따라 적었습니다.</p>

<h2 id="6월-2일-기록에는-이미-적혀-있었습니다">6월 2일, 기록에는 이미 적혀 있었습니다</h2>

<p>이미지 회귀 쪽은 UTKFace(나이 추정)와 SCUT-FBP5500(얼굴 매력도 점수) 두 데이터셋에 같은 오염 5종을 5단계로 넣어 데이터셋당 26개 설정을 만듭니다. 6월 2일에 각 오염이 점수를 실제로 떨어뜨리는지 먼저 확인했는데, 넷은 단계가 올라갈수록 점수가 내려갔고 가우시안 노이즈 하나만 반응이 없었어요. 오히려 선명도 지표인 <code class="language-plaintext highlighter-rouge">sample_quality_image</code>가 UTKFace에서 0.97에서 0.98로 올랐습니다.</p>

<p>그날 진행 기록(Claude Code와 함께 쓴 문서라 커밋에 공동 작성자로 남아 있습니다)에는 이렇게 적혀 있습니다.</p>

<blockquote>
  <p>noise_injection 무반응(의도된 한계): 가우시안 노이즈가 Laplacian 분산(선명도)을 오히려 높여 <code class="language-plaintext highlighter-rouge">sample_quality</code>를 안 떨어뜨림. 노이즈의 실제 영향은 임베딩 계열에 감.</p>
</blockquote>

<p>그 뒤에는 다섯 종 중 하나라 hold-out과 상관에 지장이 없다고, 텍스트 셀의 word_shuffle 무반응과 같은 성격이라고 이어집니다. 원인까지 적어 놓고 넘어간 거죠.</p>

<p>같은 날 성능을 재는 방법도 바꿨습니다(ADR-019). 원래는 설정마다 ResNet18 모델을 통째로 다시 학습시켰는데, 이미지 한 셀에 GPU 2.5시간이 들고 Colab 무료 한도에서 자꾸 끊겼어요. 그래서 사전 학습된 ResNet18로 이미지 특징을 한 번만 뽑아 두고, 그 위에 Ridge, RandomForest, MLP, kNN 같은 가벼운 모델(probe)만 올려 성능을 재기로 했습니다. 정형 쪽은 이미 그렇게 재고 있었으니 방식을 맞추는 의미도 있었고, 셀당 한 시간 안쪽으로 내려왔습니다.</p>

<pre><code class="language-mermaid">flowchart LR
    A[오염 주입&lt;br/&gt;5종 x 5단계] --&gt; B[DSC 점수 계산&lt;br/&gt;지표 10개 가중합]
    A --&gt; C[frozen ResNet18&lt;br/&gt;임베딩 1회]
    C --&gt; D[Ridge / RF / MLP / kNN&lt;br/&gt;clean test R²]
    B --&gt; E[설정별 상관 r]
    D --&gt; E
</code></pre>

<p><em>그림 1. 바뀐 측정 방식. 왼쪽 점수와 오른쪽 probe 성능이 설정마다 짝을 이뤄 상관을 만듭니다.</em></p>

<h2 id="6월-3일-새벽-상관이-음수로-나왔습니다">6월 3일 새벽, 상관이 음수로 나왔습니다</h2>

<p>바뀐 방식으로 돌린 첫 결과가 이랬습니다(<a href="https://github.com/gary5876/capstone-dsc/commit/bbd88c7">bbd88c7</a>).</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>전체 5 polluter</th>
      <th>noise_injection 제외 4종</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>UTKFace</td>
      <td>r = −0.16 (FAIL)</td>
      <td>r = +0.94</td>
    </tr>
    <tr>
      <td>SCUT-FBP5500</td>
      <td>r = +0.16 (FAIL)</td>
      <td>r = +0.65</td>
    </tr>
  </tbody>
</table>

<p><em>표 1. 두 데이터셋 모두 noise_injection 하나가 합격선 0.40 아래로 끌어내렸습니다.</em></p>

<p>노이즈 설정들에서 DSC 점수는 85에서 87 사이로 거의 움직이지 않았습니다. UTKFace 기준 85.9에서 85.1이요. 그런데 같은 설정에서 Ridge probe의 R²는 0.43에서 0.00까지 떨어졌습니다. 점수는 멀쩡하다고 말하는데 모델은 아무것도 못 배우고 있었던 거죠.</p>

<p>왜 전날의 통째 학습 방식에서는 이게 안 보였을까요. 모델을 통째로 다시 학습시키면 노이즈에 적응합니다. 노이즈 설정에서도 R²가 0.7에서 0.8을 유지했고, 점수도 평평했으니 둘이 우연히 같이 평평해서 차이가 숨어 있었습니다. 고정된 특징 위에 얹은 가벼운 모델은 적응을 못 하니 노이즈에 그대로 무너졌고, 그제야 점수 쪽이 틀렸다는 게 드러났습니다. 비용 때문에 바꾼 측정 방식이 점수 엔진의 구멍을 보여준 셈이에요.</p>

<p>구멍의 정체는 이렇습니다. DSC의 이미지 지표 열 개는 전부 신호를 빼거나 흐리거나 중복시키는 열화를 잡도록 만들어져 있었습니다. 누락은 completeness가, 흐림은 선명도 지표가, 중복은 uniqueness가 잡는 식이죠. 정형 데이터의 품질 차원을 그대로 물려받은 결과였습니다. 가우시안 노이즈는 반대로 가짜 고주파를 더합니다. 선명도 지표가 쓰는 Laplacian 분산은 고주파가 많을수록 커지니까, 노이즈가 심할수록 “더 선명하다”로 읽습니다. 합성 이미지로 재보니 σ=0에서 369였던 분산이 σ=60에서 18,324가 됐고, 점수는 셋 다 1.000이었습니다.</p>

<h2 id="노이즈를-직접-재는-지표를-하나-넣었습니다">노이즈를 직접 재는 지표를 하나 넣었습니다</h2>

<p>지표 열 개 중에 “이 이미지에 노이즈가 얼마나 있나”를 묻는 게 없었으니, 하나 추가했습니다(<a href="https://github.com/gary5876/capstone-dsc/commit/c0c429b">c0c429b</a>). Immerkær(1996)의 빠른 노이즈 추정을 씁니다. 3×3 마스크가 엣지 같은 구조를 상쇄하고 노이즈만 남기기 때문에, 흐림이나 디테일에는 반응하지 않고 노이즈 강도에만 반응합니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># dsc_framework/image_cell.py:469-478
</span><span class="k">def</span> <span class="nf">_immerkaer_sigma</span><span class="p">(</span><span class="n">gray_uint8</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Immerkær(1996) 빠른 Gaussian 노이즈 sigma 추정. 3x3 마스크가 구조(엣지)를
    상쇄해 노이즈만 추정 → blur/디테일엔 오발 안 하고, 노이즈 강도엔 σ를 복원.</span><span class="sh">"""</span>
    <span class="kn">from</span> <span class="n">scipy</span> <span class="kn">import</span> <span class="n">ndimage</span>
    <span class="n">g</span> <span class="o">=</span> <span class="n">gray_uint8</span><span class="p">.</span><span class="nf">astype</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">float64</span><span class="p">)</span>
    <span class="n">N</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="nf">array</span><span class="p">([[</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">],</span> <span class="p">[</span><span class="o">-</span><span class="mi">2</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="o">-</span><span class="mi">2</span><span class="p">],</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">2</span><span class="p">,</span> <span class="mi">1</span><span class="p">]],</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="p">.</span><span class="n">float64</span><span class="p">)</span>
    <span class="n">conv</span> <span class="o">=</span> <span class="n">ndimage</span><span class="p">.</span><span class="nf">convolve</span><span class="p">(</span><span class="n">g</span><span class="p">,</span> <span class="n">N</span><span class="p">,</span> <span class="n">mode</span><span class="o">=</span><span class="sh">'</span><span class="s">reflect</span><span class="sh">'</span><span class="p">)</span>
    <span class="n">H</span><span class="p">,</span> <span class="n">W</span> <span class="o">=</span> <span class="n">g</span><span class="p">.</span><span class="n">shape</span>
    <span class="n">denom</span> <span class="o">=</span> <span class="mf">6.0</span> <span class="o">*</span> <span class="nf">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">W</span> <span class="o">-</span> <span class="mi">2</span><span class="p">)</span> <span class="o">*</span> <span class="nf">max</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">H</span> <span class="o">-</span> <span class="mi">2</span><span class="p">)</span>
    <span class="k">return</span> <span class="nf">float</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="nf">sqrt</span><span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">pi</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">)</span> <span class="o">*</span> <span class="n">np</span><span class="p">.</span><span class="nf">abs</span><span class="p">(</span><span class="n">conv</span><span class="p">).</span><span class="nf">sum</span><span class="p">()</span> <span class="o">/</span> <span class="n">denom</span><span class="p">)</span>
</code></pre></div></div>

<p>점수는 <code class="language-plaintext highlighter-rouge">1 − clip(σ̂ / noise_norm, 0, 1)</code>의 평균이고, 기준값 <code class="language-plaintext highlighter-rouge">noise_norm=25</code>는 결과를 보기 전에 정해 두었습니다. 후보는 셋이었는데 합성 검증에서 MAD-Laplacian은 흐린 이미지에 오발했고, 디노이징 잔차는 깨끗한 이미지에 오발해서 Immerkær만 남았습니다.</p>

<table>
  <thead>
    <tr>
      <th>입력</th>
      <th>signal_integrity</th>
      <th>추정 σ̂</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>clean</td>
      <td>0.99</td>
      <td> </td>
    </tr>
    <tr>
      <td>blur</td>
      <td>0.99</td>
      <td> </td>
    </tr>
    <tr>
      <td>σ=10</td>
      <td>0.76</td>
      <td>10.2</td>
    </tr>
    <tr>
      <td>σ=30</td>
      <td>0.31</td>
      <td>30.3</td>
    </tr>
    <tr>
      <td>σ=60</td>
      <td>0.00</td>
      <td>53.7</td>
    </tr>
  </tbody>
</table>

<p><em>표 2. 합성 검증. 깨끗한 이미지와 흐린 이미지에 오발이 없고, 노이즈 강도를 따라 내려갑니다.</em></p>

<p>가중치는 <code class="language-plaintext highlighter-rouge">sample_quality_image</code>를 0.15에서 0.10으로 내리고 <code class="language-plaintext highlighter-rouge">signal_integrity</code>에 0.05를 줘서 합 1.00을 유지했습니다. Colab을 다시 돌리기 전에 로컬에서 먼저 봤는데, 실제 probe 결과에 노이즈 레벨로 추정한 <code class="language-plaintext highlighter-rouge">signal_integrity</code> 값을 붙여 가중치 최적화를 돌리니 held-out 상관이 0.42에서 0.94로 올라왔습니다. 추정값이라 예측일 뿐이었고, 확정은 재실행에서 하기로 했습니다.</p>

<p>같은 날 오후 재실행 결과입니다(<a href="https://github.com/gary5876/capstone-dsc/commit/888d6ff">888d6ff</a>). UTKFace 노이즈 설정에서 <code class="language-plaintext highlighter-rouge">signal_integrity</code>가 0.87에서 0.04로 떨어졌고, 전에는 평평하던 DSC가 86.3에서 80.4로 내려갔습니다.</p>

<table>
  <thead>
    <tr>
      <th>가중치</th>
      <th>튜닝(UTKFace)</th>
      <th>held-out(SCUT)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>수정 전</td>
      <td>−0.16</td>
      <td>+0.16 (FAIL)</td>
    </tr>
    <tr>
      <td>default</td>
      <td>+0.49</td>
      <td>+0.67 (PASS)</td>
    </tr>
    <tr>
      <td>제약 최적화</td>
      <td>+0.85</td>
      <td>+0.95 (PASS)</td>
    </tr>
  </tbody>
</table>

<p><em>표 3. 수정 전후. 튜닝에 쓰지 않은 데이터셋에서도 합격선 0.40을 넘었습니다.</em></p>

<p>로컬 시뮬레이션이 0.94를 예측했고 실측은 0.951이었습니다. 최적화된 가중치에서 <code class="language-plaintext highlighter-rouge">signal_integrity</code>는 0.152로 열 개 지표 중 세 번째로 컸어요. 이 커밋 세 개는 전부 Claude Opus 4.8과 함께 작업했고, 후보 지표를 합성 이미지로 비교하는 실험과 구현 초안이 그쪽 몫이었습니다.</p>

<h2 id="정리">정리</h2>

<p>4월 12일에도 비슷한 일이 있었습니다(ADR-008). 정형 데이터에 75%까지 오염을 넣어도 점수가 2점도 안 움직였는데, 그때는 completeness가 placeholder 값을 결측으로 안 세는 구현 문제였습니다. 이번은 구현 버그가 아니라 지표 셋이 처음부터 한 방향만 보고 있었던 문제였습니다. 빼는 열화만 재는 지표 셋은 더하는 열화를 구조적으로 못 봅니다.</p>

<p>막은 건 노이즈 하나입니다. 텍스트 셀의 문자 노이즈에 같은 구멍이 있을 가능성은 보고서에 후속 과제로만 적혀 있고 확인하지 않았습니다. 그리고 6월 2일 기록의 “5종 중 1종이라 지장 없음”은 결과로 보면 틀린 판단이었습니다. 하나가 전체 상관을 깨는 데는 다섯 중 하나면 충분했습니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="troubleshooting" /><category term="capstone-dsc" /><category term="ml" /><category term="experiment-design" /><category term="data-quality" /><summary type="html"><![CDATA[데이터 품질 점수가 노이즈를 오히려 좋게 읽었다. 빼는 열화만 보던 지표 열 개에 노이즈 지표 하나를 더해 상관을 −0.16에서 +0.95로]]></summary></entry><entry><title type="html">GCP VM 위의 세 루프: proposer, reviewer, PR handler, 그리고 토큰 예산</title><link href="https://gary5876.github.io/portfolio/blog/2026/05/23/three-loops-on-a-gcp-vm/" rel="alternate" type="text/html" title="GCP VM 위의 세 루프: proposer, reviewer, PR handler, 그리고 토큰 예산" /><published>2026-05-23T00:00:00+00:00</published><updated>2026-05-23T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/05/23/three-loops-on-a-gcp-vm</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/05/23/three-loops-on-a-gcp-vm/"><![CDATA[<p>안녕하세요, 고준서입니다. vgc-ai는 IEEE CoG 2026 포켓몬 VGC AI 대회를 목표로 혼자 시작한 게임 AI 프로젝트예요. 5월 19일부터 23일 사이에 GCP 가상 서버 위에 셸 루프 네 개를 올려서, 코딩 에이전트가 전략을 제안하고 구현하면 통계 기준이 판정하고 사람 손 없이 기본 전략이 바뀌는 구조를 만들었습니다. 판정 기준은 <a href="/portfolio/blog/2026/05/12/wilson-gate-16-verdicts/">지난 글</a>에 있고, 이번 글은 루프 자체예요.</p>

<p>먼저 결과부터 적으면, 그 닷새 동안 루프는 세 번 멈췄고 세 번 다 이유는 같았습니다. 루프가 자기 상태를 몰랐어요. main이 이미 빨간지, 자기가 연 PR이 아직 열려 있는지, 지금 체크아웃이 main인지. 셋 다 한 줄짜리 확인으로 막았고, 그중 둘은 “2시간째 새 전략이 없다”, “30시간째 결과가 같다”는 부재를 사람이 로그에서 본 뒤에야 알았습니다.</p>

<p>이 글에는 루프가 어떤 순서로 하나에서 셋이 됐는지, 각각이 언제 Claude를 부르고 언제 부르지 않는지, 그리고 세 사고의 원인과 수정을 PR 본문과 코드 줄 번호로 적었습니다. 셋으로 나눈 구조와 한 프로세스에 다 두는 구조를 비교한 기록은 없습니다. 각 루프는 앞 루프가 못 하는 일이 드러났을 때 하나씩 생겼고, 그 이유는 PR 본문에 남아 있습니다.</p>

<h2 id="하나에서-셋이-된-순서">하나에서 셋이 된 순서</h2>

<p>5월 11일에 처음 올린 건 루프 하나였습니다. <code class="language-plaintext highlighter-rouge">TASKS.md</code>를 읽고 맨 위의 승인된 할 일을 하나 집어 구현하는 루프이고, 서두에는 이렇게 적혀 있었습니다.</p>

<blockquote>
  <p>The loop NEVER invents tasks. If it has an idea, it appends to <strong>Proposed for human review</strong>.</p>
</blockquote>

<p>그 뒤 reviewer가 붙었습니다. 벤치 CSV를 풀링해 게이트를 넘은 후보가 있으면 기본값을 바꾸자는 PR을 여는 루프예요. 그런데 reviewer 혼자서는 할 일이 없었습니다. <a href="https://github.com/gary5876/vgc-ai/pull/30">PR #30</a> 본문은 그 상태를 “registry had no new compounds for the reviewer to discover, so it correctly reported ‘no gate fires’ forever”라고 적었고, 5월 20일에 새 전략을 만들어 PR을 여는 proposer가 그래서 생겼습니다. “NEVER invents tasks”는 이때 넘어갔어요. 다만 만든 것이 곧바로 기본값이 되지는 못합니다. 새 전략은 머지된 뒤 bench 루프에 측정되고, 기본값을 이겨야 reviewer가 승격 PR을 열고, handler가 그 시점의 CSV로 다시 검증해야 머지됩니다. 같은 PR 본문에 이 순환이 “proposer hourly fire → new-compound PR → handler merges if CI green → bench loop measures it → if it beats default → reviewer catches it → bench-gate PR → handler merges if CI + bench re-verify pass → new default → repeat”로 적혀 있습니다.</p>

<pre><code class="language-mermaid">flowchart TD
    P[proposer&lt;br/&gt;1시간마다] --&gt;|새 전략 PR| H[PR handler&lt;br/&gt;1분마다]
    H --&gt;|CI 통과면 머지| B[bench 루프&lt;br/&gt;1분마다 CSV 적재]
    B --&gt;|풀링 결과| R[reviewer&lt;br/&gt;10분마다]
    R --&gt;|기본값 교체 PR| H
    H --&gt;|게이트 재검증 후 머지| D[(새 기본값)]
    D --&gt; B
</code></pre>

<p><em>그림 1. 네 루프의 순환. 제안, 판정, 집행이 서로 다른 프로세스에 있고, 어느 루프도 자기 PR을 자기가 머지하지 않습니다.</em></p>

<p>머지 권한을 가진 handler에는 LLM 호출이 없습니다. 이건 제가 정한 것인데, 왜 그렇게 정했는지를 문장으로 적어 둔 곳은 없어요. 지금 이유를 붙이면 “머지 여부를 확률로 정하고 싶지 않았다”인데, 당시 기록이 아니라 지금의 설명입니다.</p>

<h2 id="각-루프가-언제-claude를-부르나">각 루프가 언제 Claude를 부르나</h2>

<p>네 루프는 전부 같은 모양의 셸 스크립트입니다. <code class="language-plaintext highlighter-rouge">git pull</code>, <code class="language-plaintext highlighter-rouge">uv sync</code>, 파이썬 모듈 한 번, <code class="language-plaintext highlighter-rouge">sleep</code>. 다른 것은 주기와, 그 모듈이 Claude를 부르는지 여부입니다.</p>

<table>
  <thead>
    <tr>
      <th>루프</th>
      <th>주기</th>
      <th>하는 일</th>
      <th>Claude 호출</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>bench</td>
      <td>60초</td>
      <td>전략 전수 대전, CSV 행 추가</td>
      <td>없음</td>
    </tr>
    <tr>
      <td>reviewer</td>
      <td>600초</td>
      <td>CSV 풀링, 게이트 판정, 승격 PR</td>
      <td>게이트가 발화할 때만</td>
    </tr>
    <tr>
      <td>proposer</td>
      <td>3600초</td>
      <td>가장 접전인 트랙에 새 전략 제안 PR</td>
      <td>조건을 다 통과할 때만</td>
    </tr>
    <tr>
      <td>PR handler</td>
      <td>60초</td>
      <td>열린 루프 PR 머지 또는 닫기</td>
      <td>없음</td>
    </tr>
  </tbody>
</table>

<p><em>표 1. 루프별 주기와 Claude 호출 여부. 넷 중 둘은 LLM을 부르지 않습니다.</em></p>

<p>reviewer 모듈의 도크스트링에 순서가 있습니다. 자기가 연 PR이 아직 열려 있으면 멈춘다. 오늘 Claude 호출 횟수가 상한이면 멈춘다. CSV로 기준을 계산한다. 여기까지는 토큰 0이고, 기준을 넘은 후보가 있을 때만 <code class="language-plaintext highlighter-rouge">claude -p</code>를 한 번 띄웁니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/reviewer.py:16-17
</span><span class="o">-</span> <span class="o">**</span><span class="n">Step</span> <span class="mi">4</span><span class="o">**</span><span class="p">:</span> <span class="n">only</span> <span class="n">when</span> <span class="n">a</span> <span class="n">candidate</span><span class="sh">'</span><span class="s">s pooled ``ci95_low &gt; 0.5`` does a
  one-shot ``claude -p`` fire — no persistent session, ~5K input tokens
</span></code></pre></div></div>

<p>상한은 처음 12회였고, 5월 20일 PR #32에서 50회로 올렸습니다. 근거는 본문의 “Max 플랜은 잦은 호출은 감당하지만 낭비되는 호출은 싫다”였고, 같은 PR에서 직전 성공 후 30분 안에는 다시 발화하지 않는 <code class="language-plaintext highlighter-rouge">has_new_signal</code> 조건을 붙였습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/reviewer.py:48
</span><span class="n">DEFAULT_DAILY_CAP</span> <span class="o">=</span> <span class="mi">50</span>  <span class="c1"># bumped 2026-05-20 — user's Max plan tolerates frequent calls
</span></code></pre></div></div>

<p>proposer는 reviewer와 같은 예산 파일을 쓰고, 발화 한 번이 10K에서 20K 토큰이라 주기를 1시간으로 뒀습니다. 프롬프트에는 현재 기본값, 가장 강한 전략과 약한 전략, 최근 결과, 그리고 <code class="language-plaintext highlighter-rouge">ops/proposer_attempts.csv</code>에 적힌 이전 시도 목록이 들어갑니다. 직전 시도가 실패면 “뭐가 잘못됐는지 먼저 조사하라”, 성공이면 “비슷한 걸 또 만들지 말고 다른 각도로”라는 문장이 붙습니다.</p>

<p>handler인 <code class="language-plaintext highlighter-rouge">pr_auto_handler.py</code>는 PR 본문에서 <code class="language-plaintext highlighter-rouge">BENCH GATE</code> 또는 <code class="language-plaintext highlighter-rouge">NEW COMPOUND</code> 블록을 찾고, 네 관문을 순서대로 통과시킵니다. 30초보다 어리면 기다리고, 바뀐 파일이 허용 경로 밖이면 닫습니다(기본값 교체 PR은 <code class="language-plaintext highlighter-rouge">registry.py</code>와 <code class="language-plaintext highlighter-rouge">battle.py</code> 두 파일만, 새 전략 PR은 <code class="language-plaintext highlighter-rouge">policies/</code>, <code class="language-plaintext highlighter-rouge">eval/</code>, <code class="language-plaintext highlighter-rouge">strategies/</code>, <code class="language-plaintext highlighter-rouge">tests/</code> 아래만). <code class="language-plaintext highlighter-rouge">ruff format --check</code>, <code class="language-plaintext highlighter-rouge">ruff check</code>, <code class="language-plaintext highlighter-rouge">mypy --strict</code>, <code class="language-plaintext highlighter-rouge">pytest</code>가 하나라도 빨간 채 5분이 지나면 닫습니다. 그리고 기본값 교체 PR은 머지 직전에 그 시점의 CSV로 기준을 다시 계산합니다. reviewer가 PR을 연 뒤에도 bench 루프는 행을 계속 쌓으니, 그 사이에 신호가 죽었으면 머지하지 않는 겁니다. PR #28 본문은 이 재검증을 “reviewer의 스냅샷을 믿는 대신 재검증하는 단 하나의 가장 중요한 이유”라고 적었습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/pr_auto_handler.py:45-46
</span><span class="n">DEFAULT_MIN_AGE_SEC</span> <span class="o">=</span> <span class="mi">30</span>
<span class="n">DEFAULT_MAX_AGE_SEC</span> <span class="o">=</span> <span class="mi">300</span>  <span class="c1"># 5-min SLA
</span></code></pre></div></div>

<p>이 재검증이 있어도 풀링 표본이 15판에서 30판이라 기본값이 12일 동안 일곱 번 바뀌었습니다. 관문의 개수와 관문이 보는 표본의 크기는 다른 문제였고, 그건 따로 적을 생각입니다.</p>

<h2 id="첫-닷새-세-번-멈췄습니다">첫 닷새, 세 번 멈췄습니다</h2>

<p>5월 11일, 루프가 <a href="https://github.com/gary5876/vgc-ai/issues/1">이슈 #1</a>을 스스로 열었습니다. vgc2의 <code class="language-plaintext highlighter-rouge">Competitor</code>가 <code class="language-plaintext highlighter-rouge">Any</code>로 타입돼 있어 <code class="language-plaintext highlighter-rouge">mypy --strict</code>가 main에서 실패했고, 루프의 수용 기준이 strict mypy라 “main이 이미 빨간 상태에서는 어떤 자율 PR도 기준을 통과할 수 없다”고 적었습니다. 그날 커밋 60f21be(type-ignore 한 줄)로 고쳐지고 닫혔습니다. 루프가 자기를 멈추게 한 조건을 이슈로 보고한 건 이때 한 번뿐입니다.</p>

<p>5월 20일 <a href="https://github.com/gary5876/vgc-ai/pull/36">PR #36</a>의 원인 설명은 이렇습니다. 열린 PR을 세는 함수가 <code class="language-plaintext highlighter-rouge">BENCH GATE</code> 마커만 찾고 <code class="language-plaintext highlighter-rouge">NEW COMPOUND</code>는 찾지 않았습니다. 그래서 proposer는 자기가 연 PR이 아직 열려 있는데도 매시간 다시 발화했고, 여러 PR이 동시에 <code class="language-plaintext highlighter-rouge">registry.py</code>를 건드려 충돌이 났습니다. PR #33과 #35는 몇 시간 동안 충돌 상태로 있었고, handler는 1분마다 머지를 시도하다 실패했습니다. 새 전략이 하나도 안 들어온 2시간이 그 결과였습니다. 고친 것은 두 마커를 다 보게 한 것과, 마커가 줄 하나를 통째로 차지할 때만 인식하게 한 것입니다. 후자는 그 직전 PR #30의 첫 시도가 본문 표 안에 마커 문자열을 언급했다는 이유로 옛 handler에게 닫힌 데서 나왔습니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/pr_auto_handler.py:223-226
</span>    <span class="n">Strict</span> <span class="n">by</span> <span class="n">design</span> <span class="err">—</span> <span class="n">prose</span> <span class="n">mentions</span> <span class="n">of</span> <span class="sb">``</span><span class="n">BENCH</span> <span class="n">GATE</span><span class="sb">``</span> <span class="ow">or</span> <span class="sb">``</span><span class="n">NEW</span> <span class="n">COMPOUND</span><span class="sb">``</span>
    <span class="ow">in</span> <span class="n">markdown</span> <span class="n">formatting</span> <span class="n">must</span> <span class="ow">not</span> <span class="n">trigger</span> <span class="n">handler</span> <span class="n">attention</span><span class="p">.</span> <span class="n">The</span> <span class="n">PR</span>
    <span class="n">that</span> <span class="n">initially</span> <span class="n">added</span> <span class="n">this</span> <span class="n">strict</span> <span class="n">check</span> <span class="n">was</span> <span class="n">itself</span> <span class="n">closed</span> <span class="n">by</span> <span class="n">the</span> <span class="n">old</span>
    <span class="n">looser</span> <span class="n">handler</span> <span class="n">because</span> <span class="n">its</span> <span class="n">description</span> <span class="n">mentioned</span> <span class="n">the</span> <span class="n">markers</span> <span class="ow">in</span>
</code></pre></div></div>

<p>5월 22일 <a href="https://github.com/gary5876/vgc-ai/pull/45">PR #45</a>에 따르면 bench 체크아웃이 <code class="language-plaintext highlighter-rouge">auto/compound-damage_estimate_selection-20260522</code> 브랜치에 서 있었습니다. proposer가 auto 브랜치를 만들어 푸시한 뒤 main으로 돌아오지 않았고, 이후 매 사이클의 <code class="language-plaintext highlighter-rouge">git pull</code>은 그 auto 브랜치를 따라갔습니다. 이틀 전 머지된 PR #41, #42, #43이 가상 서버에 반영되지 않은 채 벤치가 돌고 있었고, 앞 글의 <code class="language-plaintext highlighter-rouge">championship_real</code> 수정이 거기 포함돼 있었으니 고친 하네스가 서버에서는 30시간 더 안 돌았던 셈입니다. 수정은 pull 앞에 체크아웃을 강제하는 세 줄입니다.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ops/run_bench.sh:47-49</span>
        git fetch <span class="nt">--quiet</span> origin main <span class="o">||</span> <span class="nb">true
        </span>git checkout <span class="nt">--quiet</span> main <span class="o">||</span> <span class="nb">true
        </span>git pull <span class="nt">--ff-only</span> <span class="nt">--quiet</span> <span class="o">||</span> <span class="nb">true</span>
</code></pre></div></div>

<p>셋 다 <code class="language-plaintext highlighter-rouge">|| true</code>가 붙어 있습니다. 이 스크립트는 <code class="language-plaintext highlighter-rouge">set -e</code>를 일부러 껐고, 한 판의 vgc2 내부 오류로 루프 전체가 죽으면 안 되며 실패한 단계를 건너뛰고 성공한 단계의 행이라도 남겨야 한다는 이유가 머리에 적혀 있습니다.</p>

<p>루프 밖에서 온 변경도 한 번 있었습니다. 5월 19일에 윈도우 PC의 로컬 세션이 발표자료 생성 스크립트 695줄을 <code class="language-plaintext highlighter-rouge">auto/</code> 브랜치에 스테이징해 둔 것을 발견해 받지 않았고, 이유는 <code class="language-plaintext highlighter-rouge">docs/archive/2026-05-19-orphan-presentation-attempt.md</code>에 남겼습니다. 루프가 만든 변경은 마커와 경로로 자동 판정되지만 루프 밖에서 온 변경은 판정 기준이 없어서, 이 경우는 기록을 남기는 것으로 대신했습니다.</p>

<h2 id="남은-것">남은 것</h2>

<p>루프 모듈 셋과 셸 스크립트 넷의 구현, PR 본문, 사고 원인 진단은 Claude가 했고, 해당 기간 커밋에 공동 작성자로 남아 있습니다. 제가 한 건 제안과 판정과 머지를 분리한다는 구조, 토큰 상한과 주기, handler에 LLM을 두지 않는다는 결정, 그리고 PR 검토와 머지입니다. 루프는 6월 7일 PR #61 머지를 끝으로 멈췄고, 왜 멈췄는지는 커밋에도 이슈에도 없습니다.</p>

<p>세 사고 다 한 줄짜리 확인으로 막았지만, 네 루프가 각자 로그 파일을 남길 뿐 그 로그를 읽는 다섯 번째 루프는 만들지 않았습니다. 같은 종류의 부재가 다시 오면 다시 사람이 로그에서 보게 될 거예요. 그리고 셋으로 나눈 구조가 한 프로세스보다 나았는지는 비교한 적이 없습니다. 이 구조에서 확실한 건 어느 루프도 자기 PR을 자기가 머지하지 않았다는 것 하나입니다.</p>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="vgc-ai" /><category term="coding-agents" /><category term="automation" /><category term="github-actions" /><summary type="html"><![CDATA[코딩 에이전트가 제안하고 통계 게이트가 판정하는 자율 루프가 어떤 순서로 셋이 됐고, 첫 닷새 동안 왜 세 번 멈췄나]]></summary></entry><entry><title type="html">팀빌딩을 LP로 푼 이유: 그리디 커버리지와 Nash 혼합전략, +111 ELO</title><link href="https://gary5876.github.io/portfolio/blog/2026/05/15/teambuild-lp-minimax/" rel="alternate" type="text/html" title="팀빌딩을 LP로 푼 이유: 그리디 커버리지와 Nash 혼합전략, +111 ELO" /><published>2026-05-15T00:00:00+00:00</published><updated>2026-05-15T00:00:00+00:00</updated><id>https://gary5876.github.io/portfolio/blog/2026/05/15/teambuild-lp-minimax</id><content type="html" xml:base="https://gary5876.github.io/portfolio/blog/2026/05/15/teambuild-lp-minimax/"><![CDATA[<p>안녕하세요, 고준서입니다. vgc-ai는 포켓몬 VGC 대회용 AI를 혼자 만들어 본 개인 프로젝트예요. 그중 Championship 트랙에서는 배틀을 시작하기 전에 30종 로스터에서 4종을 골라야 하는데, 이 4종을 고르는 정책을 2026년 5월 14일 하루 동안 세 번 바꿨습니다. 마지막 버전은 선형계획법(LP, 여러 제약 아래에서 가장 좋은 값을 찾는 계산법)으로 최악의 상대를 가정한 혼합전략을 풀어서 그 확률 순으로 종을 고릅니다. 그리디에서 LP까지 간 순서와, +111 ELO라는 숫자가 실제로는 어떤 모양이었는지를 PR 기록대로 적습니다.</p>

<h2 id="상대를-모르는-채로-4종을-고르는-문제">상대를 모르는 채로 4종을 고르는 문제</h2>

<p>로스터는 매 챔피언십마다 무작위로 30종이 주어집니다. 참가자는 그중 <code class="language-plaintext highlighter-rouge">max_team_size</code>인 4종을 골라 팀을 만들고, 배틀마다 그 팀에서 2종을 선출해 싸웁니다. 팀빌딩 시점에 쓸 수 있는 정보는 로스터 자체와, 에포크가 진행되면서 쌓이는 상대들의 사용률(meta)뿐이고, 첫 에포크에는 그 meta도 비어 있습니다.</p>

<pre><code class="language-mermaid">flowchart TD
    R[로스터 30종] --&gt; M[매치업 표 M&lt;br/&gt;쌍당 10판 시뮬]
    M --&gt; LP["LP: max v&lt;br/&gt;v ≤ pᵀM[:,j] for all j"]
    LP --&gt; P[혼합분포 p]
    P --&gt; S[p 상위 4종&lt;br/&gt;결정적 선택]
    S --&gt; T[팀 4종]
</code></pre>

<p><em>그림 1. 최종 버전의 팀빌딩 흐름. 매치업 표를 보수 행렬로 보고 LP를 풀어 나온 확률 순으로 4종을 고릅니다.</em></p>

<h2 id="세-번의-시도">세 번의 시도</h2>

<p>첫 팀빌더(<a href="https://github.com/gary5876/vgc-ai/pull/12">PR #12</a>)는 meta의 종별 사용률 상위 4종을 고르고, meta가 비어 있으면 종족값 합으로 대신했습니다. 무작위 팀빌더를 상대로 5에포크 15전 전승, +177 ELO였고, 이 숫자가 이후 모든 후보가 통과해야 하는 “무작위는 이기는가” 검사의 기준이 됐습니다.</p>

<p>두 번째는 타입 차트였습니다. 후보 종을 팀에 넣었을 때 공격 커버리지에서 약점을 뺀 값이 얼마나 늘어나는지를 사용률 점수에 가중해 더하는 그리디였어요(<a href="https://github.com/gary5876/vgc-ai/pull/15">PR #15</a>). 5시드 결과가 −30, +72, +98, −47, −111, 평균 −4 ELO였고, 가중치를 0.4로 두면 −32, 0.15로 낮추면 −4였습니다. PR 본문에 “Honest negative result”라고 적고 닫았고, 코드는 남기되 기본값은 바꾸지 않았습니다. 이 PR 본문의 결론이 다음 방향이 됐습니다. 타입 차트 휴리스틱을 더 만들 게 아니라 실제 배틀 시뮬레이션으로 로스터 간 매치업 표를 만들고 그 위에서 LP나 유전 알고리즘을 돌리자는 것이었습니다.</p>

<p>세 번째(<a href="https://github.com/gary5876/vgc-ai/pull/16">PR #16</a>)가 그 매치업 표입니다. 로스터의 모든 순서쌍 (i, j)에 대해 1대1 배틀을 열 판씩 돌려 승률 행렬 M을 만듭니다. 양쪽 다 <code class="language-plaintext highlighter-rouge">GreedyBattlePolicy</code>로 싸우고, <code class="language-plaintext highlighter-rouge">M[i][j]</code>는 i가 j를 이긴 비율, 대각선은 0.5입니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/eval/matchup_table.py:1-12
</span><span class="sh">"""</span><span class="s">Roster x Roster 1v1 matchup table built from actual battle simulation.

For each ordered pair (i, j) of distinct species in the roster, run
``n_battles_per_pair`` singleton-vs-singleton battles (one-mon teams,
``n_active=1``) with both sides playing ``GreedyBattlePolicy``. Return an
``N x N`` matrix where ``M[i][j]`` is species i</span><span class="sh">'</span><span class="s">s win rate against j over
those battles. Diagonal is 0.5 by convention.

Singleton battles are an approximation of the doubles-actually-played
contest format — they ignore positioning and double-targeting — but they
isolate raw 1v1 typing + stat + move synergy, which is the only signal the
team-build pre-game has. The table is computed once per roster and cached.
</span><span class="sh">"""</span>
</code></pre></div></div>

<p>이 표 위에서 그리디로 골랐습니다. 첫 종은 행 평균이 가장 높은 종, 그다음은 팀이 각 상대에게 낼 수 있는 최고 승률 <code class="language-plaintext highlighter-rouge">max_t(M[t][j])</code>의 평균을 가장 많이 올리는 종입니다. 사용률 상위 빌더와 10시드 대결에서 평균 +127 ELO, 7시드에서 양수였고, 표 하나 만드는 데 30종 기준 약 5초라 챔피언십당 한 번만 계산하고 캐시했습니다.</p>

<h2 id="lp로-최악의-상대를-가정하기">LP로 최악의 상대를 가정하기</h2>

<p>같은 날 저녁 네 번째 버전(<a href="https://github.com/gary5876/vgc-ai/pull/20">PR #20</a>)이 기본값이 됐습니다. 그리디 커버리지는 이길 수 있는 상대의 수를 늘리지만, 로스터에 가위바위보 구조가 있으면 한 갈래에 몰아 넣는 경향이 있습니다. 그래서 매치업 표를 두 사람이 서로 반대 목표를 가진 게임의 보수 행렬로 보고, 상대가 어떤 종을 내도 보장되는 최악의 승률을 가장 크게 만드는 확률 분포를 LP로 풀었습니다. 변수는 최악의 보수 v와 각 종을 고를 확률 p이고, 모든 상대 열 j에 대해 v가 p로 가중한 승률 이하가 되도록 두고 v를 최대화합니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/policies/teambuild.py:263-267
</span>        <span class="n">variables</span> <span class="n">x</span> <span class="o">=</span> <span class="p">[</span><span class="n">v</span><span class="p">,</span> <span class="n">p_0</span><span class="p">,</span> <span class="p">...,</span> <span class="n">p_</span><span class="p">{</span><span class="n">n</span><span class="o">-</span><span class="mi">1</span><span class="p">}]</span>
        <span class="n">minimize</span>   <span class="o">-</span><span class="nf">v             </span><span class="p">(</span><span class="n">i</span><span class="p">.</span><span class="n">e</span><span class="p">.</span> <span class="n">maximize</span> <span class="n">the</span> <span class="n">worst</span><span class="o">-</span><span class="k">case</span> <span class="n">payoff</span><span class="p">)</span>
        <span class="n">subject</span> <span class="n">to</span> <span class="n">v</span> <span class="o">-</span> <span class="n">p</span><span class="o">^</span><span class="n">T</span> <span class="n">M</span><span class="p">[:,</span> <span class="n">j</span><span class="p">]</span> <span class="o">&lt;=</span> <span class="mi">0</span>   <span class="k">for</span> <span class="n">each</span> <span class="n">column</span> <span class="n">j</span>
                   <span class="nf">sum</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="o">=</span> <span class="mi">1</span>
                   <span class="n">p_i</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="p">,</span>  <span class="n">v</span> <span class="n">unbounded</span>
</code></pre></div></div>

<p>풀이는 <code class="language-plaintext highlighter-rouge">scipy.optimize.linprog</code>에 맡겼습니다. 이 LP의 형태는 대회 주최자 Reis가 공개한 <code class="language-plaintext highlighter-rouge">vgc-agents</code>의 <code class="language-plaintext highlighter-rouge">get_policy</code>와 같고, 30종이면 10ms 안에 풀립니다. 나온 p는 어떤 종을 어떤 확률로 섞어야 최악의 상대에게도 v를 보장하는가인데, 여기서는 확률대로 뽑지 않고 p가 큰 순서로 4종을 정해진 대로 고릅니다.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/vgc_ai/policies/teambuild.py:318-321
</span>    <span class="n">p</span> <span class="o">=</span> <span class="nf">_solve_minimax_policy</span><span class="p">(</span><span class="n">table</span><span class="p">)</span>
    <span class="n">row_mean</span> <span class="o">=</span> <span class="n">table</span><span class="p">.</span><span class="nf">mean</span><span class="p">(</span><span class="n">axis</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
    <span class="n">order</span> <span class="o">=</span> <span class="nf">sorted</span><span class="p">(</span><span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">i</span><span class="p">:</span> <span class="p">(</span><span class="o">-</span><span class="n">p</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="o">-</span><span class="n">row_mean</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">i</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">order</span><span class="p">[:</span><span class="n">max_team_size</span><span class="p">]</span>
</code></pre></div></div>

<p>도크스트링에 적힌 이유는 LP 가중치를 매번 굴리는 샘플링 분포가 아니라 데려갈 종의 순위로 취급한다는 것, 호출마다 무작위가 섞이면 캐시가 깨지고 4종 사전 선택 문제에서는 그럴 필요가 없다는 것입니다. 구현은 코딩 에이전트가 했고 커밋에 <code class="language-plaintext highlighter-rouge">Co-Authored-By: Claude Opus 4.7</code>이 남아 있습니다. PR을 읽고 머지한 것과 scipy를 의존성에 넣는 것을 승인한 건 저입니다. 다만 PR #20에는 제가 남긴 리뷰 코멘트가 없어요. 제가 확인한 건 본문에 적힌 LP 정식화가 Reis의 <code class="language-plaintext highlighter-rouge">get_policy</code>와 같은 꼴이라는 설명과 아래 10시드 표였고, 그 이상 무엇을 검토했는지는 기록이 없습니다.</p>

<p>이전 기본값(그리디 커버리지)과 10시드로 붙인 결과가 이렇습니다. 표는 그리디 시점에서 본 ELO 차이라 음수가 minimax의 승리입니다.</p>

<table>
  <thead>
    <tr>
      <th>시드</th>
      <th>1</th>
      <th>2</th>
      <th>3</th>
      <th>4</th>
      <th>5</th>
      <th>6</th>
      <th>7</th>
      <th>8</th>
      <th>9</th>
      <th>10</th>
      <th>평균</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ELO 차이</td>
      <td>+344</td>
      <td>+11</td>
      <td>−271</td>
      <td>−423</td>
      <td>−354</td>
      <td>+255</td>
      <td>−423</td>
      <td>−26</td>
      <td>+11</td>
      <td>−238</td>
      <td>−111</td>
    </tr>
  </tbody>
</table>

<p><em>표 1. PR #20의 10시드 결과. 그리디 시점 부호이므로 음수가 minimax 승리입니다.</em></p>

<p>minimax가 이긴 시드는 6개이고 그중 2개는 전승(−423)입니다. 시드 1과 6에서는 그리디가 +344, +255로 크게 이겼고, 시드 2와 9는 +11로 사실상 동률입니다. 범위가 −423에서 +344까지 767 ELO 폭이고, PR 본문은 이를 “championship-level variance”라고 적었습니다. 평균 +111과 6/10을 근거로 승격했지만, 4개 시드에서 졌다는 사실은 표에 그대로 있습니다.</p>

<p>일주일 뒤 2시드 재벤치(<a href="https://github.com/gary5876/vgc-ai/pull/41">PR #41</a>)에서는 순수 Nash 빌더가 중위권이었고, 관측된 meta에 최적 대응하는 빌더는 최하위였습니다. 그래서 <a href="https://github.com/gary5876/vgc-ai/pull/48">PR #48</a>에서 목적함수를 (1 − blend) × v + blend × (meta 최적 대응 보수)로 섞은 <code class="language-plaintext highlighter-rouge">MinimaxMetaTeamBuildPolicy</code>를 추가했습니다. 기본 blend는 0.5이고 meta가 없으면 순수 Nash와 같은 결과를 냅니다. 이 0.5가 더 낫다는 증거는 없고, 도크스트링에도 “Worth A/B-ing if signal warrants”라고만 적혀 있습니다.</p>

<p>남은 문제도 적어 둡니다. 매치업 표는 한 쌍의 승률을 10판으로 추정하므로 각 칸이 크게 흔들리는데, LP는 그 표를 정확한 값처럼 풉니다. 판 수를 늘리면 5초짜리 사전 계산이 분 단위가 됩니다. 그리고 확률 순 상위 4종을 정해진 대로 고르는 것은 혼합전략의 핵심인 “섞어서 읽히지 않는다”를 버린 것이라, 상대가 로스터와 이 빌더를 알면 4종은 예측 가능합니다. 10시드 중 4시드에서 진 결과는 분산으로 두었고, 그 시드들의 로스터에 어떤 구조가 있었는지는 확인하지 않았습니다.</p>

<h2 id="참고-문헌">참고 문헌</h2>

<ul>
  <li>Reis, <code class="language-plaintext highlighter-rouge">vgc-agents</code> 저장소의 <code class="language-plaintext highlighter-rouge">teambuilders.py</code>, <code class="language-plaintext highlighter-rouge">get_policy</code>. 같은 형태의 max-min LP 정식화.</li>
  <li>SciPy, <code class="language-plaintext highlighter-rouge">scipy.optimize.linprog</code>. 본 글의 LP 풀이에 사용.</li>
</ul>]]></content><author><name>고준서</name><email>jerry0622@naver.com</email></author><category term="decisions" /><category term="vgc-ai" /><category term="linear-programming" /><category term="game-theory" /><category term="scipy" /><summary type="html"><![CDATA[상대를 모르는 상태에서 팀을 고르는 문제를 선형계획법으로 푼 이유와 실제 결과]]></summary></entry></feed>