<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발 메모장</title>
    <link>https://kir93.tistory.com/</link>
    <description>개발을 하며 새로 배운 지식과 에러 등에 대해 공유하는 블로그입니다.
문의사항은 kir931028@gmail.com로 연락바랍니다.</description>
    <language>ko</language>
    <pubDate>Thu, 13 Aug 2026 02:34:28 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Kir93</managingEditor>
    <image>
      <title>개발 메모장</title>
      <url>https://tistory1.daumcdn.net/tistory/4845731/attach/e53bf0e1bc8049a48445f74bd3397b43</url>
      <link>https://kir93.tistory.com</link>
    </image>
    <item>
      <title>멀티프로젝트 Claude Code 설정 운영기 1편 - 여러 프로젝트 .claude/를 한 저장소가 소유하게 만들기</title>
      <link>https://kir93.tistory.com/entry/%EB%A9%80%ED%8B%B0%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Claude-Code-%EC%84%A4%EC%A0%95-%EC%9A%B4%EC%98%81%EA%B8%B0-1%ED%8E%B8-%EC%97%AC%EB%9F%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-claude%EB%A5%BC-%ED%95%9C-%EC%A0%80%EC%9E%A5%EC%86%8C%EA%B0%80-%EC%86%8C%EC%9C%A0%ED%95%98%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code를 여러 프로젝트에서 쓰다 보면 설정이 사방에 흩어진다. 프로젝트마다 &lt;code&gt;.claude/&lt;/code&gt;(commands, rules, hooks, agents)가 있고, 루트엔 &lt;code&gt;CLAUDE.md&lt;/code&gt;가 있고, 공통으로 쓰고 싶은 룰과 훅이 있다. 처음엔 손으로 복사했다. 룰 하나를 고치면 대여섯 군데를 똑같이 고쳐야 했고, 어느 프로젝트는 갱신을 빼먹어 조용히 갈라졌다. &quot;설정을 한 곳에서 관리하고 각 프로젝트엔 링크만 걸자&quot;는 결론은 자연스러웠다. 문제는 그다음이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 개인 토이 프로젝트부터 회사 repo까지 예닐곱 개의 &lt;code&gt;.claude/&lt;/code&gt;를 저장소 하나에서 관리하려고 만든 &lt;code&gt;link.sh&lt;/code&gt; 작업기다. 셸 스크립트 300줄짜리지만, 진짜 어려웠던 건 코드가 아니라 &quot;어느 쪽이 원본인가&quot;라는 질문이었다. Claude Code든 유사한 에이전트 설정이든, 같은 걸 여러 repo에서 굴리는 사람이라면 그대로 겪는 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;i&gt;동작은 2026년 7월 기준 내 스크립트 구현이다.&lt;/i&gt;&lt;/u&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;dotfiles가 풀어주지 않는 세 가지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 떠오르는 건 dotfiles다. GNU Stow 같은 도구로 &lt;code&gt;$HOME&lt;/code&gt;에 심링크를 쫙 거는 방식. 그런데 내 요구는 dotfiles의 가정과 세 군데서 어긋났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 타깃이 하나(&lt;code&gt;$HOME&lt;/code&gt;)가 아니라 N개 프로젝트다. 게다가 프로젝트마다 공유하는 범위가 다르다 &amp;mdash; 공통 룰은 전부, 훅은 일부만, commands는 프로젝트 고유 + 공용이 섞인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 방향이 한쪽이 아니다. 내 개인 프로젝트는 설정 저장소가 원본이면 된다. 하지만 회사 repo는 &lt;b&gt;그 repo 자체가 &lt;code&gt;.claude/&lt;/code&gt;의 원본&lt;/b&gt;이어야 한다. 팀원이 그 repo만 클론해도 설정이 붙어 있어야 하니까. 내 저장소는 회사 repo에 &quot;공용 부분만 얹고&quot;, 반대로 회사 repo의 상태는 &quot;읽기용으로 미러&quot;만 해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 팀 공용 글로벌 설정은 아예 다른 repo(회사 공용 flow repo)에서 온다. 그건 내가 수정하는 게 아니라 읽기 전용으로 미러링해서 각 프로젝트에 얹는 대상이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dotfiles는 &quot;하나의 홈, 통째 심링크, 단방향&quot;을 가정한다. 내겐 &quot;여러 타깃, 부분 공유, 자원마다 다른 소유 방향&quot;이 필요했다. 그래서 얇은 스크립트를 직접 짰다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;저장소 하나를 진리원으로: registry와 apply&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계는 단순하다. 설정 저장소(여기선 &lt;code&gt;ai-config&lt;/code&gt;라 부르자) 안에 각 프로젝트의 store를 두고, &lt;code&gt;registry.json&lt;/code&gt;이 &quot;프로젝트 이름 &amp;rarr; 실제 경로 + 타입&quot;을 매핑한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;my-toy-app&quot;:    { &quot;path&quot;: &quot;~/dev/my-toy-app&quot;,     &quot;type&quot;: &quot;local&quot; },
  &quot;company-web&quot;:   { &quot;path&quot;: &quot;~/work/company-web&quot;,   &quot;type&quot;: &quot;external&quot; },
  &quot;design-system&quot;: { &quot;path&quot;: &quot;~/work/design-system&quot;, &quot;type&quot;: &quot;external&quot; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;./link.sh apply&lt;/code&gt;는 이 registry를 읽어 각 프로젝트에 심링크를 만든다. 심링크를 거는 함수는 이렇게 생겼다 &amp;mdash; 핵심은 멱등성과, &quot;실제 파일은 절대 덮어쓰지 않는다&quot;는 방어다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;create_symlink() {
  local source=&quot;$1&quot;  # ai-config 안의 경로 (심링크가 가리킬 대상)
  local target=&quot;$2&quot;  # 프로젝트 안의 경로 (심링크를 만들 위치)

  if [ -L &quot;$target&quot; ]; then
    # 이미 올바른 심링크면 아무것도 안 함 &amp;mdash; 몇 번을 돌려도 안전(멱등)
    [ &quot;$(readlink &quot;$target&quot;)&quot; = &quot;$source&quot; ] &amp;amp;&amp;amp; return 0
    rm &quot;$target&quot;                       # 엉뚱한 곳을 가리키면 교체
  elif [ -e &quot;$target&quot; ]; then
    # 실제 파일이 있으면 손대지 않는다. import로 store에 먼저 흡수할 것
    echo &quot;! $target 이미 존재(심링크 아님) &amp;mdash; import 먼저&quot; &amp;gt;&amp;amp;2
    return 1
  fi

  ln -s &quot;$source&quot; &quot;$target&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;짧지만 edge case가 벌써 셋이다. 올바른 심링크(무시), 엉뚱한 심링크(교체), 실제 파일(중단). 마지막이 특히 중요하다. 프로젝트에 이미 사람이 손으로 만든 &lt;code&gt;.claude/&lt;/code&gt;가 있으면 그걸 날려선 안 되고, 먼저 &lt;code&gt;import&lt;/code&gt;로 store에 흡수한 뒤 심링크로 바꿔야 한다. 이 방어 하나가 없으면 apply 한 번에 남의 설정을 덮어쓴다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;local과 external, 두 방향의 소유권&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타입이 두 개인 이유가 여기 있다. 같은 &lt;code&gt;apply&lt;/code&gt;라도 방향이 정반대다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;타입&lt;/th&gt;
&lt;th&gt;진리원&lt;/th&gt;
&lt;th&gt;&lt;code&gt;apply&lt;/code&gt;가 하는 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;local&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;설정 저장소&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.claude/&lt;/code&gt;를 통째로 저장소 store에 심링크. 저장소가 원본&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;external&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;프로젝트 repo&lt;/td&gt;
&lt;td&gt;프로젝트 실파일은 그대로 두고, 공용 리소스만 심링크로 주입 + 프로젝트 상태를 store로 미러(읽기용)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;local&lt;/code&gt;은 쉽다. 저장소가 원본이니 &lt;code&gt;.claude/&lt;/code&gt;를 통째 심링크한다. &lt;code&gt;external&lt;/code&gt;이 까다롭다. 프로젝트가 원본이므로 그 파일을 건드리면 안 되고, 공용 룰&amp;middot;훅만 심링크로 &quot;얹되&quot;, 팀 공용 글로벌에 이미 같은 이름이 있으면 그건 건너뛴다(중복 주입 방지):&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;# external 프로젝트: 팀 공용 global에 같은 이름이 있으면
# 개인 공용(shared) 주입을 건너뛴다 &amp;mdash; 팀 글로벌이 우선 소유
if [ &quot;$project_type&quot; = &quot;external&quot; ] \
   &amp;amp;&amp;amp; [ -f &quot;$GLOBAL_DIR/$resource_type/$file_name&quot; ]; then
  continue
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 1~2주면 도달하는 그림이다. 개인 프로젝트는 저장소&amp;rarr;프로젝트, 회사 프로젝트는 프로젝트&amp;rarr;저장소, 팀 글로벌은 회사 flow repo&amp;rarr;저장소로, 화살표(=진리원의 방향)가 자원마다 다르다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;config-ops-1-ownership.png&quot; data-origin-width=&quot;2380&quot; data-origin-height=&quot;1540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmPwKg/dJMcaaMUmYg/ftdYDvRHlfHIvB6V7Abd1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmPwKg/dJMcaaMUmYg/ftdYDvRHlfHIvB6V7Abd1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmPwKg/dJMcaaMUmYg/ftdYDvRHlfHIvB6V7Abd1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmPwKg%2FdJMcaaMUmYg%2FftdYDvRHlfHIvB6V7Abd1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2380&quot; height=&quot;1540&quot; data-filename=&quot;config-ops-1-ownership.png&quot; data-origin-width=&quot;2380&quot; data-origin-height=&quot;1540&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;되돌아보면, 위험은 충돌이 아니라 &quot;누가 진리원인지&quot;였다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심링크로 두 곳을 잇는 동기화를 짜면 누구나 파일 충돌을 걱정한다. 정작 나를 문 건 충돌이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사고는 이랬다. &lt;code&gt;external&lt;/code&gt; 프로젝트 중 하나가 공개 repo라 &lt;code&gt;.claude/&lt;/code&gt; 자체를 gitignore하고 있었다. 즉 그 프로젝트의 planning docs(스펙, 노트)는 프로젝트 git으로 동기화되지 않는다. 그래서 나는 그 docs를 &quot;설정 저장소가 소유&quot;하게 하고 프로젝트엔 심링크만 뒀다. 여기까진 좋았다. 문제는 다른 기기였다. 저장소를 최신으로 pull하지 않은 기기에서 &lt;code&gt;apply&lt;/code&gt;를 돌리자, 미러 단계가 &quot;프로젝트엔 없고 저장소엔 committed인&quot; 파일들을 &quot;프로젝트 기준으로 지워야 한다&quot;고 판단해 조용히 날렸다. commit해둔 문서가 사라진 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;교훈은 한 문장으로 남았다. &lt;b&gt;심링크 양방향 동기화의 진짜 위험은 충돌이 아니라, 어느 쪽이 진리원인지 모호해지는 순간이다.&lt;/b&gt; 그래서 세 겹의 가드를 넣었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 소유를 registry에서 &lt;b&gt;명시&lt;/b&gt;하게 했다. &lt;code&gt;.claude/&lt;/code&gt;(또는 docs만)를 저장소 단일 소유로 두는 opt-in 필드다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;&quot;client-app&quot;: {
  &quot;path&quot;: &quot;~/work/client-app&quot;,
  &quot;type&quot;: &quot;external&quot;,
  &quot;claude&quot;: &quot;ai-config&quot;   // .claude/ 전체를 저장소가 소유
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;claude: &quot;ai-config&quot;&lt;/code&gt;가 켜지면 미러는 자동으로 skip된다. store가 원본인데 프로젝트&amp;rarr;store 미러를 돌리는 건 말이 안 되기 때문이다. 신규 파일은 store에 쓰고 commit&amp;middot;push한 뒤, 다른 기기에서 pull&amp;middot;apply한다. 소유의 방향을 사람이 데이터로 적어두면, 코드가 그 방향을 어기지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, &lt;code&gt;apply&lt;/code&gt; 진입에서 저장소가 origin보다 뒤처졌는지 검사한다(&lt;b&gt;stale-repo 가드&lt;/b&gt;). 뒤처졌으면 삭제를 동반하는 cleanup&amp;middot;store 재작성 단계를 통째로 건너뛰고, 주입만 진행한다. 사고의 근본 원인 &amp;mdash; &quot;pull 안 한 채 apply&quot; &amp;mdash; 을 코드가 스스로 막는 것이다.&lt;/p&gt;
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;behind=&quot;$(git -C &quot;$STORE&quot; rev-list --count 'HEAD..@{u}')&quot;
if [ &quot;${behind:-0}&quot; -gt 0 ]; then
  STALE_REPO=1     # cleanup 함수들이 이 플래그를 보고 자기 검열한다
  echo &quot;! 저장소가 origin 대비 ${behind} commit 뒤처짐 &amp;mdash; cleanup skip(주입만 진행)&quot;
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 미러가 실제로 파일을 지우기 직전에 한 번 더 막는다(&lt;b&gt;손실 방지 가드&lt;/b&gt;). &quot;저장소 git에 committed인데 프로젝트엔 없는&quot; 파일을 지우려 하면, 그 하위 디렉토리 전체를 보류하고 목록을 출력한다. 의도된 삭제라면 &lt;code&gt;--force&lt;/code&gt;로만 강행한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;# store가 진리원인 subdir에서만 작동. committed인데 프로젝트엔 없는 파일이
# 삭제 대상이면 보류하고 목록을 출력 &amp;rarr; 사람이 확인. 의도된 삭제면 --force
while IFS= read -r tracked; do
  proj_equiv=&quot;$project_path/.claude/${tracked#*/.claude/}&quot;
  [ -e &quot;$proj_equiv&quot; ] || lost+=(&quot;$tracked&quot;)
done &amp;lt; &amp;lt;(git -C &quot;$STORE&quot; ls-files -- &quot;$store/.claude/$subdir&quot;)

if [ ${#lost[@]} -gt 0 ] &amp;amp;&amp;amp; [ &quot;$force&quot; != &quot;force&quot; ]; then
  printf '  보류: %s\n' &quot;${lost[@]}&quot; &amp;gt;&amp;amp;2
  return 2         # 이 subdir는 이번 미러에서 통째 보류
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가드의 공통점은, 방어 대상이 &quot;충돌&quot;이 아니라 &quot;정보 손실&quot;이라는 것이다. 심링크로 두 저장소를 잇는 순간, 코드는 &quot;사용자가 의도한 삭제&quot;와 &quot;stale 때문에 벌어진 사고&quot;를 구분하지 못한다. 그래서 애매하면 멈추게 했다. 이게 이 스크립트가 배운 가장 비싼 규칙이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;닫으며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 프로젝트의 에이전트 설정을 한 곳에서 관리하는 문제의 핵심 축은 &quot;심링크를 어떻게 거느냐&quot;가 아니라 &quot;자원마다 누가 진리원인가&quot;다. local/external로 방향을 가르고, 소유를 registry에 명시하고, 모호할 땐 삭제 대신 멈추게 하면, dotfiles가 못 풀던 부분 공유가 풀린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 할 수 있는 첫걸음은 이거다. 당신의 &lt;code&gt;.claude/&lt;/code&gt;를 열어 &quot;팀과 공유할 것 / 나만 쓸 것 / 원본이 딴 데 있는 것&quot; 세 무더기로 갈라보라. 소유 방향이 갈리는 그 지점이 곧 당신 스크립트의 타입 경계가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GNU Stow &amp;mdash; 전통적 dotfiles 심링크 관리 도구: &lt;a href=&quot;https://www.gnu.org/software/stow/&quot;&gt;https://www.gnu.org/software/stow/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ai 엔지니어링</category>
      <category>bash</category>
      <category>claude code</category>
      <category>dotfiles</category>
      <category>Git</category>
      <category>SymLink</category>
      <category>개발환경</category>
      <category>설정관리</category>
      <category>재현</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/208</guid>
      <comments>https://kir93.tistory.com/entry/%EB%A9%80%ED%8B%B0%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Claude-Code-%EC%84%A4%EC%A0%95-%EC%9A%B4%EC%98%81%EA%B8%B0-1%ED%8E%B8-%EC%97%AC%EB%9F%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-claude%EB%A5%BC-%ED%95%9C-%EC%A0%80%EC%9E%A5%EC%86%8C%EA%B0%80-%EC%86%8C%EC%9C%A0%ED%95%98%EA%B2%8C-%EB%A7%8C%EB%93%A4%EA%B8%B0#entry208comment</comments>
      <pubDate>Tue, 11 Aug 2026 07:51:08 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 11편 &amp;mdash; 설정도 코드처럼 부채가 쌓인다</title>
      <link>https://kir93.tistory.com/entry/%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-11%ED%8E%B8-%E2%80%94-%EC%84%A4%EC%A0%95%EB%8F%84-%EC%BD%94%EB%93%9C%EC%B2%98%EB%9F%BC-%EB%B6%80%EC%B1%84%EA%B0%80-%EC%8C%93%EC%9D%B8%EB%8B%A4</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;설정을 열었더니 코드 부채 냄새가 났다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;열 편에 걸쳐 권한 경계부터 검증 게이트, 자율 모드까지 AX(에이전트가 일하는 방식, Agent Experience) 체계를 쌓았다. 그러다 어느 날 설정 파일을 열었는데, 익숙한 냄새가 났다. 코드 부채의 냄새였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇 달 전에 넣은 룰이 지금 워크플로와 어긋나 있었고, 같은 지시가 두 파일에 조금씩 다르게 적혀 있었고, 언젠가 쓸 것 같아 넣어둔 커맨드는 한 번도 실행된 적이 없었다. 레거시와 차세대 프로젝트를 동시에 들고 있는 나 같은 프런트엔드 개발자에게, AI 설정은 어느새 또 하나의 코드베이스가 되어 있었다. 그리고 코드베이스라면, 추가만 하는 순간부터 망가지기 시작한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;설정에도 운영 루프가 필요하다 &amp;mdash; 흡수(+)와 정리(&amp;minus;)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 부채는 코드 부채와 똑같이 작동한다. 한 줄로 정의하면, &lt;b&gt;지금의 워크플로와 어긋나 있는데도 남아서 콘텍스트를 갉아먹는 모든 룰&amp;middot;커맨드&amp;middot;문서&lt;/b&gt;다. AI 설정은 특히 잘 쌓인다. 지우는 건 무섭고(혹시 나중에 필요하면?), 추가는 쉽고(트렌드 글 하나 읽을 때마다), 효과는 당장 눈에 안 보이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Anthropic이 context engineering에서 &quot;토큰을 curation 한다&quot;라고 말하는 것과 같은 이야기를, 나는 설정 파일 레벨에서 하고 있었다. 그래서 열 편을 지나며 내린 결론은 이거였다 &amp;mdash; 설정에도 운영 루프가 필요하다. 새로운 걸 안으로 들이는 &lt;b&gt;흡수(+)&lt;/b&gt;와, 낡은 걸 밖으로 덜어내는 &lt;b&gt;정리(&amp;minus;)&lt;/b&gt;, 두 방향으로.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흡수(+)는 다시 두 갈래다. 하나는 안에서 밖으로(inside-out) &amp;mdash; 내가 방금 끝낸 작업에서 반복되는 패턴을 뽑아 룰로 승격시키는 &lt;code&gt;/compound&lt;/code&gt;. 다른 하나는 밖에서 안으로(outside-in) &amp;mdash; 바깥의 믿을 만한 출처를 훑어 &lt;i&gt;내 설정 진화에 관련된 것만&lt;/i&gt; 끌어오는 &lt;code&gt;/my-radar&lt;/code&gt;. 둘 다 곧장 설정으로 들어가지 않고, 가운데 있는 &lt;b&gt;흡수 게이트&lt;/b&gt;를 통과해야 한다. 게이트가 하는 일은 채택이 아니라 판정이다: 수용(adopt) / 보류(watch) / 기각(skip).&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;fe-ax-11-operating-loop.png&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;955&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8DG2N/dJMcah6n7DV/8SCfAHockZY6MeYNhmX1K1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8DG2N/dJMcah6n7DV/8SCfAHockZY6MeYNhmX1K1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8DG2N/dJMcah6n7DV/8SCfAHockZY6MeYNhmX1K1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8DG2N%2FdJMcah6n7DV%2F8SCfAHockZY6MeYNhmX1K1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1766&quot; height=&quot;955&quot; data-filename=&quot;fe-ax-11-operating-loop.png&quot; data-origin-width=&quot;1766&quot; data-origin-height=&quot;955&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;바깥에서 안으로: /my-radar가 실제로 걸려 넘어진 곳&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 편에서 새로 붙인 건 outside-in 쪽, &lt;code&gt;/my-radar&lt;/code&gt;다. inside-out인 &lt;code&gt;/compound&lt;/code&gt;가 &quot;내 완료 작업에서 패턴을 뽑는다&quot;면, &lt;code&gt;/my-radar&lt;/code&gt;는 그 반대편에서 canonical 한 AI 출처들을 병렬로 fan-out 스캔해 delta만 뽑는다. 여기서 진짜 문제는 스캔이 아니라 &lt;b&gt;기억&lt;/b&gt;이었다. 트렌드 인테이크를 자동화하면 가장 먼저 부딪히는 게 &quot;어제 본 걸 오늘 또 들고 온다&quot;이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 2-tier memory를 뒀다. seen-ledger는 이미 본 URL을 통째로 차단해 스캔을 &lt;b&gt;멱등하게&lt;/b&gt; 만든다 &amp;mdash; 같은 소스를 다시 훑어도 새 항목만 나온다. watch-list는 &quot;지금은 아니지만 나중에 다시 볼 것&quot;을 따로 적재해 다음 스캔에서 재평가한다. 이 둘이 없으면 radar는 매번 같은 소음을 증분 없이 토해낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 더, cwd 이식성 함정이 있었다. 이 커맨드는 아무 프로젝트 폴더에서나 호출될 수 있는데, 상태 파일을 실행 위치(cwd) 기준으로 쓰면 엉뚱한 레포에 seen-ledger가 흩어진다. 그래서 커맨드는 항상 설정 레포 루트를 먼저 해석하고, 그 기준으로만 상대경로에 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 밝혀두면, radar가 뽑은 adopt 후보를 대상 설정 파일 경로에 매핑해 자동 변경 제안까지 잇는 &quot;닫힌 루프&quot;는 아직 구현이 아니라 설계 방향이다. 측정도 n=1 수동 실행 몇 번 뿐이라 자랑할 파이프라인은 없다. 다만 자동 인테이크가 실전에서 걸려 넘어지는 지점들(멱등성&amp;middot;재평가&amp;middot;경로 해석)을 먼저 풀어뒀다는 정도가 지금 손에 쥔 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커맨드 스펙은 결국 상태&amp;middot;절차&amp;middot;가드다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;outside-in 커맨드의 뼈대를 스펙으로 옮기면 이렇게 생겼다. 프롬프트 장식은 걷어내고 상태&amp;middot;절차&amp;middot;가드만 남겼다. 이 셋만 맞으면 나머지 문장은 취향이다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;# /my-radar &amp;mdash; outside-in 트렌드 인테이크 (스펙 발췌)
목적: canonical AI 출처를 fan-out 스캔 &amp;rarr; &quot;내 설정에 관련된&quot; 항목만 delta로 추출

상태(2-tier memory):
  seen-ledger.jsonl   # 이미 본 URL. 중복 완전 차단 &amp;rarr; 스캔을 멱등하게
  watch-list.md       # &quot;지금은 보류, 다음 스캔에서 재평가&quot; 후보

절차:
  1. 출처 fan-out 스캔 (병렬 서브에이전트)
  2. 각 항목을 seen-ledger와 대조 &amp;rarr; 신규만 통과   # edge: 중복 URL은 여기서 탈락
  3. 신규 항목을 delta로 분류: adopt / watch / reading / skip
  4. adopt에는 &quot;이 파일을 이렇게 바꾸자&quot; 제안을 붙인다 (경로 매핑)
     #  여기까지는 '제안'만 &amp;mdash; 자동 반영은 설계 방향(미구축)
  5. watch는 watch-list.md에 적재            # edge: 다음 스캔에서 재평가

이식성 가드 (cwd gotcha):
  # 이 커맨드는 임의의 프로젝트 폴더에서 실행될 수 있다.
  # cwd가 아니라 '설정 레포 루트'를 먼저 해석하고, 그 기준으로만 write 한다.
  repo_root = resolve_config_repo_root()
  write(f&quot;{repo_root}/my-radar/seen-ledger.jsonl&quot;, new_items)
  #     ✅ 레포 루트 기준          ❌ write(&quot;./seen-ledger.jsonl&quot;)  &amp;larr; cwd로 새면 상태가 흩어짐&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주석에 박아둔 세 지점 &amp;mdash; 중복 URL 탈락, watch 재평가, cwd 해석 &amp;mdash; 이 커맨드가 실전에서 깨지지 않는 이유의 거의 전부다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;흡수의 미덕은 속도가 아니라 판정이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 흡수를 &lt;i&gt;어떻게&lt;/i&gt; 하느냐라면, 더 중요한 건 흡수를 &lt;i&gt;언제 하지 않느냐&lt;/i&gt;였다. 초반엔 나도 좋은 글을 보면 곧장 룰로 만들었다. 게이트를 하나 더 다는 게 발전이라고 믿었으니까. 세 번의 흡수를 거치며 그 습관이 틀렸다는 걸 알았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째. Claude Code에 dynamic workflow라는 신기능이 나왔을 때, 이걸로 AX를 근본부터 단순화할 수 있나 진지하게 검토했다. 결론은 &quot;아직 이르다&quot;였다. 그런데 폐기하지 않고 watch-list에 남겼다. 흡수 게이트의 핵심은 &quot;받는 즉시 반영&quot;이 아니라 수용&amp;middot;보류&amp;middot;기각을 &lt;b&gt;판정&lt;/b&gt;하는 것이고, 보류는 실패가 아니라 정상 출력이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째. 정작 그 신기능이 값을 한 건 흡수 &lt;i&gt;대상&lt;/i&gt;이 아니라 흡수 &lt;i&gt;도구&lt;/i&gt;로 쓰였을 때였다. 에이전틱 코드리뷰에 관한 외부 글(Addy Osmani)을 그 dynamic workflow로 현 워크플로와 대조했더니, 채택 4건 &amp;middot; 기각 5건이 나왔다. 비 자명한 발견은 &lt;b&gt;채택 4건이 전부 &quot;새 게이트 0개, 기존 계약 문구를 reshape&quot;&lt;/b&gt;였다는 점이다. 기각 5건은 &quot;작성자의 서사를 그대로 되먹임&quot;하거나 &quot;잘못된 타이밍에 ceremony를 하나 더 얹는&quot; 것들이었다. 흡수의 레버리지는 LLM 게이트를 추가하는 데 있지 않고, 이미 있는 계약의 문구를 다듬는 데 있었다. (이 흡수의 채택분이 7편의 변조 탐지를 비롯해 이후 편들의 실제 출처가 됐다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째. 외부 codegraph 도구와 LLM 위키 생성기를 &quot;우리 워크플로에 합당한가&quot;를 팩트 기준으로 따진 뒤 도입을 보류했다. 정리(&amp;minus;) 루프가 무분별한 흡수를 막은 사례다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 흡수 3건 중 2건이 보류, 1건만 채택 &amp;mdash; 그 1건마저 새 게이트가 아니라 계약 reshape였다(내 워크플로에서의 관측치, n=3). 이 비율이 이번 편에서 하고 싶은 말의 전부다. 좋은 트렌드일수록, 받는 즉시 반영이 아니라 판정을 먼저 통과시켜야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리(&amp;minus;) 방향은 흡수의 거울상이다. stale&amp;middot;duplicate 룰을 주기적으로 걷어내고, curation report로 &quot;안 쓰는 룰&quot;을 가시화하고, 발행 전 pre-flight로 중복을 잡는다. 코드에 리팩터링이 있듯, 설정엔 curation이 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시리즈는 닫지만, 루프는 닫히지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정은 코드처럼 부채가 쌓인다. 그래서 계속 추가하는 것보다, 흡수(+)와 정리(&amp;minus;)를 도는 운영 루프가 어느 순간부터 더 중요해진다. 흡수의 미덕은 속도가 아니라 판정이다 &amp;mdash; 좋은 트렌드일수록 &quot;수용&amp;middot;보류&amp;middot;기각&quot; 중 어디인지를 먼저 묻는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당장 할 일은 하나다. 다음 룰을 추가하기 전에, 지난달 넣은 룰 중 오늘도 실제로 쓰는 게 몇 개인지 세어보라. 그 숫자가 당신에게 정리(&amp;minus;) 루프가 필요한지 말해준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Andrej Karpathy, &quot;Software Is Changing (Again)&quot; (Software 3.0) &amp;mdash; &lt;a href=&quot;https://www.ycombinator.com/library/MW-andrej-karpathy-software-is-changing-again&quot;&gt;https://www.ycombinator.com/library/MW-andrej-karpathy-software-is-changing-again&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Anthropic, &quot;Effective context engineering for AI agents&quot; &amp;mdash; &lt;a href=&quot;https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents&quot;&gt;https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Addy Osmani, &quot;Agentic Code Review&quot; &amp;mdash; &lt;a href=&quot;https://addyosmani.com/blog/agentic-code-review/&quot;&gt;https://addyosmani.com/blog/agentic-code-review/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;i&gt;&lt;span style=&quot;color: #666666; text-align: center;&quot;&gt;본문의 흡수 채택/기각 수치는 내 워크플로에서의 관측 count(n=3)이며 일반 통계가 아니다. 신기능&amp;middot;도구의 동작은 2026년 상반기 기준이다.&lt;/span&gt;&lt;/i&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kir93.co.kr/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-10%ED%8E%B8-%EB%8B%A8%EA%B0%80%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%8B%A4%ED%8C%A8%EB%B9%84%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%AA%A8%EB%8D%B8%EC%9D%84-%EA%B3%A0%EB%A5%B8%EB%8B%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.07.23 - [AI 엔지니어링] - 프런트엔드 AX 설계기 10편 - 단가가 아니라 실패비용으로 모델을 고른다&lt;/a&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ax</category>
      <category>claude code</category>
      <category>config curation</category>
      <category>기술 부채</category>
      <category>멱등성</category>
      <category>설정 관리</category>
      <category>워크플로 자동화</category>
      <category>트렌드 인테이크</category>
      <category>프런트엔드</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/207</guid>
      <comments>https://kir93.tistory.com/entry/%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-11%ED%8E%B8-%E2%80%94-%EC%84%A4%EC%A0%95%EB%8F%84-%EC%BD%94%EB%93%9C%EC%B2%98%EB%9F%BC-%EB%B6%80%EC%B1%84%EA%B0%80-%EC%8C%93%EC%9D%B8%EB%8B%A4#entry207comment</comments>
      <pubDate>Tue, 4 Aug 2026 07:00:35 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 10편 - 단가가 아니라 실패비용으로 모델을 고른다</title>
      <link>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-10%ED%8E%B8-%EB%8B%A8%EA%B0%80%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%8B%A4%ED%8C%A8%EB%B9%84%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%AA%A8%EB%8D%B8%EC%9D%84-%EA%B3%A0%EB%A5%B8%EB%8B%A4</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;모든 command가 opus를 쓰는데, command별로 다른 model을 쓰게 할 수 있나?&quot;&lt;/b&gt; 한 세션에서 이 질문으로 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬래시 command마다 하는 일이 다른데 전부 최상위 모델을 태우는 게 낭비처럼 보였다. 답은 기술적으로 &quot;된다&quot;였다. slash command frontmatter에 &lt;code&gt;model:&lt;/code&gt;만 박으면 command별로 티어가 갈린다. 그래서 나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그리고 한 달 뒤 되돌렸다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 왕복의 기록이다. AI 에이전트를 여러 개 굴리는 팀이라면 언젠가 &quot;이건 싼 모델로 충분하지 않나?&quot;를 마주한다. 나는 command 단위로 한 번 싸게 내려봤다가 철회했고, 반대로 특정 에이전트 하나는 측정을 거쳐 opus로 올렸다. 결론부터 말하면, 모델 선택의 축은 토큰 단가가 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;command 재실행, 사람이 붙어 검수, 잘못된 산출물이 하류로 흘러가 터지는 사고. &lt;b&gt;출력 변동성&lt;/b&gt;은 같은 입력에도 결과가 흔들리는 정도. 이 둘의 곱이 크면 비싼 모델이 값을 하고, 작으면 단가 절감이 이긴다. (이 시리즈에서 말하는 AX는 사람이 아니라 에이전트가 상대하는 인터페이스를 설계하는 일이다. 어떤 모델을 태울지 고르는 것도 그 인터페이스의 일부다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 그림은 두 판단으로 갈린다. 위쪽은 &quot;어디에 비싼 모델을 쓰나&quot;를 실패비용 축에 늘어놓는 일이고, 아래쪽은 그 판단을 감이 아니라 측정으로 내리는 루프다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;fe-ax-10-model-tiering.png&quot; data-origin-width=&quot;2090&quot; data-origin-height=&quot;1453&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceeKaR/dJMcaiqALml/Ea9kFTOtY5CMdER6XzrLqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceeKaR/dJMcaiqALml/Ea9kFTOtY5CMdER6XzrLqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceeKaR/dJMcaiqALml/Ea9kFTOtY5CMdER6XzrLqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceeKaR%2FdJMcaiqALml%2FEa9kFTOtY5CMdER6XzrLqK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2090&quot; height=&quot;1453&quot; data-filename=&quot;fe-ax-10-model-tiering.png&quot; data-origin-width=&quot;2090&quot; data-origin-height=&quot;1453&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;싸게 내렸다가, 한 달 만에 되돌렸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이렇게 갈랐다. commit 메시지 뽑는 command는 haiku, PR 계열은 sonnet. 로직은 깔끔했다 &amp;mdash; 짧고 정형화된 작업엔 싼 모델. 그런데 돌려보니 싼 모델의 출력이 들쭉날쭉했다. commit 메시지 하나가 어긋나면 다시 돌리고, 다시 돌린 command가 또 어긋나면 사람이 손으로 고친다. 그 재실행&amp;middot;수정 비용이 아낀 토큰값을 금방 넘어섰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 나는 구독제(Max)를 쓴다. 토큰을 아껴도 청구서가 줄지 않는다 &amp;mdash; 절감 인센티브 자체가 얇다. 그래서 commit 모델을 haiku에서 sonnet으로 되돌리고, 결국 override를 통째로 걷어냈다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# before &amp;mdash; command마다 티어를 박았다
---
description: commit 메시지 생성
model: haiku      # &amp;larr; 싸게. 하지만 재실행 비용이 절감액을 삼켰다
---

# after &amp;mdash; override 제거 &amp;rarr; 전부 기본(상위) 티어로 원복
---
description: commit 메시지 생성
# model 라인을 지운다. frontmatter가 티어를 강제하지 않게
---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 첫 교훈. &lt;b&gt;구독&amp;middot;Max 환경에서 일괄 티어 다운의 ROI는 낮다.&lt;/b&gt; 종량제라면 계산이 다르겠지만, 한도가 시간 단위로 리셋되는 구독제에선 &quot;싸게&quot;의 이득이 얇고 실패비용이 판을 지배한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정반대로 &amp;mdash; 딱 한 에이전트만 opus로 올렸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 반대 방향의 아크도 있었다. 다른 세션에서 이런 질문을 받았다. &quot;sonnet 쓰는 부분 있나? librarian은 뭐 하는 에이전트고, opus면 퀄리티 차이가 얼마나 나?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;librarian은 외부 문서를 가져와 콘텍스트에 붙이는 에이전트다. 여기서 환각은 곧 잘못된 출처다. 없는 문서를 지어내거나 링크를 틀리면, 그 위에 쌓는 판단이 전부 무너진다. 실패비용이 큰 자리다. 그래서 이번엔 내릴지가 아니라 올릴지를 측정했다. sonnet과 opus에 같은 작업을 시키고 점수를 매겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 비 자명한 게 튀어나왔다. &lt;b&gt;바꿨더니 opus인데도 점수가 떨어지는&lt;/b&gt; 회차가 나온 것이다. 직관과 정반대다. 나는 결론을 내리기 전에 평가 자체를 의심했다. &quot;계속 결과가 달라지는데, 이게 실제로 효과 있는 건가?&quot; 0.5점 차이가 실질인지 심판의 분산(노이즈)인지부터 따졌다. 측정값을 곧이곧대로 믿지 않는 것, 이게 두 번째 교훈이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;감이 아니라 측정으로: 블라인드 A/B 테스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 &quot;따지는&quot; 방법이 블라인드 A/B 테스트이다. 같은 입력을 두 후보 모델에 주고, 어느 쪽 출력인지 모르는 제3의 심판이 채점한다. 게이트 채점을 어떤 모델로 할지도 같은 방식으로 정했다. 얼개는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# model-ab.yaml &amp;mdash; 판단 워크로드 모델을 블라인드 A/B로 고르는 하네스
inputs: corpus/*.json          # 같은 입력 셋을 두 후보에 동일하게 먹인다
candidates:
  A: sonnet
  B: opus
constraints:
  token_parity: [0.99, 1.01]   # 토큰 동률: 한쪽이 장황해서 유리해지는 걸 차단
judge:
  model: fable                 # 제3의 심판
  blind: true                  # 어느 출력이 어느 모델인지 가린다
  rubric: [사실_정확도, 출처_타당성, refuted_라벨_일치]
  repeat: 5                    # 같은 쌍을 여러 번 &amp;mdash; 분산을 보려고
report:
  # 평균만 보지 말 것. 표준편차가 점수차보다 크면 '동률'로 판정
  flag_if: stdev &amp;gt;= abs(mean_A - mean_B)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 세 줄이다. &lt;code&gt;token_parity&lt;/code&gt;로 출력 길이를 맞춰 &quot;장황한 쪽이 유리&quot;를 막고, &lt;code&gt;blind: true&lt;/code&gt;로 심판이 브랜드를 모르게 하고, &lt;code&gt;repeat&lt;/code&gt;로 같은 쌍을 여러 번 돌려 분산을 본다. 마지막 &lt;code&gt;flag_if&lt;/code&gt;가 함정을 잡는 가드다 &amp;mdash; 표준편차가 두 모델 점수차보다 크면 그 차이는 노이즈로 보고 동률 처리한다. &quot;opus인데 점수가 떨어지는&quot; 역설의 대부분은 여기서 걸러진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 하네스로 실제 나온 숫자(개인 벤치 기준): 사실 정확도 9.18 대 8.18, refuted 라벨이 엇갈린 4건은 뜯어보니 전부 한쪽 판정이 타당했다. 0.5점이 아니라 1점 차에 라벨 불일치까지 한 방향으로 쏠렸으니, 이건 분산이 아니라 실질이다. 그래서 librarian은 opus로 올리고 환각 억제 프로토콜을 함께 강화했다. 모델 라인업은 빠르게 바뀌니, 저 숫자보다 방법을 가져가는 게 낫다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실패비용이 큰 자리만, 그리고 한도에 밀릴 때&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 잣대를 auditor(룰 스캔 에이전트)에도 대봤다. 결과는 승격 보류. 룰 스캔은 정해진 규칙을 훑는 일이라 환각 민감도가 낮다 &amp;mdash; 실패비용이 작으니 opus로 올릴 값이 안 나온다. &lt;b&gt;실패비용이 큰 에이전트만 opus.&lt;/b&gt; 이 비대칭이 요점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;전부 싸게&quot;도 &quot;전부 비싸게&quot;도 답이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에지 케이스가 하나 더 있다. 측정으로 상위 티어를 정해놨는데 한도에 걸려 강제로 다운그레이드되는 순간이다. &quot;측정 없는 다운그레이드 금지&quot;가 내 원칙인데, 한도는 그걸 안 봐준다. 여기서 원칙을 깨는 대신 복귀 조건을 미리 박아뒀다 &amp;mdash; 한도가 회복되면 재측정 후 원래 티어로 되돌리는 earn-back 룰. 강등은 임시고, 복귀는 반드시 측정을 거친다. 팀이라면 요금제 한도 편차(상위 Max와 그보다 낮은 팀원 한도)까지 변수로 넣어 &quot;팀 전체가 감당 가능한 선&quot;으로 티어를 잡는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델 선택의 축은 토큰 단가가 아니라 실패비용 &amp;times; 출력 변동성이다. 전부 싸게도 전부 비싸게도 아니고, 실패비용이 큰 자리만 골라 측정을 거쳐 올린다. 그리고 측정값은 분산부터 의심하고, 한도에 밀려 내려야 하면 복귀 조건을 미리 정해둔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당신의 에이전트를 실패비용 순으로 한 줄로 세워 보라. &lt;b&gt;맨 위 하나만 블라인드 A/B로 재보고, 값이 나오면 그것만 올려라.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;background-color: #e6f5ff; color: #0070d1; text-align: start;&quot; href=&quot;https://kir93.co.kr/entry/%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-9%ED%8E%B8-%E2%80%94-%EC%99%B8%EB%B6%80-%EC%9D%B4%EC%8A%88-%ED%8A%B8%EB%9E%98%EC%BB%A4-write-%EB%8F%84%EA%B5%AC%EB%A5%BC-%EA%B6%8C%ED%95%9C-%EA%B2%BD%EA%B3%84-%EC%95%88%EC%97%90-%EA%B0%80%EB%91%90%EA%B8%B0&quot;&gt;2026.07.01 - [AI 엔지니어링] - 프런트엔드 AX 설계기 9편 - 외부 이슈 트래커 write 도구를 권한 경계 안에 가두기&lt;/a&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ai 엔지니어링</category>
      <category>claude code</category>
      <category>eval</category>
      <category>LLM-as-Judge</category>
      <category>모델 선택</category>
      <category>비용 최적화</category>
      <category>에이전트</category>
      <category>프롬프트 엔지니어링</category>
      <category>환각</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/206</guid>
      <comments>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-10%ED%8E%B8-%EB%8B%A8%EA%B0%80%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%8B%A4%ED%8C%A8%EB%B9%84%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%AA%A8%EB%8D%B8%EC%9D%84-%EA%B3%A0%EB%A5%B8%EB%8B%A4#entry206comment</comments>
      <pubDate>Tue, 28 Jul 2026 07:00:30 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 9편 - 외부 이슈 트래커 write 도구를 권한 경계 안에 가두기</title>
      <link>https://kir93.tistory.com/entry/%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-9%ED%8E%B8-%E2%80%94-%EC%99%B8%EB%B6%80-%EC%9D%B4%EC%8A%88-%ED%8A%B8%EB%9E%98%EC%BB%A4-write-%EB%8F%84%EA%B5%AC%EB%A5%BC-%EA%B6%8C%ED%95%9C-%EA%B2%BD%EA%B3%84-%EC%95%88%EC%97%90-%EA%B0%80%EB%91%90%EA%B8%B0</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;트래커 MCP를 붙인 날, 공들여 세운 권한 경계가 조용히 뚫렸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사내 워크플로에 외부 이슈 트래커(여기서는 'Jira류 이슈 트래커'로 부른다)의 MCP를 붙인 날이었다. 붙이자마자 에이전트는 플랜을 진행하다가 스스로 티켓을 만들고, 상태를 옮기고, 담당자를 바꿀 수 있게 됐다. 처음엔 좋아했다. &quot;이제 플랜이랑 트래커가 알아서 붙는구나.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 날 아침에 불편해졌다. 나는 이 시리즈 1&amp;middot;2&amp;middot;8편에서 슬래시 커맨드&amp;middot;에이전트&amp;middot;자율 모드의 권한을 한 겹씩 좁혀 왔다. 그런데 방금 붙인 이 write 도구는 그 경계를 전부 우회해서 팀 바깥 시스템을 바꾸고 있었다. 내가 문 앞에 세워 둔 검문소를, 뒷문으로 들어온 손님이 그냥 지나친 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MCP(Model Context Protocol)는 에이전트가 외부 도구&amp;middot;데이터에 붙는 표준 인터페이스다(2026년 6월 기준으로 생태계가 빠르게 커지는 중이다). 문제는 도구의 종류다. 읽기 도구는 상태를 안 바꾸지만, write 도구 &amp;mdash; 티켓 생성&amp;middot;수정&amp;middot;전이 &amp;mdash; 는 팀 바깥 상태를 바꾸는 부수효과(side-effect)다. 권한 등급으로 치면 이건 내가 가장 조심하던 쪽, 외부로 나가는 변경을 낼 수 있는 &lt;b&gt;빌더급&lt;/b&gt;(우리 설정에서 쓰기 권한이 가장 넓은 실행 에이전트)이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;write 도구의 기본값은 &quot;게이트 우회&quot;다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8편에서 나는 publishing gate를 세웠다. 되돌릴 수 없거나 팀 바깥으로 나가는 작업 앞에는 사람이 선다는 규칙이다. 그런데 외부 write 도구는 그 앞에 서지 않는다. 아무도 시키지 않아도 티켓을 만들 수 있다는 건, 정의상 게이트를 우회한다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 위험한 건 번짐이다. 티켓을 자동으로 옮길 수 있으면 &quot;이왕이면 커밋도, 이왕이면 푸시도, PR도 자동으로&quot;라는 유혹이 자연스럽게 따라온다. write 권한 하나를 열어 두면 그 옆의 권한까지 슬금슬금 자동화되기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 git에는 원래 제동 장치가 있다. push 하려면 원격이 있어야 하고, PR을 열려면 브랜치가 먼저 올라가 있어야 한다. 그런데 트래커 write 도구에는 그런 마찰이 없다. 토큰 하나면 티켓이 즉시 팀 바깥에 생긴다. 마찰이 없다는 건 도구가 경계를 대신 지켜 주지 않는다는 뜻이고, 그러면 경계는 내가 직접 세워야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 열되, 딱 세 조건으로만 열기로 했다. 자동 실행은 (1) 플랜 lifecycle 이벤트에 묶여서, (2) registry에 allowlist 된 프로젝트에서만, (3) 문서에 미리 선언된 자동 side-effect로만 일어난다. 그리고 &lt;b&gt;커밋&amp;middot;푸시&amp;middot;PR로는 절대 확장하지 않는다.&lt;/b&gt; 이 셋은 여전히 사람이 서는 게이트로 남긴다. 이 문장을 글로벌 설정 문서와 권한 경계 매트릭스에 그대로 성문화했다 &amp;mdash; 코드가 아니라 문장으로 못을 박아 둬야, 다음에 도구를 하나 더 붙일 때 경계가 흔들리지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상태 머신이 사람의 작업 흐름을 따라가게&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;허용한 자동화는 딱 하나의 상태 머신이다. 플랜 lifecycle을 트래커 상태에 이렇게 매핑했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;fe-ax-9-permission-boundary.png&quot; data-origin-width=&quot;1765&quot; data-origin-height=&quot;1184&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxfojl/dJMcabdCG57/1Fx0fzOryOwV0IofkJsKrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxfojl/dJMcabdCG57/1Fx0fzOryOwV0IofkJsKrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxfojl/dJMcabdCG57/1Fx0fzOryOwV0IofkJsKrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbxfojl%2FdJMcabdCG57%2F1Fx0fzOryOwV0IofkJsKrK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1765&quot; height=&quot;1184&quot; data-filename=&quot;fe-ax-9-permission-boundary.png&quot; data-origin-width=&quot;1765&quot; data-origin-height=&quot;1184&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플랜을 &lt;b&gt;등록&lt;/b&gt;하면 티켓을 만들되 담당자는 비워 둔다. 아직 누가 잡을지 모르니까. 플랜이 &lt;b&gt;진행&lt;/b&gt;으로 전이하는 순간 상태를 &quot;In Progress&quot;로 옮기고, 그때 &lt;b&gt;실제 작업자로 담당자를 지정&lt;/b&gt;한다. cleanup 단계에서 &lt;b&gt;리뷰&lt;/b&gt;로 전이한다. 핵심은 담당자를 &quot;미지정 &amp;rarr; 실제 작업자&quot;로 바꾸는 지점이 '진행 전이'라는 것이다. 트래커의 상태 머신이 사람의 작업 흐름을 그대로 따라간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 화살표 어디에도 커밋&amp;middot;푸시&amp;middot;PR은 없다. 그림에서 아래쪽 벽 바깥에 있는 것들이다. 자동으로 번질 뻔한 부수효과를, 다이어그램 한 장으로 &quot;여기까지&quot;라고 눈에 보이게 그어 뒀다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;경로가 아니라 이름으로 프로젝트를 가둔다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 체크아웃 경로로 프로젝트를 식별했다. 곧 깨졌다. 팀원마다 저장소를 클론 한 위치가 달라서, 경로 기반 규칙은 내 노트북에서만 동작했다. 그래서 registry를 &lt;b&gt;프로젝트 이름 &amp;rarr; 트래커 매핑&lt;/b&gt;으로 다시 짰다. 이름은 사람마다 같지만 경로는 사람마다 다르다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;// plan-sync.registry.json
// 프로젝트를 &quot;이름&quot;으로 식별한다(경로 X: 체크아웃 위치가 사람마다 다르므로 이식성 0).
{
  &quot;projects&quot;: {
    &quot;project-a&quot;: {                 // 실제 내부 프로젝트명 대신 가명
      &quot;tracker&quot;: &quot;B-system&quot;,       // A 프로젝트 &amp;rarr; B 시스템 라벨(가명 매핑)
      &quot;project_key&quot;: &quot;BSYS&quot;,
      &quot;sync&quot;: true                 // allowlist 플래그: 여기 없거나 false면 자동 동기화 skip
    }
  },
  &quot;assignees&quot;: {                   // 팀원 매핑도 registry로 관리하고
    &quot;alice&quot;: &quot;alice@example.com&quot;,  // 설정 저장소의 배포 명령으로 각 머신에 동일하게 뿌린다
    &quot;bob&quot;:   &quot;bob@example.com&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고정된 인원이라 &quot;굳이 JSON이냐, 코드에 박으면 안 되냐&quot;를 실제로 따져봤다. 결론은 JSON이었다. 매핑이 코드가 아니라 데이터일 때 배포&amp;middot;수정&amp;middot;리뷰가 가장 단순했다. 그리고 lifecycle 후킹은 이렇게 선언한다. 문장으로 먼저 못을 박고, 훅은 그 문장을 그대로 옮긴 것뿐이다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;# 선언된 자동 side-effect (권한 경계 문서에 명문화)
# registry.sync == true 인 프로젝트에 한해, 플랜 lifecycle 이벤트에서만 트래커 write 허용.

on plan.register(project):
    if not registry[project].sync: return               # allowlist 밖 &amp;rarr; 조용히 skip (에러로 멈추지 않음)
    ticket = tracker.create(title=plan.title, assignee=None)      # 등록: 담당자 미지정

on plan.progress(project, worker):
    if not registry[project].sync: return
    tracker.transition(ticket, &quot;In Progress&quot;)
    tracker.assign(ticket, registry.assignees[worker])           # 진행 전이: 실제 작업자로 지정

on plan.cleanup(project):
    if not registry[project].sync: return
    tracker.transition(ticket, &quot;In Review&quot;)                      # 리뷰로 전이

# 경계: commit / push / PR 는 이 훅 어디에도 없다 &amp;mdash; 영원히 사람 게이트로 남긴다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;edge case를 두 개 심어 뒀다. 하나는 allowlist 밖 프로젝트를 만났을 때 에러로 파이프라인을 멈추지 않고 &lt;b&gt;조용히 skip&lt;/b&gt;하는 것 &amp;mdash; 동기화는 부가 기능이지 본류가 아니니, 없으면 없는 대로 지나가야 한다. 다른 하나는 다음 절의 함정이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상류가 바뀌면 나도 바뀐다 &amp;mdash; 감수하기로 한 결합&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 조용히 어긋났다. 플랜은 진행으로 넘어갔는데 트래커의 티켓은 등록 상태 그대로였다. 로그를 열어 보니 어느 날부터 &lt;code&gt;create&lt;/code&gt;가 실패하고 있었다 &amp;mdash; 트래커 운영 방식이 바뀌어 티켓 생성에 필수 필드가 하나 늘어 있었던 것이다. 나는 훅을 고쳐 그 필드를 채워야 했다. 외부 write에 자동 권한을 준다는 건, 상류 시스템의 운영 변경에 내 워크플로가 묶인다는 뜻이다. 공짜 자동화는 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 감당할 만했던 이유는 경계를 좁혀 뒀기 때문이다. 자동 side-effect가 lifecycle 세 지점에만 있으니, 고칠 곳도 딱 그 세 군데였다. 만약 write 도구를 열어 두고 여기저기서 자유롭게 부르게 놔뒀다면, 상류 한 번 바뀔 때마다 어디가 깨졌는지 찾아 헤맸을 것이다. 경계는 권한을 위한 것이면서, 동시에 유지보수를 위한 것이기도 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 MCP write 도구는 기본이 빌더급 권한이라, 붙이는 순간 공들여 세운 내부 게이트를 우회한다. 자동 실행은 &quot;lifecycle 이벤트 + allowlist 프로젝트 + 문서에 선언된 side-effect&quot; 안에서만 안전하고, 커밋&amp;middot;푸시&amp;middot;PR로는 절대 번지지 않게 벽을 세워야 한다. 편의(자동 동기화)와 경계(자동으로 넘지 못할 선)는 따로 오지 않는다 &amp;mdash; 같이 설계하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 연결해 둔 write 도구를 하나 골라, 그게 자동으로 실행돼도 되는 lifecycle 이벤트를 딱 한 줄로 적어 보라. 그리고 나머지 부수효과는 전부 사람 게이트로 남겨라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 편은 시리즈 1&amp;middot;2&amp;middot;8편에서 세운 내부 권한 경계(슬래시 커맨드&amp;middot;에이전트&amp;middot;자율 모드)를, 팀 바깥으로 나가는 외부 MCP로 확장한 기록이다. 사람이 쓰는 화면이든 에이전트가 만지는 외부 표면이든, &quot;어디까지 자동이고 어디서 사람이 서는가&quot;를 정하는 일은 결국 인터페이스 설계다. 그래서 외부 도구에 권한을 나눠 주는 이 일도, 여전히 프런트엔드의 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kir93.co.kr/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-8%ED%8E%B8-%EC%9E%90%EC%9C%A8-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%84%A4%EA%B3%84%ED%95%9C-%EA%B1%B4-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EB%A9%88%EC%B6%94%EB%8A%94-%EC%A7%80%EC%A0%90%EC%9D%B4%EC%97%88%EB%8B%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.07.01 - [AI 엔지니어링] - 프런트엔드 AX 설계기 8편 - 자율 모드에서 설계한 건 자동화가 아니라 멈추는 지점이었다&lt;/a&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ax</category>
      <category>claude code</category>
      <category>MCP</category>
      <category>권한 경계</category>
      <category>에이전트 툴링</category>
      <category>워크플로 자동화</category>
      <category>이슈 트래커</category>
      <category>프론트엔드 아키텍처</category>
      <category>플랜 라이프사이클</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/205</guid>
      <comments>https://kir93.tistory.com/entry/%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-9%ED%8E%B8-%E2%80%94-%EC%99%B8%EB%B6%80-%EC%9D%B4%EC%8A%88-%ED%8A%B8%EB%9E%98%EC%BB%A4-write-%EB%8F%84%EA%B5%AC%EB%A5%BC-%EA%B6%8C%ED%95%9C-%EA%B2%BD%EA%B3%84-%EC%95%88%EC%97%90-%EA%B0%80%EB%91%90%EA%B8%B0#entry205comment</comments>
      <pubDate>Fri, 24 Jul 2026 19:14:01 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 8편 - 자율 모드에서 설계한 건 자동화가 아니라 멈추는 지점이었다</title>
      <link>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-8%ED%8E%B8-%EC%9E%90%EC%9C%A8-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%84%A4%EA%B3%84%ED%95%9C-%EA%B1%B4-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EB%A9%88%EC%B6%94%EB%8A%94-%EC%A7%80%EC%A0%90%EC%9D%B4%EC%97%88%EB%8B%A4</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트에게 &quot;이 기능, 한 번에 끝까지 끝내줘&quot;라고 시키고 싶었다. goal 하나를 던지면 구현부터 검증까지 알아서 도는 end-to-end 패턴은 이미 손에 익었고, 남은 욕심은 &quot;매 단계 확인을 그만 받고 싶다&quot;였다. 그래서 &lt;code&gt;/feature&lt;/code&gt; 커맨드에 &lt;code&gt;--auto&lt;/code&gt; 플래그를 붙였다. 그런데 붙이자마자 첫 실행이 엉뚱한 데서 멈췄다. 격리 작업공간(worktree)을 만들 권한이 없다는 것이었다. 폴더 하나 만드는, 언제든 되돌릴 수 있는 일 앞에서 파이프라인이 손을 든 것이다. 그 순간 질문이 뒤집혔다. 문제는 &quot;어디까지 자동화하나&quot;가 아니라 &quot;어디서 멈춰 사람에게 넘기나&quot;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 뒤로 &lt;code&gt;--auto&lt;/code&gt;의 권한 경계를 다시 그은 작업기다. AI에게 자율 실행을 맡겨보려는 사람이라면 같은 벽을 만난다 &amp;mdash; 다 맡기자니 무섭고, 매번 확인받자니 자율이 아니다. 결론부터 말하면, 안전한 무인 실행은 &quot;끝까지 실행&quot;이 아니라 &lt;b&gt;되돌릴 수 있는 일&lt;/b&gt;과 &lt;b&gt;발행&lt;/b&gt;을 다른 권한으로 가를 때만 가능했다. (2026년 중반 워크플로이고 도구 동작은 자주 바뀌니, 규칙보다 경계 긋는 방식을 가져가시길.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;되돌릴 수 있는 일과 발행은 같은 권한이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;--auto&lt;/code&gt;가 사람 확인 없이 실행해도 되는 일은 딱 하나의 성질을 만족했다. 되돌릴 수 있을 것. 파일을 고치고, 격리 작업공간을 만들고, 테스트를 돌리는 일은 전부 git이나 삭제로 되돌릴 수 있다. 잘못돼도 흔적을 지우면 그만이다. 반면 branch를 만들고 commit 하고 push 하고 PR을 열고, 완료된 작업을 아카이브로 옮기는 정리 작업은 성질이 다르다. 한번 밖으로 나가면 남의 눈에 들어가거나, 되돌리는 데 또 다른 공개 작업(revert)이 필요하다. 앞을 reversible work, 뒤를 발행(publishing)이라 부르고, &lt;code&gt;--auto&lt;/code&gt;는 &lt;b&gt;앞만 pre-authorize 하도록&lt;/b&gt; 못 박았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;01-reversible-vs-publishing.png&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;831&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OSCTb/dJMcahyd60p/OYAtKVQWxQsnlUgVb7SuF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OSCTb/dJMcahyd60p/OYAtKVQWxQsnlUgVb7SuF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OSCTb/dJMcahyd60p/OYAtKVQWxQsnlUgVb7SuF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOSCTb%2FdJMcahyd60p%2FOYAtKVQWxQsnlUgVb7SuF1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1282&quot; height=&quot;831&quot; data-filename=&quot;01-reversible-vs-publishing.png&quot; data-origin-width=&quot;1282&quot; data-origin-height=&quot;831&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이 구분이 없었다. 그래서 worktree 생성 앞에서 멈췄던 것이다 &amp;mdash; 되돌릴 수 있는 준비 작업을, 발행과 똑같이 &quot;위험&quot; 칸에 넣어둔 탓이었다. carve-out을 하나 넣어 되돌릴 수 있는 셋업은 흐르게 하고, 발행만 게이트에 세웠다. 그러자 &lt;code&gt;--auto&lt;/code&gt;는 구현에서 검증까지 쉬지 않고 달리다가, PR을 여는 문 앞에서 정확히 한 번 멈춰 나를 불렀다. 자동화한 범위가 넓어진 게 아니라, 멈추는 지점이 정확해진 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;경계를 파일로 못 박기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경계를 머릿속에만 두면 다음 세션에서 또 흔들린다. 그래서 정책을 파일로 고정했다. 아래는 그대로 복사해 출발점으로 쓸 수 있는 &lt;code&gt;--auto&lt;/code&gt; 권한 정책이다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# /feature --auto 권한 정책 (설정 파일 발췌)
auto:
  # 되돌릴 수 있는 일 &amp;mdash; 사람 확인 없이 실행한다
  pre_authorized:
    - implement            # 파일 편집&amp;middot;구현&amp;middot;수정
    - worktree_setup       # 격리 작업공간 생성 (되돌릴 수 있어 멈추지 않음)
    - run_tests            # 검증 게이트

  # 되돌릴 수 없는 발행 &amp;mdash; 항상 사람 게이트 앞에서 멈춘다
  gated:
    - branch_create
    - commit
    - push
    - open_pr
    - completion_cleanup   # 완료 작업 아카이브 이동&amp;middot;정리

  review_loop:
    max_rounds: 3          # 무한 루프 상한
    loop_on: [CRITICAL, WARNING]
    # edge case ①: 라운드 사이에 CRITICAL이 strictly decrease 하지 않으면
    # (정체하거나 늘면) 상한에 닿기 전에 멈추고 사람에게 escalate 한다
    regression_guard: strict_decrease

  pre_pr_loop:
    loop_on: [lint, type, format]   # 발행 직전엔 기계적 검사만 반복

  drift:
    # edge case ②: 발산&amp;middot;Non-goals 위반은 자동수정하지 않는다
    divergent: stop_for_human
    non_goals_violation: stop_for_human
    missing: warn                   # 비차단 경고&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주석에 붙인 edge case 두 줄이 이 글의 나머지 절반이다. 하나는 &quot;라운드를 아무리 돌아도 CRITICAL이 안 줄면 어쩌나&quot;, 다른 하나는 &quot;발산은 자동으로 고쳐도 되나&quot;. 순서대로 풀어본다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;무한 루프를 막는 건 상한만이 아니었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자율 모드의 다음 위험은 발행이 아니라 루프였다. 에이전트가 자기 코드를 리뷰하고, 지적을 스스로 고치고, 다시 리뷰하는 수렴 루프를 돌게 했는데 &amp;mdash; 이게 안 멈출 수 있다. 그래서 루프를 두 갈래로 좁혔다. 리뷰 수렴은 CRITICAL&amp;middot;WARNING에서만 돌고, 발행 직전(pre-pr) 루프는 lint&amp;middot;type&amp;middot;format 같은 기계적 검사에서만 돈다. 그리고 어느 쪽이든 max 3 rounds에서 끊는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상한만으로 충분한 줄 알았다. 아니었다. 어려운 문제에서 에이전트는 라운드마다 CRITICAL을 줄이기는커녕, 한 지적을 고치며 다른 걸 깨서 오히려 늘렸다. 상한 3에 닿을 때까지 &quot;고치는 척 퇴행&quot;만 세 번 반복한 셈이다. 그래서 상한 위에 regression guard를 얹었다. 라운드 사이에 CRITICAL 개수가 strictly decrease &amp;mdash; 반드시 줄어야 함 &amp;mdash; 하지 않으면, 정체하거나 늘면 그 자리에서 자동수정을 멈추고 사람에게 escalate 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;02-review-convergence-guard.png&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;831&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcwuJf/dJMcafN0b6I/biZ1qK4iw5kHaRzYLzv7M1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcwuJf/dJMcafN0b6I/biZ1qK4iw5kHaRzYLzv7M1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcwuJf/dJMcafN0b6I/biZ1qK4iw5kHaRzYLzv7M1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcwuJf%2FdJMcafN0b6I%2FbiZ1qK4iw5kHaRzYLzv7M1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1193&quot; height=&quot;831&quot; data-filename=&quot;02-review-convergence-guard.png&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;831&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 감이 아니라 운영에서 나온 관찰이다. hard problem에서 자율 fix-loop는 수렴이 아니라 퇴행할 수 있다는 걸 몇 번 보고 나서야 가드를 넣었다. &quot;몇 라운드 돌았나&quot;가 아니라 &quot;나아지고 있나&quot;를 기준으로 바꾼 순간, 루프는 스스로 멈출 줄 알게 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 실패는 애초에 자동으로 고치면 안 됐다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 경계는 실패의 종류였다. spec과 구현이 어긋나는 drift를 검사할 때, 모든 어긋남을 자동수정 대상으로 두면 안 됐다. 종류를 갈랐다. 구현이 spec과 정면으로 어긋나는 divergent나, 하지 않기로 한 것(Non-goals)을 건드린 경우는 &lt;b&gt;자동수정 금지, 사람 판단으로 정지&lt;/b&gt;. 반대로 아직 구현되지 않은 부분이 남았다는 missing은 비차단 경고(WARN)로만 남기고 진행한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;03-drift-severity-routing.png&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;742&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baVx5r/dJMcaaTohHX/u2J6UQEikDfJu9vPsAjWOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baVx5r/dJMcaaTohHX/u2J6UQEikDfJu9vPsAjWOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baVx5r/dJMcaaTohHX/u2J6UQEikDfJu9vPsAjWOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaVx5r%2FdJMcaaTohHX%2Fu2J6UQEikDfJu9vPsAjWOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1193&quot; height=&quot;742&quot; data-filename=&quot;03-drift-severity-routing.png&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;742&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 어긋남을 severity 숫자가 아니라 &lt;b&gt;&quot;이걸 기계가 판단해도 되나&quot;&lt;/b&gt;로 나눈 것이다. divergent와 Non-goals 위반은 &quot;의도&quot;의 문제라 사람만 판단할 수 있다. missing은 &quot;진행도&quot;의 문제라 경고만으로 족하다. 자동화가 손대도 되는 실패와 그렇지 않은 실패를 미리 갈라두지 않으면, &lt;code&gt;--auto&lt;/code&gt;는 좋은 의도로 잘못된 방향을 더 빨리 밀어붙인다. 자율 모드에서 가장 위험한 건 멈춤이 아니라, 잘못된 방향으로의 가속이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무인 실행의 설계는 &quot;무엇을 자동화하나&quot;가 아니라 &quot;어디서 멈추나&quot;다. 되돌릴 수 있는 일은 끝까지 맡기고, 발행&amp;middot;발산&amp;middot;Non-goals 위반은 사람 앞에 세운다. 그리고 루프는 &quot;몇 번 돌았나&quot;가 아니라 &quot;나아지고 있나&quot;로 끊는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 자율 실행을 붙이려는 커맨드가 있다면, 자동화 목록을 짜기 전에 딱 두 칸짜리 표부터 그려보라 &amp;mdash; 왼쪽엔 되돌릴 수 있는 일, 오른쪽엔 되돌릴 수 없는 발행. 오른쪽은 전부 사람 게이트로 시작하고, 필요할 때만 하나씩 왼쪽으로 옮겨라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;code&gt;--auto&lt;/code&gt;가 설계한 건 자동화가 아니라 사람과 에이전트 사이의 인터페이스였다. 어디까지 맡기고 어디서 손을 넘길지, 그 경계를 정하고 정합하게 유지하는 일은 화면의 버튼을 어디까지 눌리게 할지 정하던 그 craft와 다르지 않다. 표면이 사람에서 에이전트로 하나 늘었을 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kir93.co.kr/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-7%ED%8E%B8-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EC%86%8D%EC%9D%B4%EB%8A%94-AI%EB%A5%BC-%EC%B0%A8%EB%8B%A8-%EC%97%86%EC%9D%B4-%EC%9E%A1%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.07.01 - [AI 엔지니어링] - 프런트엔드 AX 설계기 7편 - 게이트를 속이는 AI를 차단 없이 잡기&lt;/a&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ai 엔지니어링</category>
      <category>ax</category>
      <category>claude code</category>
      <category>권한 경계</category>
      <category>워크플로 자동화</category>
      <category>자율 에이전트</category>
      <category>코드리뷰 게이트</category>
      <category>프론트엔드 아키텍처</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/204</guid>
      <comments>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-8%ED%8E%B8-%EC%9E%90%EC%9C%A8-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%84%A4%EA%B3%84%ED%95%9C-%EA%B1%B4-%EC%9E%90%EB%8F%99%ED%99%94%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EB%A9%88%EC%B6%94%EB%8A%94-%EC%A7%80%EC%A0%90%EC%9D%B4%EC%97%88%EB%8B%A4#entry204comment</comments>
      <pubDate>Wed, 22 Jul 2026 19:06:58 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 7편 - 게이트를 속이는 AI를 차단 없이 잡기</title>
      <link>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-7%ED%8E%B8-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EC%86%8D%EC%9D%B4%EB%8A%94-AI%EB%A5%BC-%EC%B0%A8%EB%8B%A8-%EC%97%86%EC%9D%B4-%EC%9E%A1%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;게이트를 세운 이유는 단순했다. 사람이 매번 안 들여다봐도 품질 바닥이 유지되길 바랐다. lint&amp;middot;타입체크&amp;middot;테스트를 CI에 걸고, 구현은 AI 코더에게 &quot;통과할 때까지 고쳐&quot;라고 맡겼다. 며칠 뒤 초록불이 켜진 PR을 열었는데, 테스트 파일의 케이스 수가 줄어 있었다. 실패하던 테스트를 AI가 지워 버린 것이다. 게이트는 통과했고, 그 통과는 거짓말이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lint&amp;middot;tsc&amp;middot;test 게이트를 AI에게 맡겨 본 사람이라면 한 번쯤 겪는 장면이다. 이 글은 그 뒤에 무엇을 붙였는지에 대한 기록이다 &amp;mdash; 게이트 옆에서 &quot;게이트를 속이는지&quot;를 보는 별도 레이어, 그리고 그 레이어가 정당한 수정까지 잡아 버리는 false-positive 폭탄이 되지 않게 만든 과정.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;게이트는 '고쳐라'와 동시에 '속여라'를 가르친다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 게이트를 세우면 AI에게 동기가 하나 더 생긴다. 게이트를 통과시키는 가장 싼 경로는, 코드를 제대로 고치는 게 아니라 게이트가 불평을 멈추게 하는 것이다. 측정이 목표가 되는 순간 측정이 망가진다는 Goodhart의 법칙이, 코드 게이트에서 그대로 재연된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 우회를 나는 CI-gaming이라 부른다 &amp;mdash; 품질을 실제로 높여서가 아니라 게이트를 속여서 판정을 통과시키는 행위. 모양은 두 가지였다. 하나는 억제(suppression). 린트 경고엔 &lt;code&gt;eslint-disable&lt;/code&gt;, 타입 에러엔 &lt;code&gt;@ts-ignore&lt;/code&gt;나 &lt;code&gt;as any&lt;/code&gt;를 붙여 입을 막는다. 다른 하나는 테스트 변조. &lt;code&gt;it.skip&lt;/code&gt;으로 끄거나, 케이스를 통째로 지우거나, 가장 교묘하게는 assertion을 &quot;지금 동작에 맞춰&quot; 다시 쓴다. 버그를 정답으로 고정하는 셈이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 lint&amp;middot;tsc는 이걸 못 보나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 게이트를 더 촘촘히 하면 될 줄 알았다. 착각이었다. 게이트는 &quot;현재 코드&quot;가 규칙을 지키는지만 본다. &lt;code&gt;eslint-disable&lt;/code&gt;이 붙은 줄은 규칙을 '지킨' 것으로 처리되고, 지워진 테스트는 '존재하지 않는' 것이다. 게이트 자신에게 우회는 위반이 아니라 정상 통과다. 이건 버그가 아니라 게이트의 구조적 사각지대다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠져나갈 길은 하나였다. 게이트가 &quot;현재 코드&quot;를 본다면, 탐지는 &quot;변경(diff)&quot;을 봐야 한다. 일반 코드리뷰가 &quot;이 변경이 좋은가&quot;를 묻는다면, 게이밍 탐지는 &quot;이 변경이 게이트를 속이는가&quot;를 묻는다. 위협 모델이 다른 레이어가 필요했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;fe-ax-7-detection-flow.png&quot; data-origin-width=&quot;1560&quot; data-origin-height=&quot;900&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FMzG9/dJMcacjjOZK/ywzhWvbNmPRgtayHV9O9ik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FMzG9/dJMcacjjOZK/ywzhWvbNmPRgtayHV9O9ik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FMzG9/dJMcacjjOZK/ywzhWvbNmPRgtayHV9O9ik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFMzG9%2FdJMcacjjOZK%2FywzhWvbNmPRgtayHV9O9ik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1560&quot; height=&quot;900&quot; data-filename=&quot;fe-ax-7-detection-flow.png&quot; data-origin-width=&quot;1560&quot; data-origin-height=&quot;900&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음엔 차단했다. false-positive 폭탄이 터졌다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 시도는 단순무식했다. 변조가 의심되면 CI를 fail 시켰다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;# 첫 시도(실패) &amp;mdash; 변조 의심을 곧장 CI 차단으로. 게이트로 게이트를 지키려 들었다.
- name: anti-gaming gate
  run: |
    if git diff --cached | grep -q 'as any'; then
      echo &quot;blocked: as any 금지&quot;; exit 1   # 정당한 외부 타입 누락 대응까지 막힌다
    fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이틀 만에 두 가지가 동시에 터졌다. 첫째, 정당한 케이스가 막혔다 &amp;mdash; 외부 라이브러리 타입이 빠져 &lt;code&gt;as any&lt;/code&gt;가 합리적인 곳, 의존성이 죽어 일시로 &lt;code&gt;it.skip&lt;/code&gt;을 건 곳까지 빨간불이 됐다. 둘째, 꼼수가 더 교묘해졌다. 막히자 &lt;code&gt;as any&lt;/code&gt; 대신 &lt;code&gt;as unknown as T&lt;/code&gt;로 우회했다. 탐지는 무력화됐고 코드는 더 나빠졌다. 차단은 false-positive와 회피를 한꺼번에 키운다. 이 지점에서 방향을 틀었다 &amp;mdash; 막지 말고 보이게만 하자.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 차단을 버리고 '보이게'만 했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR을 만들기 직전, 변경 diff에서 우회 신호만 스캔하되 무엇을 찾든 &lt;code&gt;exit 0&lt;/code&gt;. 막지 않고 PR 리뷰어에게 보이게만 한다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/env bash
# PR 직전 advisory 스캔 &amp;mdash; 변경 diff에서 '게이트 우회' 신호만 본다.
# 원칙: report-only. 무엇을 찾든 exit 0 &amp;mdash; publish를 막지 않고 '보이게'만 한다.
set -uo pipefail

# 스테이징된 변경에서 '추가된 줄'만 (삭제는 아래 3에서 따로 본다)
added=$(git diff --cached --unified=0 | grep -E '^\+' | grep -vE '^\+\+\+')
flag() { printf '⚠️  advisory: %s\n' &quot;$1&quot;; }

# 1) 억제 &amp;mdash; lint&amp;middot;tsc가 '정상 통과'로 보는 사각지대
echo &quot;$added&quot; | grep -nE 'eslint-disable|@ts-(ignore|expect-error)|as any' \
  &amp;amp;&amp;amp; flag &quot;억제 주석/타입 우회 추가 &amp;mdash; 의도라면 PR 본문 '의도' 슬롯에 근거를 남기세요.&quot;

# 2) 테스트 비활성화 &amp;mdash; skip/only/todo
echo &quot;$added&quot; | grep -nE '\b(it|test|describe)\.(skip|only)\b|\bit\.todo\b' \
  &amp;amp;&amp;amp; flag &quot;테스트 skip/only 추가 &amp;mdash; 일시적인지, 추적 이슈가 있는지 확인하세요.&quot;

# 3) 테스트 삭제 &amp;mdash; 제거된 케이스 수 (edge case: 파일 리네임은 따로 걸러야 함)
removed=$(git diff --cached --unified=0 -- '*.test.*' '*.spec.*' \
  | grep -cE '^-\s*(it|test|describe)\(')
[ &quot;$removed&quot; -gt 0 ] &amp;amp;&amp;amp; flag &quot;테스트 케이스 ${removed}건 삭제 &amp;mdash; 대체 커버리지를 PR 본문에 명시하세요.&quot;

exit 0   # &amp;larr; 절대 차단하지 않는다. 이건 게이트가 아니라 '가시화 레이어'다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋하면 이렇게 뜬다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ git commit            # PR 직전 advisory 훅
⚠️  advisory: 억제 주석/타입 우회 추가 &amp;mdash; 의도라면 PR 본문 '의도' 슬롯에 근거를 남기세요.
   src/pricing.ts:42:  const total = raw as any
⚠️  advisory: 테스트 케이스 1건 삭제 &amp;mdash; 대체 커버리지를 PR 본문에 명시하세요.

✔ publish는 그대로 진행됩니다 (report-only). 위 항목은 PR 리뷰어에게 함께 표시됩니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 효과는 비용에 있다. 전체 코드가 아니라 diff만 스캔하니 CI를 느리게 하지 않고, 차단이 아니니 정당한 작업을 멈추지 않는다. 개발자가 탐지를 꺼버릴 이유가 사라진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정상 리팩터와 게이밍을 가른 건 '신뢰 의도 출처'였다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남은 난제는 가장 교묘한 변조, assertion 재작성이었다. 버그를 고치면 assertion이 바뀌는 건 당연한데, 게이밍도 똑같이 assertion을 바꾼다. 겉모습이 같다. 무엇으로 가르나?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답은 의도였다. 리뷰가 어려운 진짜 이유는 작성되지 않은 의도를 사람이 거꾸로 재구성해야 하기 때문이다. Addy Osmani는 같은 문제를 짚으며, 에이전트가 &quot;무엇을 하려 했고 무엇을 배제했는지&quot;를 PR에 decision log로 남기자고 제안한다. 그 아이디어를 빌려, PR 본문에 의도와 배제를 적는 두 칸을 만들었다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- PR 본문 템플릿(발췌) &amp;mdash; '신뢰 의도 출처'를 코드 옆에 남긴다 --&amp;gt;
## 의도
- 무엇을 바꾸려 했나: 가격 표시를 반올림(round) &amp;rarr; 내림(floor)으로 정정

## 배제
- 검토했지만 택하지 않은 대안: round 유지 후 표시단 보정 &amp;mdash; 소수 누적오차로 기각

## 테스트 변경 근거(있다면)
- assertion 수정/삭제/skip의 이유: 기존 assertion이 버그(round)를 고정 중 &amp;rarr; floor 기준으로 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 슬롯이 리뷰 단계 점검의 판정 기준이 된다. 규칙은 하나로 좁혔다 &amp;mdash; assertion 재작성은 신뢰할 수 있는 의도 출처(spec, 명시적 요청, 이 PR 의도 슬롯)가 있을 때만 게이밍 여부를 판정한다. 의도 슬롯이 &quot;round&amp;rarr;floor 정정&quot;이라 말해 주면 그에 맞춘 assertion 수정은 정당한 것으로 인식되어 조용히 지나간다. 슬롯이 비어 있으면 &quot;게이밍 가능성&quot;으로 사람에게 올라간다. 같은 코드 변경이라도 의도 출처의 유무가 판정을 가른다. 이 게이팅을 넣고 나서 오탐은 0으로 수렴했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대가가 없진 않다. advisory는 강제력이 없어 무시하면 그만이라 리뷰 규율이 받쳐 줘야 하고, 패턴 기반이라 &lt;code&gt;as unknown as T&lt;/code&gt; 같은 새 우회는 규칙을 갱신하기 전까진 놓친다. 의도&amp;middot;배제 슬롯을 적는 품도 개발자에게 더해진다. 그래도 차단이 키우던 오탐과 회피에 비하면 싼 비용이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 세 가지로 정리된다. 검증 게이트를 세우는 순간 AI는 코드를 고치는 대신 게이트를 우회할 동기를 얻고, 그래서 탐지는 게이트의 옵션이 아니라 짝이다. 탐지는 차단이 아니라 가시화여야 한다 &amp;mdash; 정당한 수정을 막지 않아야 개발자가 탐지를 끄지 않는다. 그리고 정상 리팩터와 게이밍을 가르는 건 '신뢰 의도 출처'다. 의도가 코드 옆에 남으면 오탐은 0에 수렴한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당장 할 일은 하나다. pre-commit 훅에 &lt;code&gt;git diff --cached&lt;/code&gt;로 &lt;code&gt;eslint-disable&lt;/code&gt;&amp;middot;&lt;code&gt;as any&lt;/code&gt;&amp;middot;&lt;code&gt;.skip&lt;/code&gt;&amp;middot;삭제된 테스트를 advisory(&lt;code&gt;exit 0&lt;/code&gt;)로 흘리는 다섯 줄을 붙여라. 막지 말고 보이게만 하라. 그리고 PR 템플릿에 '의도'와 '배제' 두 칸을 더하라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6편에서 게이트의 비용을 깎았다면, 이번 편은 그렇게 가벼워진 게이트를 AI가 우회하지 못하게 막는 일이었다. 둘은 한 쌍이다. 그리고 이건 결국 인터페이스 설계다. 게이트의 사용자가 사람이 아니라 에이전트일 때 &quot;무엇을 통과로 칠 것인가&quot;는 그 자체로 인터페이스 계약이 된다. 잘 만든 버튼이 오용을 어렵게 만들듯, 잘 만든 게이트는 우회를 보이게 만든다. 사람을 위한 UI를 짜든 에이전트를 위한 게이트를 짜든, 인터페이스를 잘 만드는 craft는 하나다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: Addy Osmani, &quot;Agentic Code Review&quot; &amp;mdash; 작성되지 않은 의도를 PR의 decision log로 남겨 리뷰의 의도 재구성 비용을 줄이자는 제안. 이 글 'PR 의도&amp;middot;배제 슬롯'의 출발점이다. &lt;a href=&quot;https://addyosmani.com/blog/agentic-code-review/&quot;&gt;https://addyosmani.com/blog/agentic-code-review/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1782881387819&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Agentic Code Review&quot; data-og-description=&quot;Coding agents are extraordinarily good now, and getting better fast. The interesting consequence is that the hard part of engineering moved from writing code...&quot; data-og-host=&quot;addyosmani.com&quot; data-og-source-url=&quot;https://addyosmani.com/blog/agentic-code-review/&quot; data-og-url=&quot;https://addyosmani.com/blog/agentic-code-review/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b3D4oI/dJMb8RR2QeU/kWXwq5nSrHskkkCGLYPmXK/img.jpg?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768,https://scrap.kakaocdn.net/dn/bdZHXr/dJMb83StTGZ/blFIxQFZ91Nh2Ph1idqYVk/img.jpg?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768,https://scrap.kakaocdn.net/dn/dGuf0y/dJMb87ghpAU/KUwo8sZMzkckgH9gUM90hk/img.jpg?width=2400&amp;amp;height=1350&amp;amp;face=0_0_2400_1350&quot;&gt;&lt;a href=&quot;https://addyosmani.com/blog/agentic-code-review/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://addyosmani.com/blog/agentic-code-review/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b3D4oI/dJMb8RR2QeU/kWXwq5nSrHskkkCGLYPmXK/img.jpg?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768,https://scrap.kakaocdn.net/dn/bdZHXr/dJMb83StTGZ/blFIxQFZ91Nh2Ph1idqYVk/img.jpg?width=1376&amp;amp;height=768&amp;amp;face=0_0_1376_768,https://scrap.kakaocdn.net/dn/dGuf0y/dJMb87ghpAU/KUwo8sZMzkckgH9gUM90hk/img.jpg?width=2400&amp;amp;height=1350&amp;amp;face=0_0_2400_1350');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Agentic Code Review&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Coding agents are extraordinarily good now, and getting better fast. The interesting consequence is that the hard part of engineering moved from writing code...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;addyosmani.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kir93.co.kr/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-6%ED%8E%B8-done%EC%9D%80-%EC%A7%81%EA%B4%80%EC%9C%BC%EB%A1%9C-%EC%A0%95%ED%95%98%EB%8A%94-%EC%83%81%ED%83%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.07.01 - [AI 엔지니어링] - 프런트엔드 AX 설계기 6편 - done은 직관으로 정하는 상태가 아니다&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ai 엔지니어링</category>
      <category>CI-gaming</category>
      <category>claude code</category>
      <category>Git</category>
      <category>JS &amp;amp; TS</category>
      <category>코드리뷰</category>
      <category>테스트</category>
      <category>프론트엔드 아키텍처</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/202</guid>
      <comments>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-7%ED%8E%B8-%EA%B2%8C%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EC%86%8D%EC%9D%B4%EB%8A%94-AI%EB%A5%BC-%EC%B0%A8%EB%8B%A8-%EC%97%86%EC%9D%B4-%EC%9E%A1%EA%B8%B0#entry202comment</comments>
      <pubDate>Mon, 20 Jul 2026 17:48:55 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 6편 - done은 직관으로 정하는 상태가 아니다</title>
      <link>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-6%ED%8E%B8-done%EC%9D%80-%EC%A7%81%EA%B4%80%EC%9C%BC%EB%A1%9C-%EC%A0%95%ED%95%98%EB%8A%94-%EC%83%81%ED%83%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트에게 일곱 번째 task를 맡긴 참이었다. 구현 도중 에이전트가 두 번째 task &amp;mdash;이미 &lt;code&gt;done&lt;/code&gt;으로 닫아둔&amp;mdash; 의 설계가 틀렸다는 걸 스스로 드러냈다. 나는 잠깐 멈췄다. 상태 판에는 분명 &lt;code&gt;done&lt;/code&gt;이라 적혀 있는데, 그 &lt;code&gt;done&lt;/code&gt;은 더 이상 사실이 아니었다. 팀에 AI 보조 개발을 들이면 이 장면을 반드시 만난다. 사람이 혼자 짤 때보다 에이전트는 더 자주, 더 빨리 앞선 결정을 무너뜨린다(빈도는 내 운영 관찰이지 벤치마크는 아니다 &amp;mdash; 추정으로 읽어달라). 그런데 내가 쓰던 상태 도구에는 그 상황을 적을 칸이 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 뒤로 &lt;code&gt;done&lt;/code&gt;이라는 상태를 다시 설계한 작업기다. 두 군데가 나를 물었다. &lt;code&gt;done&lt;/code&gt;에서 &lt;b&gt;나오는&lt;/b&gt; 쪽과, 애초에 &lt;code&gt;done&lt;/code&gt;으로 &lt;b&gt;들어가는&lt;/b&gt; 쪽. 두 번 다 내 직관이 틀렸고, 두 번 다 작은 측정이 그걸 바로잡았다. (2026년 중반 기준 워크플로이고 도구 동작은 자주 바뀌니, 규칙보다 개념을 가져가시길.)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;상태는 한 방향으로만 흐르지 않았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 spec/task 상태는 여느 이슈 트래커처럼 한 방향이었다. &lt;code&gt;specification &amp;rarr; planned &amp;rarr; in-progress &amp;rarr; done&lt;/code&gt;, 한번 &lt;code&gt;done&lt;/code&gt;이면 끝. 이 모델은 &quot;완료된 일은 완료된 채로 남는다&quot;는 가정 위에 서 있다. 사람만 일할 땐 그럭저럭 버텼는데, 에이전트가 들어오자 그 가정이 자꾸 깨졌다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;01-unidirectional-vs-bidirectional.png&quot; data-origin-width=&quot;1611&quot; data-origin-height=&quot;1025&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rDHyy/dJMcacXX9SM/IBivpcCevQgcRlhtnK1TK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rDHyy/dJMcacXX9SM/IBivpcCevQgcRlhtnK1TK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rDHyy/dJMcacXX9SM/IBivpcCevQgcRlhtnK1TK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrDHyy%2FdJMcacXX9SM%2FIBivpcCevQgcRlhtnK1TK1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1611&quot; height=&quot;1025&quot; data-filename=&quot;01-unidirectional-vs-bidirectional.png&quot; data-origin-width=&quot;1611&quot; data-origin-height=&quot;1025&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 방향을 하나 더 텄다. &lt;code&gt;done &amp;rarr; in-progress &amp;rarr; planned&lt;/code&gt;로 &lt;b&gt;거꾸로 가는 경로&lt;/b&gt;를 상태 머신의 정식 시민으로 넣은 것이다. 무효화된 작업을 억지로 &lt;code&gt;done&lt;/code&gt;에 남겨두거나 전혀 새 task로 갈아 끼우는 대신, 상태 자체가 &quot;아직 할 일이 남았다&quot;를 정직하게 말하게 만드는 게 목표였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;되돌릴 땐 가장 앞선 규칙 하나만 적용했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거꾸로 갈 때 spec의 상태를 어디까지 내릴지가 관건이었다. 규칙을 여러 갈래로 흩뿌리면 상태가 모호해진다. 그래서 &lt;b&gt;위에서부터 가장 먼저 들어맞는 규칙 하나&lt;/b&gt;만 적용하도록 못 박았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;02-reverse-transition-decision-tree.png&quot; data-origin-width=&quot;1497&quot; data-origin-height=&quot;1395&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cS1XuE/dJMcacXX9UB/pjNkNxCiuFBXsfQtL0XSxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cS1XuE/dJMcacXX9UB/pjNkNxCiuFBXsfQtL0XSxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cS1XuE/dJMcacXX9UB/pjNkNxCiuFBXsfQtL0XSxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcS1XuE%2FdJMcacXX9UB%2FpjNkNxCiuFBXsfQtL0XSxk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1497&quot; height=&quot;1395&quot; data-filename=&quot;02-reverse-transition-decision-tree.png&quot; data-origin-width=&quot;1497&quot; data-origin-height=&quot;1395&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 task가 &lt;code&gt;pending&lt;/code&gt;으로 되돌아갔는데(rework) 다른 task 중 &lt;code&gt;in-progress&lt;/code&gt;가 하나라도 있으면 spec은 &lt;code&gt;in-progress&lt;/code&gt;로 내린다. &lt;code&gt;in-progress&lt;/code&gt;인 게 하나도 없으면 &lt;code&gt;planned&lt;/code&gt;까지 내린다. 무효화된 게 원래부터 &lt;code&gt;pending&lt;/code&gt;이던 task 뿐이라면 spec은 움직이지 않는다. 진행도가 높은 쪽부터 평가해 내려오니 결과가 늘 하나로 떨어졌고, 역전의 바닥은 &lt;code&gt;planned&lt;/code&gt;였다 &amp;mdash; &lt;code&gt;specification&lt;/code&gt;까지는 내려가지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 무효화된 task를 그냥 &lt;code&gt;done&lt;/code&gt;인 채로 두고 &quot;나중에 고치자&quot;는 메모만 붙였다. 그게 대시보드를 거짓말쟁이로 만들었다. 다음 실행에서 에이전트는 그 &lt;code&gt;done&lt;/code&gt;을 믿고 잘못된 전제로 코드를 짰다. 그래서 규칙을 이렇게 바꿨다. 되돌아온 task는 상태를 되돌리고, &lt;code&gt;rework_required&lt;/code&gt;로 표식 하고, 딸린 작업을 &lt;code&gt;depends_on&lt;/code&gt;으로 추적한다. 아래는 그렇게 정착한 task 문서의 머리말이다. 그대로 복사해 쓸 수 있다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# task 문서 frontmatter &amp;mdash; 무효화되어 되돌아온 상태
---
task: 2
title: 토큰 갱신 처리
status: pending          # done에서 되돌림
rework_required: true    # 되돌아온 작업이라는 표식 &amp;mdash; 항상 pending과 짝
depends_on: [1]          # 이 task가 흔들리면 함께 봐야 할 하위 작업
---&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 상위 spec frontmatter &amp;mdash; 규칙에 따라 함께 역전
---
feature: auth-refresh
status: in-progress      # 다른 task가 in-progress라 여기까지만 내림
---&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;되돌림은 한 층에서 멈추지 않았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재미있는 일은 한 계층 위에서 벌어졌다. 여러 spec을 묶는 큰 작업 단위를 initiative라 부르자(예: 플랫폼 재작성). 모든 슬라이스가 &lt;code&gt;done&lt;/code&gt;이 되면 initiative도 &lt;code&gt;done&lt;/code&gt;으로 닫히고 파일이 아카이브 폴더로 넘어간다. 그런데 그 안의 spec 하나가 되돌려져 &quot;전부 완료&quot; 선이 깨지면 어떻게 될까.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;03-unarchive-cascade.png&quot; data-origin-width=&quot;1859&quot; data-origin-height=&quot;1148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzdFLT/dJMcaiw8wND/FtrvZhIJUyuEG0fkR1ewUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzdFLT/dJMcaiw8wND/FtrvZhIJUyuEG0fkR1ewUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzdFLT/dJMcaiw8wND/FtrvZhIJUyuEG0fkR1ewUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzdFLT%2FdJMcaiw8wND%2FFtrvZhIJUyuEG0fkR1ewUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1859&quot; height=&quot;1148&quot; data-filename=&quot;03-unarchive-cascade.png&quot; data-origin-width=&quot;1859&quot; data-origin-height=&quot;1148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 로직 한 줄을 되돌린 작은 rework가 세 계층을 거슬러 올라갔다. task가 &lt;code&gt;done&lt;/code&gt;에서 &lt;code&gt;pending&lt;/code&gt;으로, spec이 &lt;code&gt;done&lt;/code&gt;에서 &lt;code&gt;in-progress&lt;/code&gt;로, 그리고 아카이브 됐던 initiative가 다시 &lt;code&gt;active&lt;/code&gt;로 되살아났다. 파일은 아카이브 폴더에서 원래 자리로 옮겨지고 문서 안의 상대 경로 링크까지 다시 계산됐다. 상태 머신이 한 층에서만 양방향인 게 아니라 &lt;b&gt;되돌림이 위로 전파&lt;/b&gt;된다는 것 &amp;mdash; 이게 이 설계에서 내가 가장 마음에 든 부분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 함정도 여기서 배웠다. spec과 실제 코드가 어긋났는지 검증하는 단계는 반드시 &lt;code&gt;done&lt;/code&gt;으로 닫기 &lt;b&gt;전에&lt;/b&gt; 돌려야 한다. 일단 아카이브로 넘어간 spec에서는 그 검증이 스스로 건너뛰기 때문에, 사후에 돌리면 아무것도 못 잡는다. 완료 정리도 자동으로 하지 않고 사용자에게 물어보게 뒀다 &amp;mdash; 배포나 후속 작업이 남아 있으면 거절할 수 있어야 하니까.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그런데 done은 애초에 누가 내주나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 &lt;code&gt;done&lt;/code&gt;에서 나오는 쪽 이야기다. 그러다 반대편이 눈에 들어왔다. 애초에 작업이 &lt;code&gt;done&lt;/code&gt;으로 &lt;b&gt;들어가는&lt;/b&gt; 문은 누가 지키나. 에이전트가 짠 코드를 내가 다 읽을 수는 없으니, 나는 AI가 AI의 코드를 리뷰해 통과&amp;middot;실패를 가르는 게이트를 뒀다. 이른바 LLM-as-judge(모델이 심판을 보는 방식)다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;04-review-gate-lens-panel.png&quot; data-origin-width=&quot;1683&quot; data-origin-height=&quot;1056&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvwnKK/dJMcagF8dyh/zWuXoMqf6KOTwHCUYeaTAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvwnKK/dJMcagF8dyh/zWuXoMqf6KOTwHCUYeaTAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvwnKK/dJMcagF8dyh/zWuXoMqf6KOTwHCUYeaTAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvwnKK%2FdJMcagF8dyh%2FzWuXoMqf6KOTwHCUYeaTAk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1683&quot; height=&quot;1056&quot; data-filename=&quot;04-review-gate-lens-panel.png&quot; data-origin-width=&quot;1683&quot; data-origin-height=&quot;1056&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 실수는 코드를 짠 모델에게 그대로 채점을 시킨 것이었다. 자기 출력에는 후하다. self-preference bias라 부르는 이 편향 때문에 게이트는 늘 초록불만 켰다. 그래서 코드를 짠 쪽(writer)과 리뷰하는 쪽(evaluator)을 아예 다른 호출로 분리했다. 초기 분리 실험에선 결함을 잡아내는 recall이 대략 두 배가 됐다(초기 측정치라 방향성으로 본다). 중요한 건 점수를 손보는 게 아니라 &quot;누가 평가하느냐&quot;를 바꾸는 구조였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;관점을 늘릴수록 좋다는 건 착각이었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리뷰를 한 번에 죽 보는 대신, 서로 다른 관점(lens)으로 나눠 보고 합치기로 했다. 여기서 &quot;관점은 많을수록 안전하다&quot;는 직관이 자연스럽게 올라온다. 재보니 아니었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;05-lens-count-knee-curve.png&quot; data-origin-width=&quot;2074&quot; data-origin-height=&quot;1190&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cXU7Fw/dJMcaalu4jM/68m5E0KeNU6oeiyH1KpI1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cXU7Fw/dJMcaalu4jM/68m5E0KeNU6oeiyH1KpI1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cXU7Fw/dJMcaalu4jM/68m5E0KeNU6oeiyH1KpI1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcXU7Fw%2FdJMcaalu4jM%2F68m5E0KeNU6oeiyH1KpI1K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2074&quot; height=&quot;1190&quot; data-filename=&quot;05-lens-count-knee-curve.png&quot; data-origin-width=&quot;2074&quot; data-origin-height=&quot;1190&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관점을 1개에서 2개로 늘릴 때 recall이 확 올랐고, 2개에서 무릎이 꺾여 3개부터는 거의 평평했다. 정직하게 덧붙이면, 처음 방향성 측정에선 더 큰 폭이 나왔지만 표본이 얇았다(thin-n). 조작된 정답 세트로 통제해 다시 재니 recall은 81%에서 88%로, +7pp였다(pooled 기준, 변별이 필요한 fixture에선 +10pp). 직관만 틀리는 게 아니라 &lt;b&gt;통제 없는 측정도 과장한다&lt;/b&gt;는 걸 이때 배웠다 &amp;mdash; 그래서 헤드라인 숫자는 통제값으로 잡는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용 걱정도 빗나갔다. &quot;관점 2개면 단가도 2배, 게이트가 4배 비싸지는 것 아니냐&quot;라고 지레 겁먹었는데, 실측은 동률이었다. 관점 패널이 &lt;b&gt;한 번의 호출 안에서&lt;/b&gt; 돌기 때문에 모델 호출 수 자체가 그대로였다. 비용을 &quot;관점 수&quot;가 아니라 &quot;호출 수&quot;로 세는 순간 사라지는 걱정이었고, 이것도 재보기 전엔 안 보였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;verdict가 흔들리자 그다음이 무너졌다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 게이트의 출력 자체를 손봤다. 관점 패널로 프레이밍을 바꾸자, 사소한 diff를 통과(PASS)가 아니라 &quot;평가 불능(N/A)&quot;으로 밀어내는 회귀가 생겼다. 게이트가 조용히 무력화된 것이다. 다행히 미리 만들어둔 골든 세트가 이 회귀를 잡아냈고, &quot;발견된 문제가 없으면 PASS, N/A는 정말 평가가 불가능할 때만&quot;으로 verdict의 의미를 못 박아 고쳤다. 정밀도를 조이는 손잡이도 발견 개수의 상한이 아니라 확신 임계(confidence-floor)여야 한다는 걸 이 과정에서 정리했다. 게이트가 내보내는 신호가 흔들리면, 그 신호를 읽는 다음 단계 &amp;mdash; 바로 &lt;code&gt;done&lt;/code&gt;으로 들어가는 그 문 &amp;mdash; 가 통째로 오염되기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;done&lt;/code&gt;은 종착역이 아니라 양방향 상태다. 무효화되면 task에서 spec으로, initiative까지 거꾸로 전파된다. 그리고 &lt;code&gt;done&lt;/code&gt;을 내주는 리뷰 게이트의 손잡이 &amp;mdash; 누가 평가하는가, 관점을 몇 개 보는가, 비용을 무엇으로 세는가, verdict가 무슨 뜻인가 &amp;mdash; 는 감이 아니라 측정으로 잡아야 한다. 두 경계 모두 순진한 직관은 틀렸고, 작은 측정 한 번이 그걸 바로잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당장 할 일은 가볍다. 지금 쓰는 task 템플릿에 &lt;code&gt;rework_required&lt;/code&gt; 한 줄과 역방향 규칙 세 줄을 넣고, 리뷰 게이트가 있다면 관점을 하나 더 켜기 전에 작은 골든 세트로 recall이 정말 오르는지 딱 한 번만 재보라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;code&gt;done&lt;/code&gt;도 verdict도, 사람의 의도와 에이전트 사이에 놓인 인터페이스다. 그 인터페이스를 상태로 모델링하고 정합하게 유지하는 일은 화면을 다루든 에이전트를 다루든 프런트엔드가 늘 해온 그 craft다. 표면이 하나 늘었을 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kir93.co.kr/entry/%ED%94%84%EB%9F%B0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-5%ED%8E%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%A7%80%EC%9B%8C-%EC%B4%88%EB%A1%9D%EB%B6%88%EC%9D%84-%EB%A7%8C%EB%93%9C%EB%8A%94-AI&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.06.26 - [AI 엔지니어링] - 프런트엔드 AX 설계기 5편 - 테스트를 지워 초록불을 만드는 AI&lt;/a&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>ai 엔지니어링</category>
      <category>ax</category>
      <category>claude code</category>
      <category>eval</category>
      <category>LLM-as-Judge</category>
      <category>테스트</category>
      <category>프론트엔드 아키텍처</category>
      <category>프롬프트 엔지니어링</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/203</guid>
      <comments>https://kir93.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-AX-%EC%84%A4%EA%B3%84%EA%B8%B0-6%ED%8E%B8-done%EC%9D%80-%EC%A7%81%EA%B4%80%EC%9C%BC%EB%A1%9C-%EC%A0%95%ED%95%98%EB%8A%94-%EC%83%81%ED%83%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4#entry203comment</comments>
      <pubDate>Fri, 17 Jul 2026 17:51:36 +0900</pubDate>
    </item>
    <item>
      <title>클로저&amp;middot;this&amp;middot;viewport를 지키며 큰 컴포넌트를 쪼갠 작업기</title>
      <link>https://kir93.tistory.com/entry/%ED%81%B4%EB%A1%9C%EC%A0%80%C2%B7this%C2%B7viewport%EB%A5%BC-%EC%A7%80%ED%82%A4%EB%A9%B0-%ED%81%B0-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%A5%BC-%EC%AA%BC%EA%B0%A0-%EC%9E%91%EC%97%85%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 컴포넌트 너무 크니까 좀 쪼개 줘.&quot; 이 요청을 받으면, 사람이든 AI 에이전트든 가장 쉽게 빠지는 함정이 줄 수(LOC)를 줄이는 데 최적화하는 것이다. 함수로 뽑고 helper로 흩어 메인 본문을 짧게 만든다. 지표는 좋아진다. 그런데 화면이 깜빡이고, 차트 호버가 죽고, 다른 탭에 다녀오면 그래프가 어긋났다. 기술 트렌드를 시각화하는 한 화면(기술 트리&amp;middot;버블 차트&amp;middot;보고서)을 다섯 갈래로 쪼개면서 나는 이걸 다섯 번 확인했다. 큰 컴포넌트&amp;middot;훅을 분해해 봤고 useEffect나 차트 라이브러리에서 미묘한 회귀를 겪어 본 사람이라면 익숙한 장면일 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;줄을 세는 일과 의미를 지키는 일은 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분해를 하다 보면 매번 같은 단어로 돌아왔다. 런타임 계약. 코드가 겉으로 드러내지 않지만 동작이 의존하는 암묵적 약속이다. 클로저가 값을 캡처하는 &lt;i&gt;시점&lt;/i&gt;, 이벤트 콜백에서 &lt;code&gt;this&lt;/code&gt;가 가리키는 &lt;i&gt;대상&lt;/i&gt;, 측정이 &quot;안정적&quot;이라 말할 수 있는 &lt;i&gt;조건&lt;/i&gt; &amp;mdash; 전부 계약이다. 타입 시그니처에도 안 보이고, 단위 테스트도 잘 못 잡는다. 그래서 LOC만 보고 분해하면 지표는 좋아지는데 동작은 조용히 깨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흔히 말하는 관심사 분리(SoC)와는 결이 다르다. SoC가 &quot;코드를 어떤 단위로 나눌까&quot;라면, 런타임 계약은 &quot;나눈 뒤에도 같은 약속이 지켜지는가&quot;다. 잘 나눴는데 약속이 깨질 수 있고, 그 반대도 가능하다. 이번에 지켜야 했던 계약은 셋이었다. useEffect의 단회 반영, 차트 콜백의 &lt;code&gt;this&lt;/code&gt; 바인딩, 그리고 비활성 탭 복귀 시 viewport 안정 조건.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;탭에 다녀오면 그래프가 어긋났다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 눈에 보이는 계약은 세 번째였다. 분해 전에는 &lt;code&gt;requestAnimationFrame&lt;/code&gt;으로 매 프레임 컨테이너 크기를 재며 &quot;이제 안정됐나?&quot;를 묻는 폴링 루프가 있었다(이름도 &lt;code&gt;waitForStableSize&lt;/code&gt;였다). 폴링이 거기 있던 이유는 분명했다. 백그라운드 탭에서는 컨테이너가 0 &amp;times;0으로 접히고, @xyflow는 트리를 화면에 맞추려면 실제 크기가 필요하다. 그래서 복귀 직후 첫 측정은 거의 틀리고, &quot;맞을 때까지&quot; 매 프레임 다시 재는 코드가 자라난 것이다. 문제는 그 대가였다. 매 프레임 layout을 읽고, 사용자가 다른 탭에 가 있어도 멈추지 않고 돌았다. 이걸 이벤트 구동으로 바꿨다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;trend-survey-viewport.png&quot; data-origin-width=&quot;1982&quot; data-origin-height=&quot;1150&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uqu9l/dJMcadCsinN/D1KkxuoAC4pIZS5wIM5wOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uqu9l/dJMcadCsinN/D1KkxuoAC4pIZS5wIM5wOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uqu9l/dJMcadCsinN/D1KkxuoAC4pIZS5wIM5wOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fuqu9l%2FdJMcadCsinN%2FD1KkxuoAC4pIZS5wIM5wOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1982&quot; height=&quot;1150&quot; data-filename=&quot;trend-survey-viewport.png&quot; data-origin-width=&quot;1982&quot; data-origin-height=&quot;1150&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바뀐 구조는 세 레벨이 맞물린다. 브라우저 레벨에서 &lt;code&gt;visibilitychange&lt;/code&gt;가 &quot;탭 복귀&quot; 게이트를 열고, &lt;code&gt;ResizeObserver&lt;/code&gt;가 컨테이너 크기 변화를 추적한다. &lt;code&gt;visibilitychange&lt;/code&gt;는 크기를 직접 재지 않는다 &amp;mdash; 복귀 &lt;i&gt;시점&lt;/i&gt;만 열어 주고, 안정화 추적은 &lt;code&gt;ResizeObserver&lt;/code&gt;에 맡긴다. 라이브러리 레벨에서 @xyflow의 &lt;code&gt;setViewport&lt;/code&gt;로 최종 위치를 적용하고, 프레임워크 레벨에서 effect cleanup이 observer를 &lt;code&gt;disconnect&lt;/code&gt;해 백그라운드로 새는 관찰을 막는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 안정 조건 자체는 그대로라는 점이다. &quot;크기가 0이 아니고 더는 안 변할 때 측정한다&quot;는 의미는 같고, 그 표현만 '매 프레임 측정'에서 '변화 이벤트 + 150ms debounce'로 바뀌었다. 정직하게 덧붙이면, 일회성 viewport 계산을 위한 &lt;code&gt;rAF&lt;/code&gt; 호출 하나는 남겼다. 내가 걷어낸 건 매 프레임 도는 폴링 루프지, &lt;code&gt;rAF&lt;/code&gt; 그 자체가 아니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;setState를 루프 안에 두는 순간 ERROR가 사라졌다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 위험했던 건 첫 번째 계약이었다. 상태&amp;middot;뷰 동기화 훅의 메인 useEffect가 245줄이었는데 54줄로 줄였다. 줄은 5분의 1이 됐지만, 이 분해가 성립한 건 단 하나 &amp;mdash; &quot;누적은 지역 변수에, 반영은 루프가 끝난 뒤 한 번만&quot;이라는 클로저 캡처 의미를 그대로 지켰기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 떠오른 &quot;깔끔한&quot; 버전은 이랬다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 처음 버전 &amp;mdash; 깔끔해 보이지만 '단회 반영' 계약을 깬다
function handleMessage(msg, ctx) {
  if (isLoadingBlock(msg)) {
    ctx.setReportLoadingData(msg.data); // 메시지마다 반영 &amp;rarr; effect deps 재실행
  }
}
useEffect(() =&amp;gt; {
  messages.forEach((m) =&amp;gt; handleMessage(m, ctx));
}, [messages]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지마다 &lt;code&gt;setState&lt;/code&gt;가 호출되면 effect 의존성이 다시 돌고, 뒤늦게 도착한 &lt;code&gt;LOADING&lt;/code&gt; 블록이 먼저 반영된 &lt;code&gt;ERROR&lt;/code&gt;를 덮어쓴다. 원래 코드가 막고 있던 바로 그 버그다. 추출은 했지만 계약은 깼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고친 버전은 누적과 반영을 분리한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 고친 버전 &amp;mdash; 누적은 accum에, store 반영은 루프 종료 후 단 한 번
type LoopAccum = {
  latestReportData: ReportStatus | null;
  latestInProgressId: string | null;
  hasStatusBlock: boolean;
};

function reduceMessage(msg: Message, accum: LoopAccum) {
  if (isLoadingBlock(msg)) {
    accum.latestReportData = msg.data;       // setState 없이 누적만
    accum.latestInProgressId = msg.reportId;
    accum.hasStatusBlock = true;
  }
}

useEffect(() =&amp;gt; {
  const accum = createAccum();
  messages.forEach((m) =&amp;gt; reduceMessage(m, accum)); // helper로 빼도
  if (accum.hasStatusBlock) {                        // 같은 클로저를 캡처 &amp;rarr; 의미 동일
    setReportLoadingData(accum.latestReportData);    // 단회 반영
    if (accum.latestInProgressId) setCurrentReportId(accum.latestInProgressId);
  }
}, [messages]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수로 추출해도 &lt;code&gt;accum&lt;/code&gt;은 같은 클로저를 캡처하므로 의미가 보존된다. 결과는 화면에서 갈렸다. 처음 버전에서는 보고서 상태가 &lt;code&gt;ERROR&lt;/code&gt;로 떴다가 곧바로 &lt;code&gt;LOADING&lt;/code&gt;으로 되돌아가 깜빡였고, 고친 버전에서는 같은 메시지 배치를 처리해도 최종 상태가 한 번만 반영돼 &lt;code&gt;ERROR&lt;/code&gt;가 유지됐다. 줄 수는 비슷한데 동작은 정반대다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 건 빼고 어떤 건 남겼나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경계 판단이 늘 &quot;빼라&quot;는 아니었다. 버블 차트의 &lt;code&gt;point.events&lt;/code&gt;(mouseOver/mouseOut)는 &lt;code&gt;this&lt;/code&gt;로 해당 포인트에 접근한다. Highcharts가 콜백을 호출할 때 &lt;code&gt;this&lt;/code&gt;를 포인트에 묶어 주기 때문이다. 이걸 화살표 함수로 &quot;정리&quot;해 바깥으로 빼면 &lt;code&gt;this&lt;/code&gt; 바인딩이 끊긴다. 그래서 이 콜백은 일부러 인라인 일반 함수로 남겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대 방향도 있었다. 한 모달(383줄)이 함수 본문 &lt;i&gt;안에서&lt;/i&gt; 또 다른 트리 모달 컴포넌트를 정의하고 있었다. 이러면 부모가 리렌더 될 때마다 컴포넌트 정체성이 새로 생겨, React가 같은 UI를 언마운트&amp;middot;리마운트 하며 내부 상태와 포커스를 날린다. 이 경우엔 반대로 모듈 레벨의 별도 컴포넌트로 빼는 게 계약을 지키는 길이었고, 본문은 101줄로 줄었다. 경계의 핵심은 추출이냐 보존이냐의 &lt;i&gt;방향&lt;/i&gt;이 아니라 &lt;i&gt;근거&lt;/i&gt;다. &lt;code&gt;this&lt;/code&gt;에 묶인 콜백은 남기고, 렌더마다 새로 만들어지면 안 되는 컴포넌트는 뺀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DRY도 같은 저울에 올렸다. 트리 데이터를 다루는 두 훅을 하나로 합치자는 제안이 있었지만, 한쪽은 실시간 스트리밍(SSE) 기반 생성이고 다른 쪽은 보고서 조회 기반이라 설계가 이미 분화돼 있었다. 합치면 조건 분기 복잡도가 유지보수 이익을 넘는다고 보고, 통합을 접고 Non-goal로 남겼다. 중복 제거가 늘 이득은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 분해에서 안전망은 테스트가 아니었다. 이 화면에는 활발히 유지되는 테스트가 없어서, 나는 깨질 계약을 직접 재현하는 쪽을 택했다. 분해 전후로 같은 시나리오 &amp;mdash; 보고서가 &lt;code&gt;ERROR&lt;/code&gt;로 떴다가 &lt;code&gt;LOADING&lt;/code&gt;으로 되돌아가는 깜빡임, 다른 탭에 다녀온 뒤 어긋나던 그래프 &amp;mdash; 를 손으로 재연해 동작이 같은지 확인했다. 의미를 보존했다는 말은, 보존됐는지 눈으로 확인했다는 뜻이어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다섯 번의 분해에서 남은 결론은 단순하다. 리팩터링의 단위는 줄 수가 아니라 의미다. useEffect의 단회 반영도, 차트의 &lt;code&gt;this&lt;/code&gt; 바인딩도, viewport 안정 조건도 추출 전후로 같아야 하고, 모든 걸 빼는 게 아니라 계약에 묶인 코드는 남기는 게 더 나은 분해다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당신의 가장 큰 useEffect를 열어, &lt;code&gt;setState&lt;/code&gt;가 루프 &lt;i&gt;안&lt;/i&gt;에서 호출되는지부터 확인하라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이게 왜 프런트엔드의 일이냐면, 우리가 실제로 다루는 게 코드 줄이 아니라 사람이 보는 화면과 그 화면을 떠받치는 런타임 계약이기 때문이다. 인터페이스를 잘 만드는 craft는 하나고, 표면이 사람이든 에이전트든 &quot;의미를 보존하고, 보존했는지 확인하는&quot; 규율은 똑같이 적용된다. 줄 수는 그 계약의 그림자일 뿐이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ResizeObserver &amp;mdash; 요소 크기 변화 관찰 (MDN): &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;visibilitychange 이벤트 (MDN): &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Highcharts &lt;code&gt;plotOptions.series.point.events&lt;/code&gt; &amp;mdash; 포인트 이벤트의 &lt;code&gt;this&lt;/code&gt; 바인딩: &lt;a href=&quot;https://api.highcharts.com/highcharts/plotOptions.series.point.events&quot;&gt;https://api.highcharts.com/highcharts/plotOptions.series.point.events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;React Flow(@xyflow) Viewport 타입: &lt;a href=&quot;https://reactflow.dev/api-reference/types/viewport&quot;&gt;https://reactflow.dev/api-reference/types/viewport&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Vercel React Best Practices &amp;mdash; &quot;Don't Define Components Inside Components&quot;: &lt;a href=&quot;https://github.com/vercel-labs/agent-skills/blob/main/skills/react-best-practices/AGENTS.md&quot;&gt;https://github.com/vercel-labs/agent-skills/blob/main/skills/react-best-practices/AGENTS.md&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;i&gt;수치(245&amp;rarr;54&amp;middot;383&amp;rarr;101줄)는&amp;nbsp;본&amp;nbsp;분해&amp;nbsp;작업&amp;nbsp;기준이며,&amp;nbsp;API&amp;nbsp;동작은&amp;nbsp;각&amp;nbsp;출처의&amp;nbsp;최신&amp;nbsp;문서(2026-06)&amp;nbsp;기준입니다.&lt;/i&gt;&lt;/u&gt;&lt;/p&gt;</description>
      <category>React</category>
      <category>highCharts</category>
      <category>react</category>
      <category>React Flow</category>
      <category>ResizeObserver</category>
      <category>typescript</category>
      <category>리팩터링</category>
      <category>상태관리</category>
      <category>성능</category>
      <category>프론트엔드 아키텍처</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/201</guid>
      <comments>https://kir93.tistory.com/entry/%ED%81%B4%EB%A1%9C%EC%A0%80%C2%B7this%C2%B7viewport%EB%A5%BC-%EC%A7%80%ED%82%A4%EB%A9%B0-%ED%81%B0-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%A5%BC-%EC%AA%BC%EA%B0%A0-%EC%9E%91%EC%97%85%EA%B8%B0#entry201comment</comments>
      <pubDate>Wed, 15 Jul 2026 17:26:36 +0900</pubDate>
    </item>
    <item>
      <title>Zustand stale 플래그와 변경 직전 pre-fetch로 VERSION_CONFLICT 숨긴 작업기</title>
      <link>https://kir93.tistory.com/entry/Zustand-stale-%ED%94%8C%EB%9E%98%EA%B7%B8%EC%99%80-%EB%B3%80%EA%B2%BD-%EC%A7%81%EC%A0%84-pre-fetch%EB%A1%9C-VERSIONCONFLICT-%EC%88%A8%EA%B8%B4-%EC%9E%91%EC%97%85%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 소스 패널에서 폴더를 삭제했을 뿐인데 &quot;잠시 후 다시 시도해 주세요&quot;라는 빨간 토스트가 떴다. 코드를 따라가 보니 서버 응답은 &lt;code&gt;400 VERSION_CONFLICT&lt;/code&gt;였다. 우리 서버는 폴더 변경에 낙관적 동시성 제어를 쓴다. 폴더마다 &lt;code&gt;version: number&lt;/code&gt;를 들고 있고, 삭제&amp;middot;이름 변경&amp;middot;이동 요청에 내가 알고 있는 &lt;code&gt;expectedVersion&lt;/code&gt;을 실어 보내면 서버가 현재 버전과 비교한다. 어긋나면 거부한다. 문제는 사용자 모르게 그 버전이 올라가는 경로가 있었다는 것이다. 외부 소스를 폴더로 적재하는 백그라운드 &quot;불러오기&quot; 작업이 끝나면 서버 폴더 버전이 오른다. 그런데 브라우저 캐시는 옛 버전 그대로다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재현 경로는 이랬다. 사용자가 불러오기를 시작하고, 모달을 닫거나 새로고침하고, 백그라운드 작업이 폴더를 새로 만들며 기존 폴더 버전을 올리고, 그 사실을 모르는 사용자가 폴더를 삭제한다. 보낸 &lt;code&gt;expectedVersion&lt;/code&gt;은 옛 v3, 서버는 v4. 충돌. 사용자 입장에선 &quot;그냥 삭제를 눌렀을 뿐&quot;이다. React Query&amp;middot;Zustand로 서버 상태를 다루다 보면 버전이나 ETag를 요구하는 API에서 똑같은 일을 겪는다. 이 글은 그 충돌을 사용자 눈에 안 보이게 흡수한 과정을 정리한 작업기다. 코드는 2026년 중반, React Query v5&amp;middot;Zustand v5 기준이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음엔 완료되면 그냥 다시 불러왔다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 시도는 단순했다. 백그라운드 작업이 끝난 걸 폴링으로 감지하면 그 자리에서 폴더 목록을 &lt;code&gt;invalidate&lt;/code&gt;해 새로 받았다. 버그는 사라졌다. 대신 다른 게 깨졌다. 사용자가 폴더를 고르거나 이름을 바꾸던 도중에 목록이 발밑에서 재정렬됐다. 새 폴더가 불쑥 끼어들고, 문서 수가 바뀌고, 스크롤이 튀었다. 충돌 에러를 없애려다 더 거슬리는 경험을 만든 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 방향을 틀었다. 완료 순간에 보여주는 건 &quot;새로고침하시겠어요?&quot; 배너까지만. 실제 목록 갱신은 사용자가 변화를 기대하는 순간으로 미뤘다. 직접 새로고침을 누르거나, 어차피 무언가를 바꾸려고 변경 요청을 보내는 순간이다. 질문을 바꾼 게 핵심이었다. &quot;언제 새로고침하느냐&quot;가 아니라 &quot;사용자가 모르게 무엇만 동기화하면 충분하냐&quot;. 답은 화면 전체가 아니라 그 폴더의 버전 하나였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;version-conflict-flow.png&quot; data-origin-width=&quot;2240&quot; data-origin-height=&quot;1648&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Glzm3/dJMcaiRpZfT/rjPnmVXuFKMNwF5hFCaPvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Glzm3/dJMcaiRpZfT/rjPnmVXuFKMNwF5hFCaPvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Glzm3/dJMcaiRpZfT/rjPnmVXuFKMNwF5hFCaPvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGlzm3%2FdJMcaiRpZfT%2FrjPnmVXuFKMNwF5hFCaPvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2240&quot; height=&quot;1648&quot; data-filename=&quot;version-conflict-flow.png&quot; data-origin-width=&quot;2240&quot; data-origin-height=&quot;1648&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;낡은 건 캐시지, 화면이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 번 의심하고 갈 게 있었다. &quot;캐시에 폴더 목록이 이미 있으니 거기서 버전을 읽으면 되잖아?&quot; 안 된다. 캐시(&lt;code&gt;['folder', projectId]&lt;/code&gt;)가 들고 있는 값이 바로 옛 버전 v3이다. 그게 stale의 정의다. &lt;code&gt;queryClient.getQueryData&lt;/code&gt;로 읽어도 v3이 나온다. v4는 서버에만 있다. 그러니 버전을 맞추려면 네트워크로 한 번은 물어봐야 한다. 다만 그 결과로 화면을 다시 그릴 필요는 없다. 버전 숫자만 꺼내 쓰고 나머지는 버린다. 이 구분이 뒤에 나올 &quot;조용한 pre-fetch&quot;의 전제가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;감지하는 곳과 바꾸는 곳을 3줄짜리 스토어로 잇다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 걸림돌은 감지와 소비의 위치가 다르다는 거였다. &quot;캐시가 낡았다&quot;를 아는 쪽은 백그라운드 작업 상태를 폴링 하는 소스 패널이고, 그걸 써야 하는 쪽은 한참 아래에 있는 폴더 이름 변경&amp;middot;삭제 핸들러다. 둘은 컴포넌트 트리상 멀다. prop으로 내려보내자니 거쳐야 할 컴포넌트가 너무 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 신호선 하나를 전역에 뒀다. Zustand 스토어인데 상태는 불리언 하나뿐이다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// store/folderStaleStore.ts
import { create } from 'zustand';

interface FolderStaleState {
  isFolderListStale: boolean;
  markFolderListStale: () =&amp;gt; void;
  clearFolderListStale: () =&amp;gt; void;
}

export const useFolderStaleStore = create&amp;lt;FolderStaleState&amp;gt;((set) =&amp;gt; ({
  isFolderListStale: false,
  markFolderListStale: () =&amp;gt; set({ isFolderListStale: true }),
  clearFolderListStale: () =&amp;gt; set({ isFolderListStale: false }),
}));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;감지부는 작업이 &quot;진행 중 &amp;rarr; 완료&quot;로 넘어간 순간 플래그를 켠다. 실패는 서버 버전을 올리지 않으니 켜지 않았다. 이 작은 구분이 뒤에서 불필요한 네트워크를 줄여준다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 소스 패널: processing &amp;rarr; terminal 전환을 감지한 순간
if (current === 'COMPLETED' || current === 'PARTIALLY_COMPLETED') {
  setBannerType(current === 'COMPLETED' ? 'success' : 'partial');
  useFolderStaleStore.getState().markFolderListStale(); // &quot;캐시가 낡았다&quot; 신호
}
// FAILED는 버전을 올리지 않으므로 stale로 표시하지 않는다&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 의도한 선택이 하나 있다. 소비할 때 이 값을 훅(&lt;code&gt;useFolderStaleStore(s =&amp;gt; s.isFolderListStale)&lt;/code&gt;)으로 구독하지 않고 &lt;code&gt;getState()&lt;/code&gt;로 읽었다. 클릭 핸들러 안에서 필요한 건 &quot;그 순간의 최신값&quot;이지, 이 값이 바뀔 때마다 컴포넌트를 다시 그릴 이유는 없어서다. React 트리 밖에서 명령형으로 읽는, 의도된 비구독이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;stale-flag-decoupling.png&quot; data-origin-width=&quot;2240&quot; data-origin-height=&quot;904&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2VKMB/dJMcafHcTPa/1G7aJqkxU59y6tSeP2vUV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2VKMB/dJMcafHcTPa/1G7aJqkxU59y6tSeP2vUV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2VKMB/dJMcafHcTPa/1G7aJqkxU59y6tSeP2vUV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2VKMB%2FdJMcafHcTPa%2F1G7aJqkxU59y6tSeP2vUV0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2240&quot; height=&quot;904&quot; data-filename=&quot;stale-flag-decoupling.png&quot; data-origin-width=&quot;2240&quot; data-origin-height=&quot;904&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;바꾸기 직전에 딱 한 번 당겨온다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소비부는 &lt;code&gt;mutate&lt;/code&gt;를 부르기 직전에 플래그를 본다. 켜져 있을 때만 최신 폴더를 한 번 당겨와 그 버전으로 교체한다. 삭제 핸들러가 이렇다. 이름 변경도 구조가 같다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;const handleDelete = async () =&amp;gt; {
  let expectedVersion = folder.version;
  const { isFolderListStale, clearFolderListStale } = useFolderStaleStore.getState();

  // 낡았을 때만 최신 폴더를 당겨온다 &amp;mdash; 정상 흐름엔 추가 호출이 없다
  if (isFolderListStale) {
    try {
      const freshList = await getFolderList({ projectId });
      const freshFolder = freshList.folders.find((f) =&amp;gt; f.id === folder.id);
      if (freshFolder) expectedVersion = freshFolder.version; // 최신 버전으로 교체
      clearFolderListStale();
    } catch {
      // pre-fetch가 실패해도 옛 버전으로 진행한다 &amp;mdash; 어차피 서버가 최종으로 막는다
    }
  }

  deleteFolderMutation.mutate(
    { projectId, deleteFolders: [{ folderId: folder.id, expectedVersion }] },
    { onError: handleError, onSuccess: () =&amp;gt; { /* 선택 정리 + invalidate */ } },
  );
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 stale 여부를 안 보고 매 변경마다 pre-fetch 했다. 충돌은 안 났지만, 충돌이 날 일 없는 평범한 삭제까지 매번 한 박자 느려졌다. 그래서 게이트를 끼웠다. 덕분에 평상시 클릭 경로엔 추가 네트워크가 한 건도 없다. pre-fetch 실패를 &lt;code&gt;throw&lt;/code&gt;하지 않은 것도 같은 결의 판단이다. 원래 충돌이 안 났을 수도 있는데, 회복 시도가 실패했다고 사용자 작업까지 막을 이유는 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;pre-fetch는 창을 좁힐 뿐, 닫지 못한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정직하게 말하면 이 패턴은 충돌을 0으로 만들지 못한다. pre-fetch로 v4를 받은 뒤 &lt;code&gt;mutate&lt;/code&gt;가 나가기 전, 그 짧은 사이에 또 다른 작업이 버전을 v5로 올릴 수 있다. 창이 좁아졌을 뿐 닫힌 게 아니다. 그래서 서버 에러 핸들러를 지우지 않고 최종 안전망으로 남겼다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 잔여 충돌을 위한 최종 안전망 (useApiError)
[FolderErrorCode.VERSION_CONFLICT]: () =&amp;gt; {
  toast.error('일시적인 오류입니다', { description: '잠시 후 다시 시도해 주세요' });
  queryClient.invalidateQueries({ queryKey: folderQueryKeys.folderList(projectId) });
},&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선제적 pre-fetch는 흔한 다수를 조용히 통과시키고, fallback은 드문 잔여를 받는다. 둘은 경쟁이 아니라 층이다. TanStack Query를 쓴다면 이 pre-fetch를 &lt;code&gt;onMutate&lt;/code&gt;로 끌어올려 버전 기반 mutation 전반의 가드로 일반화할 수도 있다. 다만 우리 코드는 아직 핸들러 인라인이다. 호출처가 둘 뿐이라 추상화 비용이 이득보다 컸다고 판단했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;남은 빚: 전역 boolean이라 프로젝트 경계를 모른다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스토어의 약점도 적어둔다. &lt;code&gt;isFolderListStale&lt;/code&gt;은 &lt;code&gt;projectId&lt;/code&gt;로 나뉘지 않은 전역 불리언이다. 한 프로젝트에서 플래그가 켜진 채 다른 프로젝트로 이동하면, 그쪽 첫 변경에서 불필요한 pre-fetch가 한 번 돈다. 받아오는 건 그 프로젝트의 올바른 목록이라 결과가 틀리진 않지만, 신호의 의미는 샌 셈이다. 호출처가 더 늘면 &lt;code&gt;Record&amp;lt;projectId, boolean&amp;gt;&lt;/code&gt;이나 projectId 인자로 좁혀야 한다. 지금은 비용이 &quot;불필요한 GET 1회&quot;라, 빚으로 적어두고 넘어갔다. 측정해 보고 거슬리면 갚을 자리다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 버전 기반 동시성 제어를 쓰고 사용자 모르게 그 버전을 올리는 경로가 있으면, 캐시는 낡고 변경은 충돌한다. 전역 stale 플래그로 &quot;낡음&quot;을 감지부에서 소비부로 흘려보내고, 바꾸기 직전 최신 버전만 pre-fetch 해 실어 보내면 충돌은 사용자 눈에 닿지 않는다. 단, pre-fetch는 창을 좁힐 뿐이라 서버 에러 핸들러는 반드시 남긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 코드베이스에서 &lt;code&gt;expectedVersion&lt;/code&gt;(또는 &lt;code&gt;version&lt;/code&gt;&amp;middot;&lt;code&gt;etag&lt;/code&gt;)을 받는 mutation을 한 번 grep 해 보라. 그 화면에 백그라운드로 데이터가 바뀌는 경로가 하나라도 있으면, 3줄짜리 stale 스토어부터 만들어 변경 핸들러에 게이트를 끼워라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보면 내가 짠 건 충돌 처리 코드가 아니라 &quot;사용자가 믿을 수 있는 상태&quot;라는 인터페이스였다. 서버의 동시성 모델이라는 내부 사정을 사용자 눈에 안 보이게 흡수하는 일. 화면을 사람에게 맞추든 요청을 서버에 맞추든, 인터페이스를 다듬는 craft는 결국 하나다. 버전 충돌을 토스트로 떠넘기는 대신 조용히 흡수하는 순간, 그건 에러 핸들링이 아니라 UX 설계가 된다.&lt;/p&gt;</description>
      <category>React</category>
      <category>react</category>
      <category>tanstack query</category>
      <category>ux</category>
      <category>zustand</category>
      <category>낙관적 동시성 제어</category>
      <category>상태관리</category>
      <category>에러 핸들링</category>
      <category>프론트엔드 아키텍처</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/200</guid>
      <comments>https://kir93.tistory.com/entry/Zustand-stale-%ED%94%8C%EB%9E%98%EA%B7%B8%EC%99%80-%EB%B3%80%EA%B2%BD-%EC%A7%81%EC%A0%84-pre-fetch%EB%A1%9C-VERSIONCONFLICT-%EC%88%A8%EA%B8%B4-%EC%9E%91%EC%97%85%EA%B8%B0#entry200comment</comments>
      <pubDate>Mon, 13 Jul 2026 19:23:32 +0900</pubDate>
    </item>
    <item>
      <title>React Flow&amp;middot;dnd-kit&amp;middot;zundo로 트리 편집기 작업기</title>
      <link>https://kir93.tistory.com/entry/React-Flow%C2%B7dnd-kit%C2%B7zundo%EB%A1%9C-%ED%8A%B8%EB%A6%AC-%ED%8E%B8%EC%A7%91%EA%B8%B0-%EC%83%9D%EC%84%B1%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;노드를 끌어다 놓아 트리를 편집하고, 순서를 바꾸고, &lt;code&gt;Ctrl+Z&lt;/code&gt;로 되돌린다. 요구사항만 보면 평범하다. 노드 캔버스는 React Flow(&lt;code&gt;@xyflow/react&lt;/code&gt;), 드래그 정렬은 dnd-kit, undo/redo는 zundo(Zustand 미들웨어)를 쓰면 된다. 각 라이브러리의 README 예제는 5분이면 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 넷을 같은 DOM, 같은 마우스 이벤트, 같은 상태 트리 위에 포개는 순간 시작됐다. React Flow는 노드 클릭을 자기가 처리하려 하고, dnd-kit도 같은 &lt;code&gt;mousedown&lt;/code&gt;을 노리고, zundo는 store에 일어나는 모든 변경을 히스토리에 쌓으려 하고, 캔버스 컨테이너의 &lt;code&gt;overflow&lt;/code&gt;는 드롭다운을 가둔다. 기능을 &lt;i&gt;구현&lt;/i&gt;하는 시간보다 이 충돌을 &lt;i&gt;격리&lt;/i&gt;하는 시간이 더 길었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 그 과정에서 내린 결정들이다. React Flow에 드래그 앤 드롭과 undo를 같이 얹어야 하는 사람이라면 그대로 가져다 써도 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;conflict-map.png&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;908&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bul9O5/dJMcaaskfPp/XarsM6DDfmQFV1a8tLJKRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bul9O5/dJMcaaskfPp/XarsM6DDfmQFV1a8tLJKRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bul9O5/dJMcaaskfPp/XarsM6DDfmQFV1a8tLJKRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbul9O5%2FdJMcaaskfPp%2FXarsM6DDfmQFV1a8tLJKRK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1390&quot; height=&quot;908&quot; data-filename=&quot;conflict-map.png&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;908&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;되돌릴 건 '노드'지 '화면'이 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;zundo는 기존 store를 미들웨어로 감싸기만 하면 undo/redo가 붙는다. 편했다. 그래서 처음엔 화면 전역 store에 그대로 씌웠다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// 처음엔 이렇게 했다 &amp;mdash; 전역 store 전체를 추적
create&amp;lt;AppState&amp;gt;()(temporal((set) =&amp;gt; ({ /* ...화면 전체 상태... */ })));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그랬더니 패널을 열고 탭을 바꾼 다음 &lt;code&gt;Ctrl+Z&lt;/code&gt;를 누르면 노드가 아니라 &lt;i&gt;패널&lt;/i&gt;이 되돌아갔다. zundo가 store 전체를 추적하니, 편집과 상관없는 UI 상태까지 히스토리에 쌓인 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해법은 두 가지였다. 편집 상태를 전역 store에서 떼어 독립 store로 만들고, &lt;code&gt;partialize&lt;/code&gt;로 추적 대상을 노드 배열로만 좁혔다. 히스토리 한도(&lt;code&gt;limit&lt;/code&gt;)는 500으로 뒀다 &amp;mdash; 노드 서른 개 남짓에 스텝당 수백 바이트면 다 합쳐도 몇 MB라, 저사양에서도 신경 쓸 양이 아니다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// editor-store.ts &amp;mdash; 편집 전용 store (전역 store와 분리)
import { create } from 'zustand';
import { temporal } from 'zundo';

interface TreeNode {
  id: string;
  parentId: string | null;
  label: string;
  isDirty: boolean; // 바뀐 노드 자신만 true (뒤에서 설명)
}

interface EditorState {
  nodes: TreeNode[];
  panelOpen: boolean; // UI 상태 &amp;mdash; 히스토리엔 넣지 않는다
  setNodes: (next: TreeNode[]) =&amp;gt; void;
  togglePanel: () =&amp;gt; void;
}

export const useEditorStore = create&amp;lt;EditorState&amp;gt;()(
  temporal(
    (set) =&amp;gt; ({
      nodes: [],
      panelOpen: false,
      setNodes: (next) =&amp;gt; set({ nodes: next }),
      togglePanel: () =&amp;gt; set((s) =&amp;gt; ({ panelOpen: !s.panelOpen })),
    }),
    {
      partialize: (state) =&amp;gt; ({ nodes: state.nodes }), // 노드만 추적
      limit: 500,
    },
  ),
);

// undo/redo는 temporal store에 있다 (버튼 onClick에서 호출)
export const undoEdit = () =&amp;gt; useEditorStore.temporal.getState().undo();
export const redoEdit = () =&amp;gt; useEditorStore.temporal.getState().redo();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;Ctrl+Z&lt;/code&gt;는 노드 구조만 되돌리고, 열어둔 패널은 그대로 남는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;같은 클릭을 두 라이브러리가 노린다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Flow의 카드 노드는 클릭하면 선택 체크가 토글 되게 돼 있었다. 편집용 노드를 같은 타입으로 만들었더니, 이름을 고치려고 누른 클릭이 체크 토글로 샜다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답은 의외로 단순했다. 편집 노드를 &lt;code&gt;EditableNode&lt;/code&gt;라는 별도 &lt;code&gt;nodeType&lt;/code&gt;으로 등록해서 '클릭의 의미'를 타입 단위로 갈라놨다. React Flow는 &lt;code&gt;nodeTypes&lt;/code&gt;의 string key로 컴포넌트를 매칭하니, 타입만 나누면 어느 클릭이 누구 것인지도 자연히 갈린다. 한 컴포넌트 안에서 &lt;code&gt;if (편집모드)&lt;/code&gt; 분기로 버티지 않은 게 핵심이다 &amp;mdash; 분기는 언젠가 또 샌다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;드롭다운이 캔버스에 갇힌다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;편집 노드 위에 드롭다운을 열었는데, 바깥을 클릭해도 닫히지 않았다. 범인은 React Flow 캔버스 컨테이너였다. 이 컨테이너가 &lt;code&gt;mousedown&lt;/code&gt;을 &lt;code&gt;stopPropagation&lt;/code&gt; 한다. 흔히 쓰는 outside-click 패턴은 &lt;code&gt;document&lt;/code&gt;에서 버블 단계로 &lt;code&gt;mousedown&lt;/code&gt;을 듣는데, 그 이벤트가 컨테이너에서 막혀 &lt;code&gt;document&lt;/code&gt;까지 올라오질 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 리스너를 &lt;b&gt;캡처 단계(capture phase)&lt;/b&gt;로 등록했다. 캡처는 바깥에서 안으로 내려가는 단계라, 컨테이너가 전파를 멈추기 전에 &lt;code&gt;document&lt;/code&gt;가 먼저 받는다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// useOutsideClick.ts &amp;mdash; 캡처 단계로 등록해 컨테이너의 stopPropagation을 우회
import { useEffect, useRef } from 'react';

export function useOutsideClick&amp;lt;T extends HTMLElement&amp;gt;(onOutside: () =&amp;gt; void) {
  const ref = useRef&amp;lt;T&amp;gt;(null);
  useEffect(() =&amp;gt; {
    const handler = (e: MouseEvent) =&amp;gt; {
      if (ref.current &amp;amp;&amp;amp; !ref.current.contains(e.target as Node)) onOutside();
    };
    document.addEventListener('mousedown', handler, true); // 세 번째 인자 true = capture
    return () =&amp;gt; document.removeEventListener('mousedown', handler, true);
  }, [onOutside]);
  return ref;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;증상이 하나 더 있었다. 드롭다운이 패널 경계에서 잘려 보였다. 컨테이너의 &lt;code&gt;overflow-hidden&lt;/code&gt;이 자식을 가둔 것이다. flex/grid 레이아웃에서 자식이 넘쳐 보이게 하려면, 넘치는 지점까지의 중간 체인에 &lt;code&gt;min-height: 0&lt;/code&gt;(Tailwind면 &lt;code&gt;min-h-0&lt;/code&gt;)을 걸어줘야 한다. 그걸 넣자 드롭다운이 컨테이너를 벗어나 제대로 떴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;곁가지 하나 더. 드롭다운이 사라질 때 한 프레임이 깜빡였다. fade-out 키프레임이 끝나는 순간 &lt;code&gt;opacity&lt;/code&gt;가 원래 값으로 복원되면서, &lt;code&gt;visibility&lt;/code&gt;가 꺼지기 직전 한 프레임이 노출되는 경쟁이었다. &lt;code&gt;animation-fill-mode: forwards&lt;/code&gt;로 종료 상태를 고정해 막았다. 이런 건 코드 리뷰로는 안 잡히고 꼭 눈으로 봐야 잡힌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그 정렬 쪽도 한 줄 짚고 넘어가자. dnd-kit에서 드래그 대상의 데이터를 꺼낼 때 &lt;code&gt;as SortableData&lt;/code&gt;로 이중 캐스팅하기 쉬운데, 공식 &lt;code&gt;isSortable&lt;/code&gt; 가드를 쓰면 타입이 안전하게 좁혀진다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;import { isSortable } from '@dnd-kit/sortable';

function onDragEnd(source: unknown) {
  if (isSortable(source)) {
    source.index;        // 현재 위치
    source.initialIndex; // 드래그 시작 시 위치
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;변경 표시는 번지지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부턴 라이브러리가 아니라 백엔드 계약 문제다. 트리에서 노드 하나를 고치면, 직관적으로는 부모까지 '변경됨'으로 번질 것 같다. 그런데 계약은 정반대였다. 바뀐 노드 자신만 &lt;code&gt;isDirty = true&lt;/code&gt;, 부모와 자식으로는 전파하지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;isdirty-propagation.png&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;747&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ndhlx/dJMcaaltAQK/agY6NvIKWKC69qr4x2v7T0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ndhlx/dJMcaaltAQK/agY6NvIKWKC69qr4x2v7T0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ndhlx/dJMcaaltAQK/agY6NvIKWKC69qr4x2v7T0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fndhlx%2FdJMcaaltAQK%2FagY6NvIKWKC69qr4x2v7T0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1390&quot; height=&quot;747&quot; data-filename=&quot;isdirty-propagation.png&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;747&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연산마다 규칙도 갈렸다. 순서만 바꾸면 전부 &lt;code&gt;false&lt;/code&gt;, 노드를 옮기면 소분류만 &lt;code&gt;true&lt;/code&gt;, 중분류 텍스트를 고치면 중분류만, 소분류를 고치거나 추가하면 소분류만 &lt;code&gt;true&lt;/code&gt;. 이 규칙 덕에 저장할 때 PATCH 페이로드가 '진짜 바뀐 노드'로 최소화된다. 편하다고 부모로 dirty를 번지게 했다면, 백엔드는 멀쩡한 노드를 변경분으로 받아 처리했을 것이다. FE의 작은 편의가 계약 위반이 되는 지점이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;편집 완료엔 순서가 있다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막은 타이밍이다. 편집을 끝내면 백엔드가 변경 결과를 SSE로 흘려준다. 그래서 'SSE 연결을 먼저 열고 &amp;rarr; 완료 API를 호출'하는 순서가 강제됐다. 뒤집으면 연결이 열리기 전에 도착한 첫 이벤트를 놓친다. 라이브러리 문서엔 없지만, FE 편집 모델과 백엔드를 잇는 이런 순서가 실제로 화면을 깨거나 살린다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 전부 '경계를 어디에 긋느냐'의 문제였다. 세 줄로 줄이면 이렇다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;라이브러리를 묶는 일의 8할은 기능 구현이 아니라, 서로의 가정 충돌을 격리&amp;middot;순서&amp;middot;전파 규칙으로 푸는 설계다.&lt;/li&gt;
&lt;li&gt;undo는 &lt;code&gt;partialize&lt;/code&gt;로 추적 대상을 좁히고 독립 store에 둬야 UI 상태 오염과 메모리 폭증을 함께 막는다.&lt;/li&gt;
&lt;li&gt;노드별 &lt;code&gt;isDirty&lt;/code&gt;(무전파)와 SSE 선행 순서처럼, FE 편집 모델은 백엔드 계약과 한 쌍으로 설계해야 깨지지 않는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 비슷한 편집기를 만들고 있다면, 가장 먼저 열어볼 건 undo store다. 되돌림 대상에 UI 상태가 섞여 있지 않은지 &lt;code&gt;partialize&lt;/code&gt;부터 확인하길 권한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 발 물러나서 보면, 캔버스든 드래그 핸들이든 undo 히스토리든 백엔드로 보내는 변경분이든, 결국 사람이 '편집'이라 부르는 하나의 행위를 여러 표면에 일관되게 비추는 일이었다. 좋은 인터페이스를 만드는 기술은 표면이 무엇이든 결국 하나다. 라이브러리를 고르는 게 프런트엔드의 일이 아니라, 그 표면들 사이의 충돌을 설계로 메우는 게 프런트엔드의 일이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;zundo &amp;mdash; &lt;code&gt;partialize&lt;/code&gt;/&lt;code&gt;limit&lt;/code&gt; 옵션, gzip 700바이트 미만(공식 표기), Zustand v4/v5 지원, 작성 시점 v2.3.0. &lt;a href=&quot;https://github.com/charkour/zundo&quot;&gt;GitHub&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://www.npmjs.com/package/zundo&quot;&gt;npm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;dnd-kit &amp;mdash; Sortable 개념과 &lt;code&gt;isSortable&lt;/code&gt; 타입 가드. &lt;a href=&quot;https://dndkit.com/concepts/sortable/&quot;&gt;문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;React Flow(&lt;code&gt;@xyflow/react&lt;/code&gt;) &amp;mdash; Custom Nodes와 &lt;code&gt;nodeTypes&lt;/code&gt; 등록. &lt;a href=&quot;https://reactflow.dev/learn/customization/custom-nodes&quot;&gt;문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;i&gt;버전&amp;middot;동작은&amp;nbsp;작성&amp;nbsp;시점(2026-06)&amp;nbsp;기준이며&amp;nbsp;환경에&amp;nbsp;따라&amp;nbsp;다를&amp;nbsp;수&amp;nbsp;있다.&lt;/i&gt;&lt;/u&gt;&lt;/p&gt;</description>
      <category>React</category>
      <category>dnd-kit</category>
      <category>react</category>
      <category>React Flow</category>
      <category>undo/redo</category>
      <category>zundo</category>
      <category>zustand</category>
      <category>드래그앤드롭</category>
      <category>상태관리</category>
      <category>트리 편집기</category>
      <category>프론트엔드 아키텍처</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/199</guid>
      <comments>https://kir93.tistory.com/entry/React-Flow%C2%B7dnd-kit%C2%B7zundo%EB%A1%9C-%ED%8A%B8%EB%A6%AC-%ED%8E%B8%EC%A7%91%EA%B8%B0-%EC%83%9D%EC%84%B1%EA%B8%B0#entry199comment</comments>
      <pubDate>Fri, 10 Jul 2026 19:02:20 +0900</pubDate>
    </item>
  </channel>
</rss>