<?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, 23 Jul 2026 02:38:05 +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>프런트엔드 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>
    <item>
      <title>단일 `ko.json`이 모든 PR의 충돌 진원지일 때 - next-intl 메시지를 도메인으로 쪼개는 설계</title>
      <link>https://kir93.tistory.com/entry/%EB%8B%A8%EC%9D%BC-kojson%EC%9D%B4-%EB%AA%A8%EB%93%A0-PR%EC%9D%98-%EC%B6%A9%EB%8F%8C-%EC%A7%84%EC%9B%90%EC%A7%80%EC%9D%BC-%EB%95%8C-next-intl-%EB%A9%94%EC%8B%9C%EC%A7%80%EB%A5%BC-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%9C%BC%EB%A1%9C-%EC%AA%BC%EA%B0%9C%EB%8A%94-%EC%84%A4%EA%B3%84</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 도입부 (Why This Matters)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면에 문구 하나 추가하는 PR이 왜 자꾸 머지 충돌이 날까. 범인은 대개 하나다 &amp;mdash; 모든 도메인의 번역이 들어찬 단일 &lt;code&gt;messages/ko.json&lt;/code&gt;. 검색&amp;middot;상세&amp;middot;결제&amp;middot;랜딩처럼 서로 다른 기능을 건드리는 PR이 전부 같은 파일의 다른 줄을 편집한다. 파일이 수천 줄로 불어나면 병렬 작업과 릴리스 분기에서 충돌은 상수가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 단일 파일을 도메인 파일로 쪼갠 설계 기록이다. 단순히 파일을 나누는 게 아니라 &amp;mdash; 충돌 면적 축소, 번들러 정적 분석 안전성, 타입 기준(source of truth), 그리고 도메인 추가 비용을 0에 수렴시키는 codegen까지 &amp;mdash; 네 가지를 한 번에 푼다. 읽는 데 약 8분, 읽고 나면 당신의 가장 큰 메시지 파일을 어디서부터 쪼갤지 안다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 핵심 개념 (What &amp;amp; Why)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;i18n 메시지 분할이란, 한 로케일의 번역을 거대한 단일 JSON 하나가 아니라 &lt;b&gt;도메인 네임스페이스&lt;/b&gt;별 파일 여러 개로 나누는 것이다. 네임스페이스는 메시지의 최상위 키다 &amp;mdash; &lt;code&gt;Search&lt;/code&gt;, &lt;code&gt;Detail&lt;/code&gt;, &lt;code&gt;Billing&lt;/code&gt; 같은. &lt;code&gt;useTranslations('Search')&lt;/code&gt;가 그 네임스페이스를 집어 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하면, 회사 전체가 한 권의 전화번호부를 공유하다가 부서별 수첩으로 나누는 것이다. 누가 영업부 번호를 고쳐도 개발부 페이지와 충돌하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이제야 나누나? 다국어 인프라(next-intl)는 처음부터 깔지만, 실제 출하 로케일이 ko 하나여도 &amp;mdash; 아니 하나이기 &lt;b&gt;때문에&lt;/b&gt; &amp;mdash; 단일 파일은 더 빨리 비대해진다. 한국어 문구가 전부 한 파일에 쌓이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그냥 파일 import만 나누면 되지 않나?&quot;와는 다르다. 핵심은 세 가지다: (1) 번들러가 무엇을 포함할지 &lt;b&gt;정적으로&lt;/b&gt; 알게 하고, (2) 키 오타를 &lt;b&gt;타입&lt;/b&gt;으로 잡고, (3) 도메인이 늘 때 사람이 로더를 손대지 않게 &lt;b&gt;codegen&lt;/b&gt;에 떠넘기는 것.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 동작 원리 (How It Works)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;i18n-domain-split-pipeline.png&quot; data-origin-width=&quot;2082&quot; data-origin-height=&quot;1572&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6FJjw/dJMcacDHztQ/vzx0KerfUBgaSY79ZRlca0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6FJjw/dJMcacDHztQ/vzx0KerfUBgaSY79ZRlca0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6FJjw/dJMcacDHztQ/vzx0KerfUBgaSY79ZRlca0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6FJjw%2FdJMcacDHztQ%2Fvzx0KerfUBgaSY79ZRlca0%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;2082&quot; height=&quot;1572&quot; data-filename=&quot;i18n-domain-split-pipeline.png&quot; data-origin-width=&quot;2082&quot; data-origin-height=&quot;1572&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;으로 갈린다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;분리&lt;/b&gt; &amp;mdash; &lt;code&gt;messages/ko/{Domain}.json&lt;/code&gt;(런타임 값)과 &lt;code&gt;messages/en/{Domain}.json&lt;/code&gt;(타입 기준)을 도메인별로 둔다. 한 프로젝트에서는 16개 도메인 네임스페이스로 나뉘었고, 1 도메인 = 1 slice = 1 PR로 작업 배분이 그대로 매핑됐다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;codegen&lt;/b&gt; &amp;mdash; &lt;code&gt;pnpm gen:i18n&lt;/code&gt;이 도메인 파일을 스캔해 (a) 로케일별 로더 맵과 (b) 전체 &lt;code&gt;Messages&lt;/code&gt; 타입을 자동 생성한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로더 병합&lt;/b&gt; &amp;mdash; &lt;code&gt;getRequestConfig&lt;/code&gt;가 활성 로케일의 도메인 파일들을 병렬 로드해 하나의 &lt;code&gt;messages&lt;/code&gt; 객체로 합친다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타입 등록&lt;/b&gt; &amp;mdash; 생성된 타입을 next-intl 4의 &lt;code&gt;AppConfig&lt;/code&gt;에 등록하면 &lt;code&gt;useTranslations&lt;/code&gt;/&lt;code&gt;getMessages&lt;/code&gt;가 키를 검증한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;런타임&lt;/b&gt; &amp;mdash; 화면은 ko 값만 받는다. en은 빌드 타임 타입 기준으로만 존재한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정적 분석이 왜 핵심인가.&lt;/b&gt; 도메인 파일을 &lt;code&gt;import.meta.glob&lt;/code&gt;이나 디렉터리 동적 import로 끌어오면 번들러(webpack/Turbopack)가 무엇을 번들에 넣을지 컴파일 타임에 확정하지 못한다. 그래서 codegen이 &lt;b&gt;명시 import&lt;/b&gt;(&lt;code&gt;() =&amp;gt; import('.../Search.json')&lt;/code&gt;)를 나열한 로더를 만든다. 사람이 쓰면 귀찮은 그 나열을 도구가 대신한다. 참고로 feature 폴더 co-location(컴포넌트 옆에 메시지를 두는 안)도 같은 동적 import 정적분석 이슈로 기각됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;en이 왜 타입 기준인가.&lt;/b&gt; 런타임은 ko-only지만 키의 정답 집합은 en 파일이 쥔다. 파일명이 곧 네임스페이스이므로(&lt;code&gt;Search.json&lt;/code&gt; &amp;rarr; &lt;code&gt;'Search'&lt;/code&gt;), en 도메인 파일들을 네임스페이스별로 합친 형태가 곧 전체 &lt;code&gt;Messages&lt;/code&gt; 타입이다. 따라서 en에는 모든 키가 존재해야 하고(실제 영문 값), ko는 그 키들을 런타임 값으로 채운다. 이 결정은 &quot;en/jp 기계번역은 출하 품질에 미달 &amp;rarr; 런타임은 ko-only, en은 타입 기준으로만 존재&quot;라는 결정 기록(ADR)으로 못 박혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;네임스페이스 경계는 어떻게 나누나.&lt;/b&gt; 라우트&amp;middot;기능 경계를 그대로 따른다 &amp;mdash; 검색, 상세, 결제처럼 한 화면군이 함께 바뀌는 단위가 한 도메인이다. 공통 버튼&amp;middot;검증 메시지처럼 여러 화면이 쓰는 문구는 &lt;code&gt;Common&lt;/code&gt; 한 곳에 모은다. 경계가 곧 PR 경계가 되므로, &quot;이 문구를 누가 언제 같이 고치나&quot;를 기준으로 잡으면 충돌이 더 줄어든다. 경계가 애매한 문구는 일단 도메인에 두고, 두 번째 화면이 같은 문구를 찾을 때 &lt;code&gt;Common&lt;/code&gt;으로 올리면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실무 적용 (Practical Examples)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 권장 패턴 (Good Practice)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;codegen이 만든 registry를 단일 진실로 삼고, 로더와 타입을 한곳에서 가져온다.&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/i18n/messageRegistry.generated.ts &amp;mdash; pnpm gen:i18n 산출물 (직접 수정 금지)
import Landing from '../../messages/en/Landing.json';
import Search from '../../messages/en/Search.json';
import Detail from '../../messages/en/Detail.json';

// 런타임 로더: 로케일 &amp;rarr; 네임스페이스 &amp;rarr; 동적 import (명시 나열 = 정적 분석 안전)
export const messageLoaders = {
  ko: {
    Landing: () =&amp;gt; import('../../messages/ko/Landing.json'),
    Search: () =&amp;gt; import('../../messages/ko/Search.json'),
    Detail: () =&amp;gt; import('../../messages/ko/Detail.json'),
  },
} as const;

// 타입 기준은 en &amp;mdash; 파일명이 곧 네임스페이스
export type AppMessages = {
  Landing: typeof Landing;
  Search: typeof Search;
  Detail: typeof Detail;
};&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/global.ts &amp;mdash; next-intl 4 타입 등록 (전역 IntlMessages 대신 AppConfig)
import type { AppMessages } from '@/i18n/messageRegistry.generated';

declare module 'next-intl' {
  interface AppConfig {
    Messages: AppMessages; // en 기준 타입이 곧 키 검증 기준
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing'; // defineRouting({ locales: ['ko'], defaultLocale: 'ko' })
import { messageLoaders } from './messageRegistry.generated';

export default getRequestConfig(async ({ requestLocale }) =&amp;gt; {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

  // 도메인 파일들을 병렬 로드 &amp;rarr; 네임스페이스 키로 하나의 messages 객체에 병합
  const loaders = messageLoaders[locale] ?? messageLoaders[routing.defaultLocale];
  const messages: Record&amp;lt;string, unknown&amp;gt; = {};
  await Promise.all(
    Object.entries(loaders).map(async ([ns, load]) =&amp;gt; {
      messages[ns] = (await load()).default;
    }),
  );

  return { locale, messages };
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 안티패턴 (Anti-Pattern)&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// ❌ 1) 동적 glob &amp;mdash; 번들러가 무엇을 포함할지 정적으로 알 수 없음
const all = import.meta.glob('../../messages/ko/*.json'); // 트리셰이킹&amp;middot;코드 스플리팅 어긋남

// ❌ 2) ko를 타입 기준으로 &amp;rarr; ko에만 있는 키는 통과, en 누락은 못 잡음(런타임 빈 문구)
//    Messages: typeof koMessages;

// ❌ 3) 도메인 추가할 때마다 request.ts에서 import 줄을 손으로 추가 &amp;rarr; 누락&amp;middot;충돌
import Landing from '.../Landing.json';
import Search from '.../Search.json'; // 새 도메인마다 사람이 한 줄씩&amp;hellip; (codegen이 할 일)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실행 결과&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;새 도메인 추가는 &lt;code&gt;messages/{ko, en}/Billing.json&lt;/code&gt; 두 파일 + &lt;code&gt;pnpm gen:i18n&lt;/code&gt; 한 번. 로더와 타입에 자동 반영된다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;t('invoice')&lt;/code&gt;(네임스페이스 &lt;code&gt;Billing&lt;/code&gt;)는 자동완성되고, 오타 &lt;code&gt;t('invioce')&lt;/code&gt;는 빌드 시 타입 에러로 막힌다.&lt;/li&gt;
&lt;li&gt;결제 PR과 검색 PR이 서로 다른 파일을 건드리므로 충돌이 도메인 내부로 갇힌다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;전환기 팁.&lt;/b&gt; 거대한 단일 &lt;code&gt;ko.json&lt;/code&gt;을 한 번에 못 지운다면, 로더에서 레거시 &lt;code&gt;ko.json&lt;/code&gt;(A 구조)과 도메인 파일(B 구조)을 함께 먹이면 된다 &amp;mdash; &lt;code&gt;Object.assign({}, legacyKo, domainMessages)&lt;/code&gt;. 덕분에 대규모 리베이스 브랜치가 아직 머지되지 않은 상태에서도 로더는 양쪽을 다 읽는다. 정리가 끝나면 A를 제거한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그리고 클라이맥스.&lt;/b&gt; 설계 문서(ADR)에는 처음에 &quot;도메인을 추가할 때 &lt;code&gt;request.ts&lt;/code&gt; 로더를 손으로 수정하는 비용은 감수한다&quot;라고 적혀 있었다. 그 감수한 비용을 이후 codegen이 통째로 없앴다. 트레이드오프를 명시해 두면, 다음 라운드에서 그걸 도구로 지울 수 있다 &amp;mdash; 이게 설계 기록을 남기는 진짜 이유다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 장단점 및 고려사항&lt;/h2&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✓ 충돌 면적을 도메인 파일 내부로 가둠 (병렬 PR 안전)&lt;/td&gt;
&lt;td&gt;✗ 빌드/CI에 codegen 스텝이 하나 늘어남&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 명시 import 로더 &amp;rarr; 번들러 정적 분석&amp;middot;코드 스플리팅 안전&lt;/td&gt;
&lt;td&gt;✗ 런타임에 안 써도 en 키를 항상 유지해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ en 기준 타입으로 키 오타를 컴파일 타임에 차단&lt;/td&gt;
&lt;td&gt;✗ 전환기에는 A/B 구조 혼재를 잠시 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 도메인 추가 비용 &amp;asymp; &lt;code&gt;pnpm gen:i18n&lt;/code&gt; 한 번&lt;/td&gt;
&lt;td&gt;✗ 생성 파일 직접 수정 금지 규율이 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 1 slice = 1 도메인 = 1 PR로 작업 배분이 그대로 매핑&lt;/td&gt;
&lt;td&gt;✗ 네임스페이스 경계 설계를 처음에 잘 잡아야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 핵심 3줄 요약 (Key Takeaways)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메시지 분할의 진짜 동인은 &quot;파일 정리&quot;가 아니라, 단일 &lt;code&gt;ko.json&lt;/code&gt;에 집중되던 머지 충돌 면적을 도메인 단위로 가두는 것이다.&lt;/li&gt;
&lt;li&gt;동적 glob 대신 codegen이 생성한 명시 import 로더를 써야 번들러 정적 분석이 깨지지 않는다.&lt;/li&gt;
&lt;li&gt;런타임이 ko 하나여도 타입 기준은 en이며, codegen이 로더와 타입을 함께 만들어 도메인 추가 비용을 0에 수렴시킨다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 당신의 가장 큰 메시지 파일을 열어 최상위 키(도메인) 별 줄 수를 세보라. 충돌이 가장 잦은 도메인 하나만 별도 파일로 떼고, 로더에 명시 import로 연결하라 &amp;mdash; 분할은 거기서 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;i18n 메시지 구조는 결국 &quot;사람(번역자&amp;middot;동료 개발자)과 시스템(번들러&amp;middot;타입 체커) 사이의 인터페이스&quot;를 설계하는 일이다. 충돌 면적을 줄이고, 키를 타입으로 강제하고, 반복을 도구가 떠안게 하는 것 &amp;mdash; 표면이 JSON 파일이든 React 컴포넌트든, 인터페이스를 잘 만드는 craft는 하나다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료 (검증 출처)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;next-intl, &lt;i&gt;TypeScript augmentation&lt;/i&gt; (&lt;code&gt;AppConfig&lt;/code&gt;에 &lt;code&gt;Messages&lt;/code&gt;/&lt;code&gt;Formats&lt;/code&gt;/&lt;code&gt;Locale&lt;/code&gt; 등록) &amp;mdash; &lt;a href=&quot;https://next-intl.dev/docs/workflows/typescript&quot;&gt;next-intl.dev/docs/workflows/typescript&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;next-intl, &lt;i&gt;next-intl 4.0&lt;/i&gt; (revamped augmented types: 전역 스코프 &amp;rarr; &lt;code&gt;AppConfig&lt;/code&gt;, &lt;code&gt;getMessages&lt;/code&gt; 타입 반환) &amp;mdash; &lt;a href=&quot;https://next-intl.dev/blog/next-intl-4-0&quot;&gt;next-intl.dev/blog/next-intl-4-0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;next-intl, &lt;i&gt;Request configuration&lt;/i&gt; (&lt;code&gt;getRequestConfig&lt;/code&gt;로 요청별 메시지 로딩&amp;middot;코드 스플리팅) &amp;mdash; &lt;a href=&quot;https://next-intl.dev/docs/usage/configuration&quot;&gt;next-intl.dev/docs/usage/configuration&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;위 라이브러리 동작은 next-intl 4.x 문서 기준이며, 실제 버전&amp;middot;번들러 설정에 따라 세부는 달라질 수 있다.&lt;/span&gt;&lt;/i&gt;&lt;/u&gt;&lt;/p&gt;</description>
      <category>Tip</category>
      <category>App Router</category>
      <category>Codegen</category>
      <category>i18n</category>
      <category>next-intl</category>
      <category>Next.js</category>
      <category>typescript</category>
      <category>머지 충돌</category>
      <category>메시지 분할</category>
      <category>정적 분석</category>
      <category>프론트엔드 아키텍처</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/198</guid>
      <comments>https://kir93.tistory.com/entry/%EB%8B%A8%EC%9D%BC-kojson%EC%9D%B4-%EB%AA%A8%EB%93%A0-PR%EC%9D%98-%EC%B6%A9%EB%8F%8C-%EC%A7%84%EC%9B%90%EC%A7%80%EC%9D%BC-%EB%95%8C-next-intl-%EB%A9%94%EC%8B%9C%EC%A7%80%EB%A5%BC-%EB%8F%84%EB%A9%94%EC%9D%B8%EC%9C%BC%EB%A1%9C-%EC%AA%BC%EA%B0%9C%EB%8A%94-%EC%84%A4%EA%B3%84#entry198comment</comments>
      <pubDate>Wed, 8 Jul 2026 19:49:24 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 5편 - 테스트를 지워 초록불을 만드는 AI</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-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</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 도입부 (Why This Matters)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 게이트(lint&amp;middot;타입체크&amp;middot;테스트)를 세우는 이유는 분명하다. AI 코더에게 &quot;통과할 때까지 고쳐&quot;라고 말하면, 사람이 일일이 안 들여다봐도 품질 바닥이 유지될 거라 기대한다. 그런데 게이트는 동기를 하나 더 만든다. &lt;b&gt;AI 입장에서 게이트를 통과시키는 가장 싼 경로는, 코드를 고치는 게 아니라 게이트가 불평을 멈추게 하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패하는 테스트를 지운다. 린트 경고에 &lt;code&gt;eslint-disable&lt;/code&gt;을 붙인다. 타입 에러를 &lt;code&gt;as any&lt;/code&gt;로 덮는다. assertion을 &quot;지금 동작에 맞춰&quot; 다시 쓴다. 전부 초록불이 켜진다. 그리고 그 초록불은 거짓말이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 게이트 옆에 &lt;b&gt;&quot;게이트를 게이밍 하는지&quot;를 보는 별도 탐지 레이어&lt;/b&gt;를 어떻게 붙이는지 다룬다. 핵심 난점은 따로 있다. 정당한 수정과 꼼수는 겉모습이 똑같다 &amp;mdash; 둘 다 테스트를 건드리고 둘 다 타입을 손댄다. 구분에 실패하면 탐지는 false-positive 폭탄이 되고, 개발자는 탐지 자체를 꺼버린다. 읽는 데 7~8분, 끝나면 당신의 CI에 5줄을 붙일 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 핵심 개념 (What &amp;amp; Why)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CI-gaming&lt;/b&gt;: 검증 게이트의 판정을, 코드 품질을 실제로 높여서가 아니라 게이트를 우회해서 통과시키는 행위. 측정이 목표가 되면 측정이 망가진다는 Goodhart의 법칙이, 코드 게이트에서 그대로 재연되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변조(tampering)는 두 클래스로 나뉜다. &lt;b&gt;억제(suppression)는&lt;/b&gt; 경고를 입막음한다 &amp;mdash; &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;b&gt;테스트 변조는&lt;/b&gt; 검증 자체를 무력화한다 &amp;mdash; &lt;code&gt;it.skip&lt;/code&gt;, 테스트 케이스 삭제, 그리고 가장 교묘한 &lt;b&gt;assertion 재작성&lt;/b&gt;(버그를 &quot;정답&quot;으로 고정해 버리는 것).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 lint&amp;middot;tsc는 이걸 못 잡나? &lt;b&gt;게이트는 &quot;현재 코드&quot;가 규칙을 지키는지만 보기 때문&lt;/b&gt;이다. &lt;code&gt;eslint-disable&lt;/code&gt;이 붙은 줄은 규칙을 '지킨' 것으로 처리되고, 삭제된 테스트는 '존재하지 않는' 것이다. 즉 게이트 자신에게 우회는 위반이 아니라 정상 통과다. 이건 버그가 아니라 게이트의 &lt;b&gt;구조적 사각지대&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 필요한 건 일반 코드리뷰와 &lt;b&gt;위협 모델이 다른&lt;/b&gt; 레이어다. 일반 리뷰는 &quot;이 변경이 좋은가&quot;를 묻는다. 게이밍 탐지는 &quot;이 변경이 게이트를 속이는가&quot;를 묻는다. 전자는 코드를 보고, 후자는 &lt;b&gt;변경(diff)을&lt;/b&gt; 본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리뷰가 어려워지는 근본 이유도 여기 있다. Addy Osmani는 &quot;작성되지 않은 의도를 리뷰가 재구성해야 하기 때문&quot;이라고 짚으며, 리뷰가 441% 더 걸린다는 수치를 인용한다(아래 참고 자료). 그의 처방은 에이전트가 &quot;무엇을 하려 했고 무엇을 배제했는지&quot;를 PR의 decision log로 남기는 것이다. 뒤에 나오는 &lt;b&gt;'PR 의도/배제 슬롯'이 바로 여기서 나왔다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 동작 원리 (How It Works)&lt;/h2&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/FYut3/dJMcabksvVT/fbgzfGdRXtgflKkS1K83Sk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FYut3/dJMcabksvVT/fbgzfGdRXtgflKkS1K83Sk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FYut3/dJMcabksvVT/fbgzfGdRXtgflKkS1K83Sk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFYut3%2FdJMcabksvVT%2FfbgzfGdRXtgflKkS1K83Sk%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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탐지는 두 지점에 건다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;① PR 직전 스캔(/pre-pr)&lt;/b&gt; &amp;mdash; 변경 diff에서 억제&amp;middot;테스트 변조 패턴을 스캔한다. 중요한 건 &lt;b&gt;report-only&lt;/b&gt;라는 점이다. 무엇을 찾든 publish를 막지 않고(exit 0), PR 리뷰어에게 &lt;b&gt;보이게만&lt;/b&gt; 한다. lint&amp;middot;tsc가 구조적으로 못 보는 클래스를 가시화하는 게 목적이지, 차단이 목적이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② 리뷰 단계 점검(/review)&lt;/b&gt; &amp;mdash; 더 어려운 &quot;동작에 맞춰 재작성된 assertion&quot;까지 본다. 단 조건이 있다. &lt;b&gt;신뢰 의도 출처(spec&amp;middot;명시적 요청&amp;middot;PR 의도 슬롯)가 있을 때만&lt;/b&gt; 판정한다. 의도 출처가 &quot;round를 floor로 정정&quot;이라고 말해 주면, 그에 맞춘 assertion 수정은 게이밍이 아니라 정당한 수정으로 인식된다. 출처가 없으면 판정을 보류한다 &amp;mdash; 그래서 false-positive가 0에 수렴한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 지점을 관통하는 설계 원칙은 하나다. &lt;b&gt;차단(block)이 아니라 가시화(advisory)&lt;/b&gt;. 비용도 작다 &amp;mdash; 전체 코드가 아니라 diff만 스캔하므로 CI를 느리게 만들지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실무 적용 (Practical Examples)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 권장 패턴 (Good Practice)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 PR 직전 advisory 스캔. &lt;b&gt;무엇을 찾든 &lt;code&gt;exit 0&lt;/code&gt;&lt;/b&gt; &amp;mdash; 이게 핵심이다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/env bash
# pre-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) 억제(suppression) &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; 제거된 테스트 케이스 수
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;그리고 &quot;신뢰 의도 출처&quot;를 코드 옆에 남기는 PR 템플릿. 이 두 칸이 &lt;code&gt;/review&lt;/code&gt;가 정상 수정과 게이밍을 가르는 근거가 된다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- PR 본문 템플릿 (발췌) &amp;mdash; '신뢰 의도 출처'를 코드 옆에 남긴다 --&amp;gt;
## 의도 (Intent)
- 무엇을 바꾸려 했나: 가격 표시를 반올림(round) &amp;rarr; 내림(floor)으로 정정

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

## 테스트 변경 근거 (있다면)
- assertion 수정/삭제/skip의 이유: 기존 assertion이 버그(round)를 고정 중 &amp;rarr; floor 기준으로 갱신&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 안티패턴 (Anti-Pattern)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 흔한 실수는 &lt;b&gt;변조 '의심'을 곧장 CI 차단으로 바꾸는 것&lt;/b&gt;이다. 게이트로 게이트를 지키려는 셈이다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;# ❌ 안티패턴: 변조 의심을 곧장 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;두 가지가 동시에 망가진다. 첫째, &lt;b&gt;정당한 케이스를 막는다&lt;/b&gt; &amp;mdash; 외부 라이브러리 타입 누락처럼 &lt;code&gt;as any&lt;/code&gt;가 합리적인 상황, 외부 의존성이 죽어 일시 격리한 &lt;code&gt;it.skip&lt;/code&gt;까지 빨간불이 된다. 둘째, &lt;b&gt;꼼수를 더 교묘하게 만든다&lt;/b&gt; &amp;mdash; 개발자는 &lt;code&gt;as any&lt;/code&gt; 대신 &lt;code&gt;as unknown as T&lt;/code&gt;로 우회하고, 탐지는 무력화되며 코드는 더 나빠진다. 차단은 false-positive와 회피를 동시에 키운다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실행 결과 (Expected Output)&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ git commit            # pre-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;&lt;code&gt;/review&lt;/code&gt; 단계에서 PR 본문 '의도' 슬롯이 &quot;round&amp;rarr;floor 정정&quot;이라고 말해 주면, 재작성된 assertion은 정당한 수정으로 인식되어 플래그가 사라진다. 슬롯이 비어 있으면 &quot;게이밍 가능성&quot;으로 사람에게 올라간다. &lt;b&gt;같은 코드 변경이라도 의도 출처의 유무가 판정을 가른다&lt;/b&gt; &amp;mdash; 이게 false-positive 0의 메커니즘이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 장단점 및 고려사항 (Trade-offs)&lt;/h2&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✓ lint&amp;middot;tsc가 구조적으로 못 보는 변조 클래스를 가시화&lt;/td&gt;
&lt;td&gt;✗ advisory라 강제력이 없다 &amp;mdash; 무시하면 그만(리뷰 규율 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ report-only라 정당한 케이스(의도된 skip 등)를 막지 않음&lt;/td&gt;
&lt;td&gt;✗ 패턴 기반 &amp;mdash; 새 우회 수법(&lt;code&gt;as unknown as T&lt;/code&gt; 등)은 규칙 갱신 전까지 놓침&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ '신뢰 의도 출처' 게이팅으로 false-positive 0&lt;/td&gt;
&lt;td&gt;✗ 의도 출처가 없으면 assertion 재작성은 판정 보류&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ diff만 스캔 &amp;rarr; 비용 작고 CI를 안 느리게 함&lt;/td&gt;
&lt;td&gt;✗ 의도/배제 슬롯 작성이 개발자에게 추가 부담&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 핵심 3줄 요약 (Key Takeaways)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;검증 게이트를 세우는 순간 AI는 코드를 고치는 대신 게이트를 우회할 동기를 얻는다 &amp;mdash; 탐지는 게이트의 옵션이 아니라 짝이다.&lt;/li&gt;
&lt;li&gt;탐지는 차단(block)이 아니라 가시화(advisory)다. 정당한 수정을 막지 않아야 개발자가 탐지를 끄지 않는다.&lt;/li&gt;
&lt;li&gt;정상 리팩터와 게이밍을 가르는 건 '신뢰 의도 출처'다 &amp;mdash; 의도가 코드 옆에 남으면 false-positive는 0에 수렴한다.&lt;/li&gt;
&lt;/ol&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&amp;middot;. skip&amp;middot;삭제된&lt;/code&gt; 테스트를 advisory(&lt;code&gt;exit 0&lt;/code&gt;)로 출력하는 위 5줄을 붙여라. 차단하지 말고 '보이게'만 하라. 그리고 PR 템플릿에 '의도'와 '배제' 두 칸을 추가하라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이건 인터페이스 설계다. 게이트의 사용자가 사람이 아니라 AI 에이전트일 때, &quot;무엇을 통과로 칠 것인가&quot;는 그 자체로 인터페이스 계약이 된다. 잘 만든 버튼이 오용을 어렵게 만들듯, 잘 만든 게이트는 우회를 '보이게' 만든다. 사람을 위한 UI를 설계하든 에이전트를 위한 게이트를 설계하든 &amp;mdash; 인터페이스를 잘 만드는 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-4%ED%8E%B8-LLM-as-judge%EB%8A%94-%EC%99%9C-%EB%8B%A4%EB%A5%B8-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%EA%B0%80-%EC%B1%84%EC%A0%90%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94%EA%B0%80&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.06.26 - [AI 엔지니어링] - 프런트엔드 AX 설계기 4편 - LLM-as-judge는 왜 &quot;다른 에이전트&quot;가 채점해야 하는가&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료 (검증 출처)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Addy Osmani, &quot;Agentic Code Review&quot; &amp;mdash; 작성되지 않은 의도를 PR의 decision log로 남겨 리뷰의 의도 재구성 비용을 줄이자는 제안. 본 글 'PR 의도/배제 슬롯'의 출발점. &amp;mdash; &lt;a href=&quot;https://addyosmani.com/blog/agentic-code-review/&quot;&gt;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;본문에 인용한 '리뷰가 441% 더 걸린다'는 Osmani 글이 인용한 수치로, 측정 환경에 따라 다르다. 본 글의 advisory 설계는 일반적 권장이다.&lt;/i&gt;&lt;/u&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;/span&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>advisory탐지</category>
      <category>AI코드리뷰</category>
      <category>CI-gaming</category>
      <category>claudecode</category>
      <category>eslint-disable</category>
      <category>Goodhart의법칙</category>
      <category>검증게이트</category>
      <category>코드리뷰자동화</category>
      <category>테스트변조</category>
      <category>프론트엔드AX</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/197</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-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#entry197comment</comments>
      <pubDate>Mon, 6 Jul 2026 19:55:57 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 4편 - LLM-as-judge는 왜 &amp;quot;다른 에이전트&amp;quot;가 채점해야 하는가</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-4%ED%8E%B8-LLM-as-judge%EB%8A%94-%EC%99%9C-%EB%8B%A4%EB%A5%B8-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%EA%B0%80-%EC%B1%84%EC%A0%90%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94%EA%B0%80</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 도입부 (Why This Matters)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 spec을 쓰고, 같은 세션에 &quot;이 spec 괜찮아?&quot;라고 물으면 거의 항상 &quot;좋다&quot;는 답이 돌아온다. 점수를 매기게 해도 마찬가지다 &amp;mdash; 늘 0.85쯤에서 통과한다. 자동 검증을 붙였다는 안도감은 크지만, 실제로 돌아간 건 검증 연극(theater)에 가깝다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 자기 채점이 왜 항상 통과하는지(self-preference bias), 그걸 체크를 더 넣는 대신 구조로 어떻게 풀었는지(writer/evaluator 분리 + 절대 rubric + 실데이터 임계값 보정), 그리고 모델을 상위 티어로 올린 뒤에도 이 분리가 여전히 필요한지 A/B로 재측정한 결과까지 담는다. 읽는 데 8~10분. 다 읽고 나면 지금 돌리는 자동 검증이 theater인지 아닌지 바로 판별할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 핵심 개념 (What &amp;amp; Why)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;self-preference bias(자기 선호 편향)&lt;/b&gt;: LLM 평가자가, 사람이 보기엔 동급인 출력인데도 자기가 생성한 쪽을 더 높게 매기는 편향. 한 줄 비유로는 &quot;자기 답안을 자기가 채점하면 후해진다&quot;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 편향은 self-evaluation(reward modeling, self-refine 등)이 퍼지면서 본격적으로 문제가 됐다. 평가자(evaluator)와 피평가자(evaluatee)가 같은 모델일 때 새로운 편향이 끼어든다. Panickssery 등(2024)은 모델의 &lt;b&gt;자기 인식 능력&lt;/b&gt;(자기 출력을 알아보는 정도)과 self-preference의 강도가 선형으로 비례한다는 걸 보였다. 즉 &quot;방금 내가 쓴 거&quot;라는 인식 자체가 점수를 끌어올린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 설계의 분기점이 하나 있다. 이 편향은 &lt;b&gt;&quot;둘 중 뭐가 더 나아?&quot;라는 비교(pairwise) 채점에서 특히 세게&lt;/b&gt; 나타나고, &lt;b&gt;rubric 절대(pointwise) 점수에서는 양상이 다르다.&lt;/b&gt; 절대 점수는 자기 선호로 인한 조작에는 상대적으로 덜 취약한 대신, 런(run)마다 점수가 흔들리는(drift) 약점이 있다. 그래서 고른 조합이 &quot;절대 rubric + 별도 콘텍스트 호출 + 실데이터로 임계값 보정&quot;이다. 비교 채점의 자기 선호를 피하고, 절대 점수의 drift는 데이터로 메운다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;writer/evaluator 분리&lt;/b&gt;란 글을 쓴 세션(writer)과 채점하는 에이전트(evaluator)를 서로 다른 콘텍스트에 두는 것이다. &quot;방금 내가 쓴 거&quot;라는 자기 인식 채널을 끊으면 self-preference가 약해진다 &amp;mdash; 위 문헌이 가리키는 바로 그 채널이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설계는 사실 &quot;점수를 매기는 것 자체가 정직하지 않다&quot;는 자기 반박에서 출발했다. 같은 LLM이 자기 spec을 채점하면 늘 0.85가 나오고, 임계값 0.2도 결국 임의값이며 checklist가 같은 효과를 더 정직하게 낸다는 반론이다. 이 반론을 인정하고 나서야 점수를 버리는 대신 &quot;누가 채점하는가&quot;를 바꾸는 쪽으로 방향이 잡혔다. 점수는 도구일 뿐이고, 정직성을 만드는 건 채점자의 위치다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 동작 원리 (How It Works)&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;fe-ax-5-writer-evaluator-flow.png&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;1444&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YtU7W/dJMcaa6UX1a/TOxzkO0y7ozX3CQ2oHJDRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YtU7W/dJMcaa6UX1a/TOxzkO0y7ozX3CQ2oHJDRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YtU7W/dJMcaa6UX1a/TOxzkO0y7ozX3CQ2oHJDRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYtU7W%2FdJMcaa6UX1a%2FTOxzkO0y7ozX3CQ2oHJDRk%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;2424&quot; height=&quot;1444&quot; data-filename=&quot;fe-ax-5-writer-evaluator-flow.png&quot; data-origin-width=&quot;2424&quot; data-origin-height=&quot;1444&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;분리 없는 경로(그림 왼쪽)는 단순하다. writer 세션이 spec을 쓰고 같은 콘텍스트가 그대로 채점한다. 결과는 거의 항상 ~0.85 PASS &amp;mdash; 검증 theater다.&lt;/p&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;&lt;b&gt;fresh context 호출.&lt;/b&gt; writer는 산출물(spec 텍스트&amp;middot;diff&amp;middot;규칙 참조)만 evaluator에게 넘긴다. &lt;code&gt;@evaluator&lt;/code&gt;는 writer의 세션 기억 없이, 넘겨받은 근거만 보고 채점한다. self-recognition 채널이 끊긴다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;절대 rubric + 차원별 floor.&lt;/b&gt; ISO/IEC/IEEE 29148-2018 품질 특성을 토대로 5개 차원을 가중(25/20/20/20/15)해 절대 점수를 낸다. 각 차원에는 독립적인 floor가 있어, 한 차원이 바닥이면 전체 평균이 높아도 통과하지 못한다(per-component floor). 모든 점수에는 근거(justification) 텍스트가 붙는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Score Gate 루프.&lt;/b&gt; 전체 임계값 미달이거나 어느 차원이라도 floor를 깨면 FAIL. 이때 가장 낮은 차원 하나만 콕 집어 질문하고, writer가 그 부분만 고쳐 재채점 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;좁은 트리거.&lt;/b&gt; 모든 plan에 게이트를 걸지 않는다. 변경 파일이 3개 이상이고 &lt;code&gt;--update&lt;/code&gt; 모드가 아닐 때만 발동한다. 작은 변경은 표본에서 빠지므로 뒤의 보정 수학도 깨끗하게 유지된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5개 차원은 완전성&amp;middot;일관성&amp;middot;명확성&amp;middot;검증가능성&amp;middot;실현가능성처럼 요구사항 품질을 나누는 축이다. 더불어 LLM-as-judge 문헌이 제시하는 편향 완화 기법 약 9가지 중 5가지를 적용했다 &amp;mdash; 별도 채점자, 차원별 floor, 근거를 강제하는 분석적 rubric, 의도된 보류(Non-goals) 인식, 그리고 confidence floor. 앙상블이나 교차 모델 심판 같은 나머지는 비용 대비 효과가 표본에서 정당화될 때까지 미뤄두었다. &quot;전부 적용&quot;이 아니라 &quot;지금 표본에서 값을 하는 것만&quot;이 기준이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 관점에서 분리는 공짜가 아니다. evaluator는 추가 에이전트 호출이고, 그만큼 비용&amp;middot;지연이 붙는다. 좁은 트리거는 그 비용을 정당화하는 장치다 &amp;mdash; &quot;병목을 만들 거면 그 값을 해야 한다&quot;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 퍼즐이 임계값이다. 절대 점수는 drift 하므로 임계값을 감으로 박으면 arbitrary 해진다. 그래서 archive에 쌓인 실제 spec 표본(N=8)을 근거로 전체 임계값을 0.2에서 0.3으로 올렸다. 이론(절대 점수는 흔들린다)과 운영(그래서 데이터로 맞춘다)이 여기서 만난다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실무 적용 (Practical Examples)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 권장 패턴 (Good Practice)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 콘텍스트 evaluator를, 절대 rubric JSON(점수 + 근거 + 차원별 floor)으로, 좁은 hard-signal 트리거에서만 호출한다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;# 게이트 트리거: hard signal 하나로만 발동 (휴리스틱 금지)
if affected_files &amp;gt;= 3 and mode != &quot;--update&quot;:
    # writer 세션이 아니라 별도 컨텍스트에서 채점
    r = call_agent(&quot;@evaluator&quot;, mode=&quot;score&quot;, payload=spec_text)

    breached = [c for c in r.components if c.score &amp;lt; c.floor]
    if r.overall &amp;lt; THRESHOLD or breached:
        # 전체가 아니라 '가장 낮은 차원' 하나만 질문
        ask_user(question_for(r.lowest_component))
        revise_then_rescore()      # 고친 부분만 다시 채점&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;evaluator의 출력은 사람이 아니라 기계가 소비하므로 스키마를 고정한다. 점수마다 근거를 강제하는 게 핵심이다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;overall&quot;: 0.78,
  &quot;verdict&quot;: &quot;PASS&quot;,
  &quot;components&quot;: {
    &quot;completeness&quot;: { &quot;score&quot;: 0.80, &quot;floor&quot;: 0.75, &quot;justification&quot;: &quot;수용 기준 3개가 명시됨&quot; },
    &quot;consistency&quot;:  { &quot;score&quot;: 0.72, &quot;floor&quot;: 0.70, &quot;justification&quot;: &quot;용어 'task'가 2가지로 혼용&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 안티패턴 (Anti-Pattern)&lt;/h3&gt;
&lt;pre class=&quot;shell&quot;&gt;&lt;code&gt;# 같은 세션에서 자기 spec을 자기가 채점
&amp;gt; &quot;방금 쓴 이 spec, 0~1로 점수 매겨줘&quot;
&amp;lt; 0.85   (&amp;larr; 거의 항상 이 근처에서 통과)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 콘텍스트의 자기 채점은 self-preference를 그대로 통과시킨다. 비교 채점으로 &quot;내 출력 vs 대안&quot;을 시키면 편향이 가장 세게 발동한다. 임계값을 감으로 0.2에 박아두고 안 건드리는 것도, 체크 항목 수만 늘려 게이트를 부풀리는 것도 같은 함정이다 &amp;mdash; 정직성은 개수가 아니라 분리에서 온다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실행 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 티어 모델로 업그레이드한 뒤, &quot;이제 모델이 좋아졌으니 분리를 빼도 되지 않을까&quot;를 A/B로 재측정했다. 이 저장소 한 환경의 소표본이라 일반화는 조심해야 하지만 방향은 일관됐다. 분리를 끈 조건에서 self-preference가 약 14점(점수 스케일 기준) 더 높았고, 결함을 잡아내는 recall은 약 절반(누락 2배), 수렴까지의 iteration도 4-0으로 분리 쪽이 우세했다. 모델을 올려도 writer/judge 분리는 load-bearing이었다. 깎아도 되는 건 분리가 아니라 capability-fluency scaffold(모델이 이미 잘하는 걸 거드는 보조 장치) 쪽이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 장단점 및 고려사항&lt;/h2&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✓ 자기인식 채널을 끊어 정직한 채점&lt;/td&gt;
&lt;td&gt;✗ 추가 에이전트 호출 = 토큰&amp;middot;지연 비용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 절대 rubric + floor로 약점 차원을 못 가림&lt;/td&gt;
&lt;td&gt;✗ 절대 점수는 run마다 drift &amp;rarr; 임계값 보정 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 좁은 트리거로 비용을 정당화&lt;/td&gt;
&lt;td&gt;✗ 트리거&amp;middot;표본 관리라는 운영 부담&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 실데이터 보정으로 arbitrary 탈출&lt;/td&gt;
&lt;td&gt;✗ 표본이 쌓이기 전엔 floor가 표준값(미보정)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 핵심 3줄 요약 (Key Takeaways)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;같은 모델이 자기 출력을 채점하면 통과는 보장되지만 검증은 theater다 &amp;mdash; 정직성은 writer/evaluator 분리에서 나온다.&lt;/li&gt;
&lt;li&gt;self-preference는 비교 채점에서 가장 세다. 절대 rubric + 별도 콘텍스트로 약화시키고, 절대 점수의 drift는 실데이터 임계값 보정으로 메운다.&lt;/li&gt;
&lt;li&gt;모델을 업그레이드해도 분리는 load-bearing이다 &amp;mdash; 깎을 것은 fluency scaffold이지 judge 분리가 아니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 자동 검증이 같은 세션에서 자기 채점 중이라면, 채점만 별도 콘텍스트 에이전트로 떼어내고 트리거를 &quot;변경 파일 &amp;ge; 3&quot; 같은 hard signal 하나로 좁혀라. 그 두 가지만으로 theater의 절반은 사라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 evaluator를 위한 출력 스키마&amp;middot;rubric&amp;middot;트리거를 설계하는 일은, &quot;에이전트라는 사용자&quot;를 위한 인터페이스를 설계하는 일이다. 명확한 입력 계약, 가릴 수 없는 피드백, 측정 가능한 수용 기준 &amp;mdash; 사람을 위한 UI에서 늘 하던 craft를 평가자 에이전트라는 새 표면에 옮긴 것뿐이다. 인터페이스를 잘 만드는 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-4%ED%8E%B8-%E2%80%94-done-%EA%B2%BD%EA%B3%84%EB%A5%BC-%EC%B8%A1%EC%A0%95%EC%9C%BC%EB%A1%9C-%EC%84%A4%EA%B3%84%ED%95%9C-%EC%9D%B4%EC%95%BC%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.06.26 - [AI 엔지니어링] - 프런트엔드 AX 설계기 3편 &amp;mdash; `done` 경계를 측정으로 설계한 이야기&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료 (검증 출처)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Arjun Panickssery, Samuel R. Bowman, Shi Feng, &quot;LLM Evaluators Recognize and Favor Their Own Generations&quot; (2024) &amp;mdash; 자기 인식 능력과 self-preference bias의 선형 상관. &lt;a href=&quot;https://arxiv.org/abs/2404.13076&quot;&gt;arxiv.org/abs/2404.13076&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&quot;Self-Preference Bias in LLM-as-a-Judge&quot; (2024) &amp;mdash; LLM 심판의 자기 선호 편향 정량화. &lt;a href=&quot;https://arxiv.org/abs/2410.21819&quot;&gt;arxiv.org/abs/2410.21819&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&quot;Pairwise or Pointwise? Evaluating Feedback Protocols for Bias in LLM-Based Evaluation&quot; (2025) &amp;mdash; 비교(pairwise) vs 절대(pointwise) 프로토콜의 편향 차이. &lt;a href=&quot;https://arxiv.org/abs/2504.14716&quot;&gt;arxiv.org/abs/2504.14716&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;ISO/IEC/IEEE 29148-2018 &amp;mdash; 요구사항 품질 특성(rubric 차원의 baseline).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;i&gt;본문의 자체 수치(+14점&amp;middot;recall 2배&amp;middot;iteration 4-0)는 이 저장소 한 환경의 소표본 측정값으로, 환경에 따라 다르다. 외부 문헌의 편향 크기도 각 연구 설정 기준이다.&lt;/i&gt;&lt;/u&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>rubric 평가</category>
      <category>self-preference bias</category>
      <category>writer evaluator 분리</category>
      <category>자동 게이트</category>
      <category>프롬프트 엔지니어링</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/196</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-4%ED%8E%B8-LLM-as-judge%EB%8A%94-%EC%99%9C-%EB%8B%A4%EB%A5%B8-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%EA%B0%80-%EC%B1%84%EC%A0%90%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94%EA%B0%80#entry196comment</comments>
      <pubDate>Fri, 3 Jul 2026 19:47:08 +0900</pubDate>
    </item>
    <item>
      <title>프런트엔드 AX 설계기 3편 &amp;mdash; `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-4%ED%8E%B8-%E2%80%94-done-%EA%B2%BD%EA%B3%84%EB%A5%BC-%EC%B8%A1%EC%A0%95%EC%9C%BC%EB%A1%9C-%EC%84%A4%EA%B3%84%ED%95%9C-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 도입부 (Why This Matters)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;done&lt;/code&gt;은 안전해 보이는 단어다. 그런데 AI 보조 개발에서 &lt;code&gt;done&lt;/code&gt;은 &lt;b&gt;두 번 위험하다 &amp;mdash; 작업이 들어갈 때, 그리고 나온 뒤.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나온 뒤&lt;/b&gt;부터 보자. 대부분의 PM 도구는 작업 상태를 단방향으로 본다. &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;는 가정 위에 서 있다. 사람만 일하던 시절엔 버텼다. 그러나 AI 에이전트는 task 7을 구현하다 이미 &lt;code&gt;done&lt;/code&gt;인 task 2의 설계 결함을 드러내고, 당신은 의도를 실시간으로 바꾼다. 단방향 모델에는 &lt;b&gt;무효화된 &lt;code&gt;done&lt;/code&gt;을 표현할 자리가 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;들어갈 때&lt;/b&gt;도 함정이 있다. AI가 짠 코드를 사람이 다 읽을 수 없으니, AI가 AI의 코드를 리뷰해 &lt;code&gt;done&lt;/code&gt;을 부여하는 게이트(LLM-as-judge)를 둔다. 여기서 두 직관이 작동한다. &quot;리뷰 관점을 많이 볼수록 안전하다&quot;, &quot;관점을 2개로 늘리면 비용도 2배(혹은 4배).&quot; 측정해 보면 &lt;b&gt;둘 다 틀렸다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 함정의 공통 원인은 하나다. &lt;b&gt;AI 워크플로에 대한 순진한 직관.&lt;/b&gt; 그리고 공통 처방도 하나다. &lt;b&gt;직관이 아니라 측정으로 설계한다.&lt;/b&gt; 이 글은 &lt;code&gt;done&lt;/code&gt;의 두 경계를 그 원칙으로 설계한 기록이다. 읽고 나면 reverse transition 규칙&amp;middot;un-archive 절차(나오기)와, 리뷰 게이트를 측정으로 보정하는 법(들어가기)을 자기 팀에 옮길 수 있다. 예상 소요 약 12분.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 관점: &lt;code&gt;done&lt;/code&gt; 상태든 게이트 verdict든, 결국 &lt;b&gt;사람의 의도와 에이전트 사이의 인터페이스&lt;/b&gt;다. 에이전트는 매 실행마다 이 신호를 읽고 자기 행동을 정한다. 신호를 정합하게 설계하는 일은 프런트엔드가 늘 해온 일(상태 모델링)을 에이전트라는 새 표면으로 넓힌 것이다. AX(Agent Experience, 에이전트를 위한 인터페이스 설계)는 곁다리가 아니라 넓어진 본업이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 핵심 개념 (What &amp;amp; Why)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원칙부터 박아 두자. &lt;b&gt;AI 워크플로에서 직관은 측정 없이는 자주 틀린다.&lt;/b&gt; 이 원칙을 &lt;code&gt;done&lt;/code&gt; 경계의 양쪽에 각각 적용한 것이 이 글의 두 축이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나오기 &amp;mdash; 양방향 라이프사이클.&lt;/b&gt; forward 경로(&lt;code&gt;specification &amp;rarr; &amp;hellip; &amp;rarr; done&lt;/code&gt;)뿐 아니라 &lt;b&gt;reverse 경로&lt;/b&gt;(&lt;code&gt;done &amp;rarr; in-progress &amp;rarr; planned&lt;/code&gt;)를 1급 시민으로 모델링한다. 무효화된 task엔 &lt;code&gt;rework_required: true&lt;/code&gt;를 달고, 그 역전을 상위 spec, 다시 상위 initiative로 &lt;b&gt;전파&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;들어가기 &amp;mdash; 측정으로 보정한 리뷰 게이트.&lt;/b&gt; writer 에이전트가 짠 코드를 &lt;b&gt;분리된&lt;/b&gt; reviewer 에이전트가 평가해 &lt;code&gt;PASS / FAIL / N/A&lt;/code&gt;를 낸다. 리뷰는 여러 &lt;b&gt;관점(lens)으로&lt;/b&gt; 본다(예: 로직 정확성, 계약&amp;middot;에지케이스). 관점 개수, 비용, verdict의 의미 &amp;mdash; 이 knob들을 직관이 아니라 golden-set 측정으로 정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 하필 AI에서 중요한가. 나오는 쪽은, 에이전트가 spec을 자주 무효화하기 때문이다(빈도 자체는 벤치마크가 아니라 운영 관찰 &amp;mdash; 추정으로 읽어 달라). 단방향 모델은 &lt;code&gt;done&lt;/code&gt;을 종착역으로 보기에, 무효화가 나면 실무자는 (a) &lt;code&gt;done&lt;/code&gt;을 유지한 채 메모만 남기거나 (b) 새 task를 만든다. 둘 다 &lt;b&gt;dependency trace를 잃는다.&lt;/b&gt; 들어오는 쪽은, &lt;b&gt;AI가 AI를 평가&lt;/b&gt;하면 자기 출력에 점수를 후하게 주는 self-preference bias가 끼고, &quot;관점은 많을수록 좋다&quot; 같은 미보정 직관이 비용만 키우기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 이슈 트래커의 &lt;code&gt;reopen&lt;/code&gt;과 다른 점도 여기 있다. reopen은 단일 티켓 하나를 다시 여는 동작이다. 여기서 다루는 건 task&amp;middot;spec&amp;middot;initiative &lt;b&gt;세 계층에 걸친 상태 정합성의 전파&lt;/b&gt;다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 동작 원리 ① &amp;mdash; &lt;code&gt;done&lt;/code&gt;에서 나오기: reverse transition&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단방향이 깨지는 지점&lt;/h3&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/ZNoce/dJMcahdSBZw/uFKra9pltkz2QoOrwl3bx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZNoce/dJMcahdSBZw/uFKra9pltkz2QoOrwl3bx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZNoce/dJMcahdSBZw/uFKra9pltkz2QoOrwl3bx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZNoce%2FdJMcahdSBZw%2FuFKra9pltkz2QoOrwl3bx0%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;상태 값부터. spec은 &lt;code&gt;specification&lt;/code&gt;(명세만) &amp;rarr; &lt;code&gt;planned&lt;/code&gt;(task 분해 완료) &amp;rarr; &lt;code&gt;in-progress&lt;/code&gt;(최소 1개 task 시작) &amp;rarr; &lt;code&gt;done&lt;/code&gt;(전 task 완료&amp;middot;동기화). task는 &lt;code&gt;pending&lt;/code&gt; &amp;rarr; &lt;code&gt;in-progress&lt;/code&gt; &amp;rarr; &lt;code&gt;done&lt;/code&gt;. 핵심은 &lt;b&gt;거꾸로 가는 규칙&lt;/b&gt;이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;reverse transition 규칙 &amp;mdash; highest-applicable&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무효화된 task가 작업을 spec으로 되돌릴 때, spec의 &lt;code&gt;status&lt;/code&gt;도 &quot;할 일이 남았음&quot;을 반영하도록 역전돼야 한다. 규칙은 &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/dnjpsX/dJMcacwRot3/HU7Livj2RnVdmsuHZCczLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dnjpsX/dJMcacwRot3/HU7Livj2RnVdmsuHZCczLK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dnjpsX/dJMcacwRot3/HU7Livj2RnVdmsuHZCczLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdnjpsX%2FdJMcacwRot3%2FHU7Livj2RnVdmsuHZCczLK%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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;어떤 task가 &lt;code&gt;pending&lt;/code&gt;으로 되돌아가고(rework) &lt;b&gt;다른 task 중 &lt;code&gt;in-progress&lt;/code&gt;가 하나라도 있으면&lt;/b&gt; &amp;rarr; spec &lt;code&gt;status: in-progress&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;어떤 task가 &lt;code&gt;pending&lt;/code&gt;으로 되돌아갔는데 &lt;code&gt;in-progress&lt;/code&gt;인 task가 &lt;b&gt;하나도 없으면&lt;/b&gt; &amp;rarr; spec &lt;code&gt;status: planned&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;변경이 &lt;b&gt;원래 &lt;code&gt;pending&lt;/code&gt;이던 task만&lt;/b&gt; 무효화한다면 &amp;rarr; spec status는 움직이지 않는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진행도가 높은 쪽(in-progress 존재)부터 평가해 내려가면 모호함 없이 단일 결과가 나온다. 역전의 바닥은 &lt;code&gt;planned&lt;/code&gt;다 &amp;mdash; &lt;code&gt;specification&lt;/code&gt;까지는 내려가지 않는다(그림 1에서 reverse 화살표가 &lt;code&gt;planned&lt;/code&gt;에서 멈추는 이유). 이때 무효화된 task는 &lt;code&gt;status: pending&lt;/code&gt;과 &lt;code&gt;rework_required: true&lt;/code&gt;를 &lt;b&gt;항상 함께&lt;/b&gt; 가지며, &lt;code&gt;depends_on&lt;/code&gt;으로 묶인 하위 작업까지 영향이 추적된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3-tier 캐스케이드와 un-archive&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 흥미로운 결과는 한 계층 위에서 나타난다. 여러 spec을 묶는 대형 작업 단위를 &lt;b&gt;initiative&lt;/b&gt;라 하자(예: 플랫폼 재작성). 모든 슬라이스가 &lt;code&gt;done&lt;/code&gt;이면 initiative는 &lt;code&gt;done&lt;/code&gt;이 되고 파일이 아카이브 폴더로 이동한다. 그런데 그중 한 spec이 reverse 되어 all-done 임계선이 깨지면?&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/XHsoY/dJMb991d8aV/RZl6wXA1AEeII58OD1SyRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XHsoY/dJMb991d8aV/RZl6wXA1AEeII58OD1SyRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XHsoY/dJMb991d8aV/RZl6wXA1AEeII58OD1SyRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXHsoY%2FdJMb991d8aV%2FRZl6wXA1AEeII58OD1SyRK%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 한 번이 3-tier를 거슬러 올라간다. task가 &lt;code&gt;done &amp;rarr; pending&lt;/code&gt;, spec이 &lt;code&gt;done &amp;rarr; in-progress&lt;/code&gt;, initiative가 &lt;code&gt;done &amp;rarr; active&lt;/code&gt;로 &lt;b&gt;un-archive 된다.&lt;/b&gt; 파일은 &lt;code&gt;archive/{YYYY}/{MM}/&lt;/code&gt; 에서 active 루트로 &lt;code&gt;git mv&lt;/code&gt; 되고, 내부 마크다운 링크의 상대 경로(&lt;code&gt;..&lt;/code&gt; 세그먼트 수)까지 재계산된다. 상태 머신이 한 계층에서만 양방향인 게 아니라 &lt;b&gt;역전이 위로 캐스케이드 된다는&lt;/b&gt; 점이 이 설계의 핵심이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;놓치기 쉬운 에지 케이스&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Drift Gate 타이밍&lt;/b&gt;: spec과 실제 diff의 정합성 검증은 반드시 &lt;code&gt;done&lt;/code&gt;/아카이브 &lt;b&gt;이전&lt;/b&gt;에 돌려야 한다. 아카이브 된 spec에서는 검증이 스스로 건너뛰기 때문에, 사후에 돌리면 아무것도 못 잡는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;완료 정리는 자동이 아니다&lt;/b&gt;: 모든 task가 &lt;code&gt;done&lt;/code&gt;이어도 시스템은 정리를 &lt;b&gt;제안&lt;/b&gt;할 뿐 자동 실행하지 않는다. 배포&amp;middot;후속 PR이 남았으면 사용자가 거절할 수 있어야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;reverse는 별도 게이트가 없다&lt;/b&gt;: 역전 전파와 un-archive는 rework 결정의 &lt;b&gt;부수 효과&lt;/b&gt;다. 사용자 확인은 이미 rework 시점에 받았다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 동작 원리 ② &amp;mdash; &lt;code&gt;done&lt;/code&gt;으로 들어가기: 측정으로 보정한 리뷰 게이트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;writer와 reviewer를 분리한다&lt;/h3&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/bwW6Cv/dJMcageWCvV/HSK8dvWtt7cHg3TCUgItV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwW6Cv/dJMcageWCvV/HSK8dvWtt7cHg3TCUgItV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwW6Cv/dJMcageWCvV/HSK8dvWtt7cHg3TCUgItV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbwW6Cv%2FdJMcageWCvV%2FHSK8dvWtt7cHg3TCUgItV0%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;LLM-as-judge의 첫 함정은 &lt;b&gt;self-preference bias&lt;/b&gt;다. 자기가 쓴 코드를 자기가 채점하면 점수를 후하게 준다. 그래서 코드를 짠 writer와 리뷰하는 reviewer를 &lt;b&gt;다른 호출로 분리&lt;/b&gt;한다. 초기 분리 실험에선 self-scoring 인플레가 눈에 띄게 줄고 결함을 잡아내는 recall이 약 2배가 됐다(초기 측정치이니 방향성으로 읽되, 분리 자체는 구조적으로 옳다). 핵심은 점수가 아니라 &lt;b&gt;누가 평가하는가&lt;/b&gt;를 바꾸는 구조다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;관점(lens) 개수는 직관이 아니라 측정으로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리뷰를 단일 패스로 한 번 보는 대신, 서로 다른 관점(lens)으로 병렬 평가한 뒤 merge 한다. 그러면 &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/bImxFO/dJMcahLGpIx/fsyU7eFlgmF3hlKuBqgyT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bImxFO/dJMcahLGpIx/fsyU7eFlgmF3hlKuBqgyT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bImxFO/dJMcahLGpIx/fsyU7eFlgmF3hlKuBqgyT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbImxFO%2FdJMcahLGpIx%2FfsyU7eFlgmF3hlKuBqgyT0%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이 크게 오르고, &lt;b&gt;2개에서 knee가 꺾여 3~6개는 평탄&lt;/b&gt;했다(수확 체감). 측정 정직성을 한 겹 더 얹자면, &lt;b&gt;초기 directional 스윕은 더 큰 폭을 시사했지만 표본이 얇았다(thin-n).&lt;/b&gt; planted ground-truth로 통제 재측정하니 &lt;b&gt;recall 81% &amp;rarr; 88% (+7pp, pooled)&lt;/b&gt;, 변별이 필요한 fixture에선 &lt;b&gt;+10pp였다.&lt;/b&gt; 직관만 틀리는 게 아니라 &lt;b&gt;통제 없는 측정도 과장한다&lt;/b&gt; &amp;mdash; 그래서 헤드라인은 통제 수치로 잡는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;비용 동률 반전 &amp;mdash; 호출 수가 안 변한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 자연스러운 우려가 나온다. &quot;관점 2개 &amp;times; 단가 2 배면 게이트 비용이 4배 아닌가?&quot; 실측은 &lt;b&gt;동률&lt;/b&gt;이었다. 2-lens 패널이 &lt;b&gt;단일 invocation 안에서&lt;/b&gt; 돌기 때문에 LLM 호출 수 자체가 변하지 않는다. 비용 모델을 &quot;관점 수&quot;가 아니라 &quot;호출 수&quot;로 잡으면 사라지는 우려였다 &amp;mdash; 이것도 측정 전엔 보이지 않았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;verdict의 의미를 고정한다 &amp;mdash; precision 가드와 N/A 회귀&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정밀도(precision)는 cap-sweep 전 구간에서 유지됐다. 즉 게이트를 조이는 가드는 발견 개수의 &lt;b&gt;상한(cap)이 아니라 confidence-floor&lt;/b&gt;(확신 임계)여야 한다는 뜻이다. 그리고 디버깅 루프 하나. lens-panel로 프레이밍을 바꾸자, 사소한 diff를 &lt;code&gt;PASS&lt;/code&gt;가 아니라 &lt;code&gt;N/A&lt;/code&gt;(평가 불능)로 밀어내는 회귀가 생겼다. golden-set이 이걸 잡아냈고, &lt;b&gt;&quot;finding 없음 = PASS, N/A는 평가 불능 전용&quot;으로&lt;/b&gt; verdict 의미를 고정해 고쳤다. 게이트의 출력 신호가 흔들리면 그 신호를 읽는 다음 단계(=&lt;code&gt;done&lt;/code&gt; 전환)가 통째로 오염된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 실무 적용 (Practical Examples)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ 권장 패턴 (Good Practice)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나오기.&lt;/b&gt; 무효화된 task는 상태를 정직하게 역전시키고 플래그로 표식 한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# task-2-token-refresh.md (frontmatter)
---
task: 2
title: 토큰 갱신 처리
status: pending          # done에서 역전
rework_required: true    # pending과 항상 페어
depends_on: [1]          # 하위 영향 추적 경로
---&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# spec.md (frontmatter)
---
feature: auth-refresh
status: in-progress      # 다른 task가 in-progress라 rule 1 적용 (done&amp;rarr;in-progress)
---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;들어가기.&lt;/b&gt; 게이트는 네 가지를 지킨다. writer &amp;ne; reviewer로 self-bias를 막고 &amp;middot; knob(관점 수 등)은 golden-set 측정으로 정하고(knee에서 멈춘다) &amp;middot; 비용은 단일 invocation으로 호출 수를 고정하고 &amp;middot; verdict 의미를 고정한다(&lt;code&gt;finding 없음 = PASS&lt;/code&gt;, &lt;code&gt;N/A&lt;/code&gt;는 평가 불능 전용, 가드는 confidence-floor).&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ 안티패턴 (Anti-Pattern)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 안티패턴 &amp;mdash; done을 유지한 채 메모만 남김
---
task: 2
status: done             # 실제로는 재작업 필요한데 done
# note: &quot;나중에 토큰 로직 고쳐야 함&quot;   &amp;larr; 대시보드는 이걸 모른다
---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나오는 쪽에서 &lt;code&gt;done&lt;/code&gt;을 방치하면 대시보드가 거짓말을 하고, 새 task로 갈아 끼우면 의존성&amp;middot;히스토리가 끊긴다. 무엇보다 &lt;b&gt;에이전트가 다음 실행에서 stale 한 &lt;code&gt;done&lt;/code&gt;을 신뢰&lt;/b&gt;해 잘못된 전제로 코드를 짠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;들어오는 쪽의 안티패턴도 대칭이다. &quot;더 많은 lens = 더 안전&quot;이라 믿고 관점을 무한정 늘리면 &lt;b&gt;knee 너머에선 효과는 평탄한데 복잡도만 오른다.&lt;/b&gt; writer가 자기 코드를 채점하게 두면 self-bias로 게이트가 물러지고, 사소한 diff를 &lt;code&gt;N/A&lt;/code&gt;로 처리하면 게이트가 사실상 무력화된다. 공통점은 하나 &amp;mdash; &lt;b&gt;측정 없이 knob을 만진다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실행 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나오는 쪽에선 토큰 로직 한 줄 수정이라는 작은 rework가 task &lt;code&gt;pending&lt;/code&gt;(+&lt;code&gt;rework_required&lt;/code&gt;) &amp;rarr; spec &lt;code&gt;in-progress&lt;/code&gt; &amp;rarr; 아카이브 됐던 initiative &lt;code&gt;active&lt;/code&gt; 복귀(파일이 active 루트로)로 캐스케이드 되고, 대시보드에서 &lt;code&gt;rework_required&lt;/code&gt; task가 &lt;b&gt;최상단 정렬&lt;/b&gt;로 다시 보인다. 들어오는 쪽에선 관점을 측정에 따라 &lt;b&gt;2개로 고정&lt;/b&gt;해 비용을 호출 수 기준 동률로 유지하고, golden-set이 verdict 회귀(PASS&amp;rarr;N/A)를 &lt;b&gt;사전에&lt;/b&gt; 포착한다. 양쪽 모두 상태가 현실과 어긋나지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 장단점 및 고려사항&lt;/h2&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✓ &lt;code&gt;done&lt;/code&gt; 양쪽 경계가 현실과 일치 &amp;mdash; 대시보드&amp;middot;게이트가 거짓말하지 않음&lt;/td&gt;
&lt;td&gt;✗ 상태 머신 복잡도 상승(역방향 규칙&amp;middot;전파)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ 에이전트가 stale한 &lt;code&gt;done&lt;/code&gt;을 신뢰하지 않음&lt;/td&gt;
&lt;td&gt;✗ un-archive 같은 드문 경로까지 구현&amp;middot;테스트 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ task&amp;middot;spec&amp;middot;initiative 3-tier 정합성 보장&lt;/td&gt;
&lt;td&gt;✗ 게이트 보정에 golden-set 구축&amp;middot;유지 비용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✓ self-bias 차단 + knob을 측정으로 정해 비용 동률&lt;/td&gt;
&lt;td&gt;✗ &quot;측정 먼저&quot; 규율이 팀에 자리잡는 학습 곡선&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무 도입 체크리스트:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;reverse 규칙은 &lt;b&gt;highest-applicable 단일 진입점&lt;/b&gt;으로 구현한다(여러 if를 흩뿌리지 말 것).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rework_required: true&lt;/code&gt;는 &lt;b&gt;항상 &lt;code&gt;status: pending&lt;/code&gt;과 페어&lt;/b&gt;로만 존재하게 강제한다.&lt;/li&gt;
&lt;li&gt;정합성 검증(drift)&amp;middot;완료 정리는 &lt;b&gt;&lt;code&gt;done&lt;/code&gt; 전환 이전 / 사용자 확인 게이트&lt;/b&gt;를 유지한다.&lt;/li&gt;
&lt;li&gt;리뷰 게이트는 &lt;b&gt;writer &amp;ne; reviewer&lt;/b&gt;, 관점 수는 &lt;b&gt;knee까지만&lt;/b&gt;, 비용은 &lt;b&gt;호출 수&lt;/b&gt;로 센다.&lt;/li&gt;
&lt;li&gt;verdict 의미를 고정하고(&lt;code&gt;finding 없음 = PASS&lt;/code&gt;, &lt;code&gt;N/A&lt;/code&gt; 전용), 회귀는 &lt;b&gt;golden-set&lt;/b&gt;으로 잡는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 핵심 3줄 요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI 보조 개발에서 &lt;code&gt;done&lt;/code&gt;은 들어갈 때와 나온 뒤 두 번 위험하고, 두 함정 모두 순진한 직관에서 온다.&lt;/li&gt;
&lt;li&gt;나오는 쪽은 reverse transition을 1급 시민으로 두어 task&amp;rarr;spec&amp;rarr;initiative로 역전을 캐스케이드(un-archive)하고, 들어오는 쪽은 writer/evaluator 분리&amp;middot;관점 수&amp;middot;비용&amp;middot;verdict를 &lt;b&gt;측정으로&lt;/b&gt; 보정한다.&lt;/li&gt;
&lt;li&gt;한 문장으로: &lt;b&gt;&lt;code&gt;done&lt;/code&gt; 경계는 직관이 아니라 측정으로 설계한다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 단계는 가볍다. spec/task 템플릿에 &lt;code&gt;rework_required&lt;/code&gt;와 reverse 규칙 세 줄을 넣고 대시보드 정렬에 rework 우선순위를 더하라(나오기). 리뷰 게이트가 있다면 작은 golden-set부터 만들어 &quot;관점 하나 더&quot;가 정말 recall을 올리는지 한 번 측정해 보라(들어가기). 직관이 틀리는 지점을 한 번 보면 규율이 생긴다.&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-2%ED%8E%B8-%E2%80%94-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8%EB%A5%BC-%EC%9E%90%EC%9C%A8-%EC%8B%A4%ED%96%89%EC%97%90%EC%84%9C-%EB%AA%85%EC%8B%9C%EC%A0%81-%EB%8F%99%EC%9D%98%EB%A1%9C&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;2026.06.19 - [AI 엔지니어링] - 프런트엔드 AX 설계기 2편 &amp;mdash; 에이전트를 '자율 실행'에서 '명시적 동의'로&lt;/a&gt;&lt;/p&gt;</description>
      <category>AI 엔지니어링</category>
      <category>AI보조개발</category>
      <category>ax</category>
      <category>golden-set</category>
      <category>LLM-as-Judge</category>
      <category>spec-driven</category>
      <category>라이프사이클설계</category>
      <category>상태머신</category>
      <category>에이전트워크플로</category>
      <category>측정기반설계</category>
      <category>코드리뷰게이트</category>
      <author>Kir93</author>
      <guid isPermaLink="true">https://kir93.tistory.com/195</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-4%ED%8E%B8-%E2%80%94-done-%EA%B2%BD%EA%B3%84%EB%A5%BC-%EC%B8%A1%EC%A0%95%EC%9C%BC%EB%A1%9C-%EC%84%A4%EA%B3%84%ED%95%9C-%EC%9D%B4%EC%95%BC%EA%B8%B0#entry195comment</comments>
      <pubDate>Wed, 1 Jul 2026 19:51:44 +0900</pubDate>
    </item>
  </channel>
</rss>