<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>hooninedev.com</title>
        <link>https://hooninedev.com/ja</link>
        <description>프론트엔드 개발자 이지훈(후니)의 기술 블로그. React, TypeScript, Next.js 로 만들면서 겪은 설계 판단과 문제 해결 과정, 성능과 관측 실측, 학습 노트를 남깁니다.</description>
        <lastBuildDate>Thu, 15 Oct 2026 20:46:10 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ja</language>
        <copyright>All rights reserved 2026, 이지훈</copyright>
        <item>
            <title><![CDATA[React アプリの layer]]></title>
            <link>https://hooninedev.com/ja/261001</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/261001</guid>
            <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、React アプリのコードを画面、業務ルール、データアクセスに分ける方法について話してみたい。component 一つに fetch と計算と render が一緒に入っていて、直すたびに全部を読まなければならない開発者のための記事だ。最後まで読めば、hook、pure component、domain object、Strategy、network client をどんなシグナル...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、React アプリのコードを画面、業務ルール、データアクセスに分ける方法について話してみたい。component 一つに fetch と計算と render が一緒に入っていて、直すたびに全部を読まなければならない開発者のための記事だ。最後まで読めば、hook、pure component、domain object、Strategy、network client をどんなシグナルを見てどの順番で取り出すのか、そしてその構造をそのまま持ち込むとどこで壊れるのかがわかる。</p>
<p>骨組みは、Thoughtworks の Juntao Qiu が 2023 年 2 月に martinfowler.com で連載した <a href="https://martinfowler.com/articles/modularizing-react-apps.html" target="_blank" rel="noopener noreferrer">Modularizing React Applications with Established UI Patterns</a> だ。原文をそのまま訳さず筆者の言葉で解き直しており、本文の図はすべてこの原文から借りている。その上に、著者の<a href="https://github.com/abruzzi/payment-round-up-refactoring" target="_blank" rel="noopener noreferrer">サンプルリポジトリ</a>を取得して型チェックとテストを回した結果と、同じ問題を扱った別の資料を付け加えた。</p>
<h2 id="react-は-view-library"><a class="anchor" href="#react-は-view-library">React は view library</a></h2>
<p>React を初めて学ぶとき最も魅力的な考え方は、UI はデータを DOM に変える純粋関数だというものだ。ある程度は正しい。ところがサーバーにリクエストを送ったりページを遷移したりした瞬間、component はもう純粋ではない。そこにグローバルな state とローカルな state が絡むと、コードはすぐに複雑になる。</p>
<p>著者の答えは視点を変えることだ。「React アプリケーション」という特別な種類のソフトウェアは存在しない。React は UI を描く library であり、計算や業務ルールをどこに置くかは最初から関心の外にある。その上に載っているのはごく普通の JavaScript アプリだ。それなら、デスクトップ GUI の時代から使われてきた設計、特にコードを presentation、domain、data の三つの layer に分ける方法をそのまま使える。</p>
<p>実際のフロントエンドアプリには view 以外にも、router、local storage、いくつかのレベルのキャッシュ、ネットワークリクエスト、外部サービス連携とログイン、セキュリティ、ロギング、パフォーマンス調整がある。これを全部 component と hook に押し込むと、一つのファイルの中で注文状態をリクエストする行の次に文字列の先頭の空白を削る行が来て、その次に別の画面へ遷移する行が来る。読む人は抽象度を何度も切り替えながら前後を行き来しなければならない。</p>
<p>分ける理由は二つにまとめられる。画面は業務ルールより頻繁に変わる。そして二つを切り離しておけば、一度に一つのことだけ考えればよい。二つ目の理由は、Martin Fowler が <a href="https://martinfowler.com/bliki/PresentationDomainDataLayering.html" target="_blank" rel="noopener noreferrer">PresentationDomainDataLayering</a> でこの区分の最大の利点として挙げたものだ。三つのテーマを比較的別々に考えられるので、注意を向ける範囲が狭まる。</p>
<h2 id="アプリが育つ五つの段階"><a class="anchor" href="#アプリが育つ五つの段階">アプリが育つ五つの段階</a></h2>
<p>著者はリファクタリングに入る前に、アプリが育つにつれて構造がどう変わるかを五枚の図で見せる。重要なのは次の段階へ進むきっかけだ。決まったタイミングではなく、その段階で生じる不便が次の段階を呼び込む。図の色は五枚とも同じ意味だ。黄緑は state を持つ container component、濃い緑は描画だけを担う presentational component、紫は hook、青は domain object、オレンジはネットワークのような infrastructure だ。</p>
<p>最初は component 一つがネットワークリクエスト、state、計算、render をすべて持つ。小さなアプリや使い捨てのプロジェクトならこれで十分で、コードが HTML に近いのでむしろ読みやすい。リストを回して項目を作るコードと外部 component を設定するコードが混ざり、何が起きているのかを読むのに時間がかかり始めたら次の段階へ進む。</p>
<p><picture><source type="image/webp" srcset="/content/261001/1.720w.webp 720w, /content/261001/1.1440w.webp 1440w, /content/261001/1.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/1.png" alt="一つの component がネットワークリクエスト、state 管理、domain logic、render をすべて持ち、API を直接呼び出す構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="661" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>画面が大きくなると、出力される HTML の形に沿って component を分ける。描画だけの component は切り出されるが、一番上の component にはネットワークリクエスト、レスポンスを画面用の形に変えるコード、サーバーに送るデータを集めるコードがそのまま残る。三つとも UI ではないのに component の中にある。</p>
<p><picture><source type="image/webp" srcset="/content/261001/2.720w.webp 720w, /content/261001/2.1440w.webp 1440w, /content/261001/2.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/2.png" alt="描画だけの component は分離されたが、上位の component にネットワークリクエストと domain logic が残っている構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="984" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>次に state とその変化を custom hook に切り出す。すると hook の中に、side effect でも state でもない純粋な計算が残る。</p>
<p><picture><source type="image/webp" srcset="/content/261001/3.720w.webp 720w, /content/261001/3.1440w.webp 1440w, /content/261001/3.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/3.png" alt="ネットワークリクエストと domain logic は hook に移ったが、hook の中で両者が混ざっている構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="872" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>その計算を React と無関係なオブジェクトに切り出すと domain object が生まれる。データ形式の変換、null チェック、代替値がここに来る。オブジェクトが増えると、継承やポリモーフィズムが必要になり始める。</p>
<p><picture><source type="image/webp" srcset="/content/261001/4.720w.webp 720w, /content/261001/4.1440w.webp 1440w, /content/261001/4.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/4.png" alt="hook には state だけが残り、domain logic は Domain オブジェクトへ、ネットワークリクエストは Fetcher へ分かれた構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="790" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>最後に、UI にも属さず、データがサーバーから来るのか local storage やキャッシュから来るのかも気にしないオブジェクトが見えてきたら、それを別の model layer としてまとめる。</p>
<p><picture><source type="image/webp" srcset="/content/261001/5.720w.webp 720w, /content/261001/5.1440w.webp 1440w, /content/261001/5.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/5.png" alt="中央の帯が model layer で、component と hook はその上に、Fetcher や Adaptor のような infrastructure はその下に置かれた構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="917" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>図だけでは抽象的だ。この五つの段階を実際のコードで一度たどってみよう。著者が選んだ例は、オンライン注文の決済画面だ。</p>
<h2 id="一つの決済画面から出発する"><a class="anchor" href="#一つの決済画面から出発する">一つの決済画面から出発する</a></h2>
<p>決済手段はサーバーの設定から来て、国ごとに異なる。サーバーが一つでも返せばその末尾に現金払いを付けてデフォルトで選択しておき、一つもなければ何も表示しない。</p>
<p><picture><source type="image/webp" srcset="/content/261001/6.720w.webp 720w, /content/261001/6.1440w.webp 1440w, /content/261001/6.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/6.png" alt="注文詳細の下の決済エリアに apple、google、現金払いのラジオボタンがあり、現金払いが選択されている画面" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>最初のコードはチュートリアルでよく見る形だ。<code>useEffect</code> の中で fetch し、レスポンスを画面用の形に変え、現金払いを付け、ラジオボタンのリストを描く。以下は筆者が短くして書き写したものだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(url)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> extended</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ provider: m.name, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`Pay with ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">name</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        extended.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ provider: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(extended);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {paymentMethods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">method</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"radio"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultChecked</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          {method.label}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      ))}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>このサイズなら問題ではない。しかしこの component を直すには、四つのことを一緒に理解する必要がある。ネットワークリクエストをどう始めるか、サーバーのデータを画面が知っている形にどう変えるか、決済手段一つをどう描くか、決済エリア全体をどう描くかだ。コードが大きくなると、読む人はこの四つの間を何度も行き来しなければならない。</p>
<h2 id="hook-と-pure-component"><a class="anchor" href="#hook-と-pure-component">hook と pure component</a></h2>
<p>最初に取り出すのは state と fetch だ。判断のシグナルは単純だ。<strong>render と関係ないコードが component 本体の半分を超えたら</strong> hook に切り出す。<code>usePaymentMethods</code> が fetch と変換を受け持ち、component は hook から受け取った配列を描くだけになる。</p>
<p>二つ目はリストを描く JSX だ。<strong>props だけ受け取れば描けるブロックが見えたら</strong> component として切り離す。<code>PaymentMethods</code> は決済手段の配列一つだけを受け取る pure component なので、テストも再利用もしやすい。後で選択を知らせる <code>onSelect</code> callback が付いても外側の state には触れないので、依然として pure だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위의 fetch와 변환 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">PaymentMethods</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> paymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{paymentMethods} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>著者はどちらの作業もリファクタリングカタログの <a href="https://refactoring.com/catalog/extractFunction.html" target="_blank" rel="noopener noreferrer">Extract Function</a> だと呼ぶ。React では component も関数だからだ。新しい概念を持ち込んだのではなく、関数一つを二つに分けただけだという点が重要だ。</p>
<p>これで終わったように見えるが、まだ二か所残っている。view の中には「現金ならデフォルトで選択」という判定があり、hook の中にはレスポンスを画面用の形に変える無名関数がある。著者は、このように業務ルールが view や hook に漏れ出たものを logic leak と呼ぶ。</p>
<h2 id="domain-object-に集めたルール"><a class="anchor" href="#domain-object-に集めたルール">domain object に集めたルール</a></h2>
<p>三つ目のシグナルは、<strong>同じデータに対する判定が view と hook に散らばっているとき</strong>だ。漏れ出た判定と変換を <code>PaymentMethod</code> class 一つに集める。このブログの<a href="/260418" target="_blank" rel="noopener noreferrer">ドメインモデル</a>で扱ったように、ルールをデータのそばに置くということだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> remote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.remote.name; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "Pay in cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `Pay with ${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">provider</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDefaultMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m)), </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })];</span></span></code></pre></figure>
<p>view のラジオボタンは、今度は <code>defaultChecked={method.isDefaultMethod}</code> でオブジェクトに問い合わせる。現金払いのデフォルト値も同じ class のインスタンス一つで表現される。サーバーがくれたものとアプリが付け足したものが同じ形になるので、view は両者を区別する必要がない。</p>
<p><picture><source type="image/webp" srcset="/content/261001/7.720w.webp 720w, /content/261001/7.1440w.webp 1440w, /content/261001/7.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/7.png" alt="Payment が usePaymentMethods を呼び、hook が PaymentMethod を作り、PaymentMethods は描画だけを担う構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="662" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>著者はこの構造の利点として、テストが簡単になることを挙げる。原文はそこで止まっているが、どれだけ簡単になるかはコードで見たほうが早い。以下は筆者が書いたテストだ。React も、レンダラーも、fetch の mock もない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 준 결제 수단 끝에 현금 결제를 기본값으로 붙인다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([{ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.label)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.isDefaultMethod).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.provider)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 아무것도 주지 않으면 현금 결제도 보여 주지 않는다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([])).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>同じルールを最初のコードで確かめるには、component を render し、fetch を横取りし、ラジオボタンが描かれるまで待たなければならなかった。ルールがオブジェクトとして外に出た瞬間、テストは関数呼び出し一行になる。</p>
<p>もう一つの利点は、新しい要件が来たときに行き先が決まっていることだ。この時点のファイル構成はこうなる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>src</span></span>
<span data-line=""><span>├── components</span></span>
<span data-line=""><span>│   ├── Payment.tsx</span></span>
<span data-line=""><span>│   └── PaymentMethods.tsx</span></span>
<span data-line=""><span>├── hooks</span></span>
<span data-line=""><span>│   └── usePaymentMethods.ts</span></span>
<span data-line=""><span>└── models</span></span>
<span data-line=""><span>    └── PaymentMethod.ts</span></span></code></pre></figure>
<p>構造が本当に持ちこたえるかは、新しい要件が来てみないとわからない。著者はここで機能をもう一つ加える。</p>
<h2 id="寄付機能と-state-machine-としての-hook"><a class="anchor" href="#寄付機能と-state-machine-としての-hook">寄付機能と state machine としての hook</a></h2>
<p>新しい要件は、注文金額を切り上げてその差額を慈善団体に寄付できるようにすることだ。19.80 ドルの注文なら 0.20 ドルの寄付を尋ね、同意すればボタンに 20 ドルと表示する。</p>
<p><picture><source type="image/webp" srcset="/content/261001/8.720w.webp 720w, /content/261001/8.1440w.webp 1440w, /content/261001/8.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/8.png" alt="決済手段の下に 0.2 ドル寄付のチェックボックスが追加された決済画面" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>著者はわざと、最初は同意の有無の state と計算を決済 component の中に入れる。チェックボックスのマークアップ、文言の分岐、合計の計算が一度に入ってきて、component は再び重くなる。整理する順番は前に見たとおりだ。state と計算は <code>useRoundUp</code> hook へ、文言の組み立ては helper 関数へ、チェックボックスのマークアップは <code>DonationCheckbox</code> component へ移る。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">agreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total: agreeToDonate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    tip: </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [amount, agreeToDonate]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateAgreeToDonate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">v);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { total, tip, agreeToDonate, updateAgreeToDonate };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p><code>toPrecision(10)</code> は、原文が理由を説明していない部分だ。筆者が Node 24 で動かしてみると、<code>20 - 19.8</code> は <code>0.1999999999999993</code> になった。浮動小数点の誤差を取り除くための仕掛けだ。</p>
<p>著者はこうした hook を「view の背後にある state machine」と捉える。UI からイベントが来ると新しい state を作り、新しい state がまた render を引き起こす。hook は本来、複数の component が logic を共有するために作られたものだが、使う場所が一つでも component を render に集中させてくれるので切り出す価値がある、というのが著者の立場だ。ここに一つ付け加えると、React 公式ドキュメントの <a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">Reusing Logic with Custom Hooks</a> が指摘するように、custom hook が共有するのは state を扱う logic であって state そのものではない。この state machine は、hook を呼ぶ component ごとに一つずつ別々に生まれる。</p>
<p>整理が終わると、決済 component は hook を二つ呼び、子 component 二つとボタン一つを並べる形になる。機能が一つ増えたのに、決済 component の役割は変わらない。</p>
<p><picture><source type="image/webp" srcset="/content/261001/9.720w.webp 720w, /content/261001/9.1440w.webp 1440w, /content/261001/9.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/9.png" alt="Payment の下に useRoundUp と usePaymentMethods の二つの hook、PaymentMethods と DonationCheckbox の二つの子 component が置かれた構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="775" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h2 id="国別ルールと-shotgun-surgery"><a class="anchor" href="#国別ルールと-shotgun-surgery">国別ルールと shotgun surgery</a></h2>
<p>次の要件は、国ごとに切り上げ単位を変えることだ。日本は 100 円単位、デンマークは 10 クローネ単位だ。著者は「簡単な修正に見える」と言いながら、国コードを prop で受け取って分岐を入れるやり方をわざと先に見せる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useRoundUp 안</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: agreeToDonate</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 체크박스 문구 helper 안</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 버튼 JSX 안</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}{total}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>同じ <code>countryCode === "JP"</code> の判定が、hook、helper、ボタンの三か所に一つずつ入った。デンマークを追加すれば三か所すべてを直し直す必要があり、三項演算子で手に負えなくなると国コードから通貨記号を引く表が生まれる。その表も結局あちこちで別々に参照される。一つの変更のために複数のモジュールを同時に直さなければならないこの臭いを、shotgun surgery と呼ぶ。</p>
<p><picture><source type="image/webp" srcset="/content/261001/10.720w.webp 720w, /content/261001/10.1440w.webp 1440w, /content/261001/10.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/10.png" alt="国別ルールを表す色の帯が、複数の component や hook、domain object に散らばっている図" style="max-width:min(720px, 100%);height:auto;" width="1600" height="902" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>この段階が前の三つの段階と違うのは、判断のシグナルがコードではなく<strong>変更要求</strong>から来ることだ。今のコードだけを見れば、三つの分岐は読みにくくない。国を一つ増やすときに何か所直す必要があるかを数えてみて、初めて問題が見える。では、散らばった分岐をどこに集めればよいのか。</p>
<h2 id="strategy-に集めた国ごとの違い"><a class="anchor" href="#strategy-に集めた国ごとの違い">Strategy に集めた国ごとの違い</a></h2>
<p>著者の答えは、国ごとの違いそのものをオブジェクトにすることだ。まず <a href="https://refactoring.com/catalog/extractClass.html" target="_blank" rel="noopener noreferrer">Extract Class</a> と <a href="https://refactoring.com/catalog/replaceConditionalWithPolymorphism.html" target="_blank" rel="noopener noreferrer">Replace Conditional with Polymorphism</a> で、interface 一つと国ごとの実装を作る。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyAU</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 1달러 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyJP</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 100엔 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>作ってみると、実装ごとに異なるのは通貨記号と切り上げ関数の二つだけで、<code>getTip</code> のような残りは同じだ。そこで著者は <a href="https://refactoring.com/catalog/inlineClass.html" target="_blank" rel="noopener noreferrer">Inline Class</a> で実装を一つの class にまとめ、異なる部分だけをコンストラクタで受け取る。国ごとに subclass を作らずに切り上げアルゴリズムを関数として受け取れるのは、JavaScript では関数が値だからだ。以下はその結果を筆者が短くして書いたものだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> roundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">roundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n); }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> n).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)); }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> japan</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3400</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 88</span></span></code></pre></figure>
<p>hook と component はもう国を知らない。このオブジェクト一つを受け取って <code>strategy.getRoundUpAmount(amount)</code> と <code>strategy.currencySign</code> を使うだけだ。国を一つ増やすならオブジェクトを一つ作れば終わりで、直すファイルはない。</p>
<p><picture><source type="image/webp" srcset="/content/261001/11.720w.webp 720w, /content/261001/11.1440w.webp 1440w, /content/261001/11.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/11.png" alt="component は PaymentStrategy 一つだけを見ており、国ごとの三つの実装がその背後に集まった構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="847" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>interface を経由してからまたまとめ直した過程は、無駄に見えるかもしれない。筆者はこの部分が原文で最も学ぶ価値のあるくだりだと考える。ポリモーフィズムは目的ではなく共通点と相違点を浮かび上がらせる道具であり、違いが関数一つにまで縮んだのが見えたところで class 階層を取り払った。パターンを持ち込むことより、そのパターンをいつ取り払うかのほうが難しい判断だ。</p>
<h2 id="fetch-を-hook-の外へ"><a class="anchor" href="#fetch-を-hook-の外へ">fetch を hook の外へ</a></h2>
<p>最後に取り出すのは、<code>usePaymentMethods</code> の中の fetch と変換だ。hook は React の概念なので view 側に残る。error handling や retry を入れると hook はすぐに膨らみ、別の view library に移れば hook は使えない。一方、普通の関数はどこでも使える。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">API</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/payment-methods?countryCode=AU`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setPaymentMethods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>著者は、この関数が Anti-Corruption Layer あるいは Gateway のように振る舞うと書く。サーバーのレスポンス構造が変わっても、直す場所がこの関数一つで済むという意味だ。ただし二つの名前は同じものではない。Fowler の <a href="https://martinfowler.com/articles/gateway-pattern.html" target="_blank" rel="noopener noreferrer">Gateway</a> は外部システムへのアクセスを一か所に包むオブジェクトであり、Anti-Corruption Layer は Eric Evans の DDD から来た言葉で、意味体系の異なる外部モデルが自分のモデルを汚染しないよう翻訳する境界だ。この例のサーバーレスポンスは <code>{ name }</code> 一つだけで、翻訳すべき意味の違いがほとんどないので、筆者は Gateway のほうが正確な名前だと考える。サーバーが別チーム所有のレガシーで、決済手段の意味そのものが異なる場合に、同じ場所が Anti-Corruption Layer になる。</p>
<p>最終的な構造はこうだ。render は component に、state は hook に、ルールは domain object に、ネットワークリクエストは関数にある。</p>
<p><picture><source type="image/webp" srcset="/content/261001/12.720w.webp 720w, /content/261001/12.1440w.webp 1440w, /content/261001/12.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/12.png" alt="Payment と子 component、useRoundUp と usePaymentMethods の hook、PaymentStrategy と PaymentMethod の domain object、Fetcher がそれぞれの枠に置かれた最終構造" style="max-width:min(720px, 100%);height:auto;" width="1600" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>著者は、このように分けた結果として五つの利点を挙げる。欠陥のある場所を見つけやすくなる。コードを再利用し組み合わせやすくなる。読みやすくなる。機能を加えても全体が揺らがない。そして domain logic が view を知らないので、view layer だけを差し替えられる。最後の一つについては、著者自身が大半のプロジェクトでは非常にまれなことだと断りを入れている。五つとも測定値はないという点も、あわせて覚えておく価値がある。</p>
<p>ここまでが原文のあらすじだ。ところが、このコードを実際に動かしてみると、記事からは見えなかったものが出てくる。</p>
<h2 id="サンプルを動かすと見えるもの"><a class="anchor" href="#サンプルを動かすと見えるもの">サンプルを動かすと見えるもの</a></h2>
<p>筆者は著者のサンプルリポジトリ(コミット 16 件、最終コミット <code>de186c5</code>)を取得して、<code>tsc --noEmit</code> とテスト 7 件を回した。どちらも通り、原文で定義が抜けていた helper もリポジトリの <code>src/utils.ts</code> にあった。ところが構造をのぞいてみると、持ち込む前に知っておくべきことが三つあった。</p>
<h3 id="render-のたびに新しく作られる-strategy"><a class="anchor" href="#render-のたびに新しく作られる-strategy">render のたびに新しく作られる Strategy</a></h3>
<p>決済 component は Strategy を prop で受け取り、渡されなければデフォルト値を <code>new</code> で作る。そして <code>useRoundUp</code> はその Strategy を <code>useMemo</code> の dependency に入れる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  strategy </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, roundUpToNearestInteger),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount, strategy); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안에서 [agreeToDonate, amount, strategy]</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>JavaScript の default parameter は<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters" target="_blank" rel="noopener noreferrer">呼び出しのたびに評価される</a>。そして <code>useMemo</code> は dependency を <code>Object.is</code> で比較する。この二つが重なると、render のたびに新しいインスタンスが生まれ、memo は毎回再計算する。React 18 で同じ構造を四回 render したところ、デフォルト値を使う場合は計算が 4 回、同じインスタンスを渡す場合は 1 回走った。リポジトリの <code>App.tsx</code> は <code>&#x3C;Payment amount={19.9} /></code> で Strategy を渡さないので、アプリの実行経路はまさにデフォルト値の側だ。</p>
<p>この例の計算は足し算数回なのでコストはない。問題は構造だ。Strategy を誰がどこで作って渡すのかが記事にもリポジトリにも決まっておらず、テストだけが日本とデンマークのインスタンスを作っている。筆者なら、インスタンスを module レベルの定数に置き、国コードで取り出して渡す。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> strategies</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  AU: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  JP: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Payment amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{amount} strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{strategies[countryCode]} /></span></span></code></pre></figure>
<h3 id="リポジトリの-fetch-には-dependency-配列がない"><a class="anchor" href="#リポジトリの-fetch-には-dependency-配列がない">リポジトリの fetch には dependency 配列がない</a></h3>
<p>原文のコードの <code>useEffect</code> には <code>[]</code> があるが、リポジトリの <code>usePaymentMethods</code> には最初のコミットから最後のコミットまで dependency 配列がない。配列のない effect は render のたびに再実行される。レスポンスが来ると新しい配列で state を変え、それが render を引き起こし、effect がまた fetch する。<code>convertPaymentMethods</code> が毎回新しい配列を返すので止まらない。</p>
<p>fetch をすぐ応答する mock に置き換え、Jest で 300ms の間の呼び出し回数を数えると、四回測って 152、225、231、233 回になった。<code>[]</code> を入れれば 1 回だ。リポジトリのテストは画面に文言が出るかどうかしか見ないので、この繰り返しを捕まえられず、すべて通る。原文に沿って打ち込んだ読者にはこの問題はなく、リポジトリをそのまま取得して使う場合にだけ引っかかる。</p>
<h3 id="国コードを-prop-で受け取った瞬間に生じる-race"><a class="anchor" href="#国コードを-prop-で受け取った瞬間に生じる-race">国コードを prop で受け取った瞬間に生じる race</a></h3>
<p>原文のコードの fetch effect にも cleanup がない。React 公式ドキュメントの <a href="https://react.dev/learn/synchronizing-with-effects" target="_blank" rel="noopener noreferrer">Synchronizing with Effects</a> は、effect の中で fetch すると遅れて届いた以前のレスポンスが最新のレスポンスを上書きしうるので、<code>ignore</code> フラグで以前のレスポンスを捨てるよう勧めている。原文は mount 時に一度だけ fetch するので、今は問題がない。しかし URL の <code>countryCode=AU</code> を prop で受け取った瞬間に dependency が生まれ、race の余地が開く。筆者はこの構造を持ち込むなら、最初からこう書く。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(countryCode).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ignore) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [countryCode]);</span></span></code></pre></figure>
<p>もう一つ付け加えると、原文の切り上げは <code>Math.floor(amount + 1)</code> なので、金額がすでに整数ならさらに 1 上がる。20 ドルの注文は 21 ドルになる。原文は 19.80 ドルと 3312 円しか例に挙げていないので、これが意図なのかはわからない。実際のサービスに持ち込むなら、<code>Math.ceil</code> と何が違うのかをまず決める必要がある。</p>
<h2 id="この構造はいつ使うのか"><a class="anchor" href="#この構造はいつ使うのか">この構造はいつ使うのか</a></h2>
<p>著者自身、分けることは固定のルールではないと言う。小さくまとまった component は一つのファイルに置いたほうが動作を理解しやすく、警戒すべきはファイルが理解できないほど大きくなることだ。Todo アプリやフォーム一つだけのアプリは全部 component に入れても構わず、サンプルはパターンを多く見せるためにわざと複雑にしたと明かしている。ほかの資料も同じ方向の但し書きを付けている。</p>
<p>Fowler は同じ layering の記事で、この区分は比較的小さな単位にだけ使うよう述べている。view、model、data をトップレベルのフォルダにするのは小さなシステムなら構わないが、どれか一つが大きくなりすぎたら、トップレベルをドメイン単位に分け、その中を layer で分けよということだ。前に見た <code>components/</code>、<code>hooks/</code>、<code>models/</code> フォルダを大きなアプリのトップレベル構造としてそのまま持ち込むと、Fowler が避けるよう言った形になる。</p>
<p>Dan Abramov にも似た経緯がある。2015 年に <a href="https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0" target="_blank" rel="noopener noreferrer">presentational と container component</a> に分けようと提案し、2019 年に冒頭へ、今はそう分けることを勧めないという更新文を付けた。Hooks が同じことを恣意的な区分なしにやってくれるというのが理由で、必要もないのに教条的に強制されるのをあまりにも多く見てきたと付け加えた。原文の hook の抽出は、この更新文が言うやり方に近い。container という名前の component を別に置かず、hook を呼ぶ <code>Payment</code> がその役割を兼ねる。</p>
<p>fetch については、React 公式ドキュメントがさらに一歩進む。<a href="https://react.dev/learn/you-might-not-need-an-effect" target="_blank" rel="noopener noreferrer">You Might Not Need an Effect</a> は、fetch logic を custom hook に切り出しておけば後でより良い戦略へ移しやすいとしながらも、第一候補としてはフレームワーク組み込みの fetching や TanStack Query のような client cache を勧めている。原文の最終段階のように fetch を普通の関数に切り出しておけば、その関数をそのまま TanStack Query の <code>queryFn</code> に渡せて、前の節の race と繰り返し呼び出しは library が引き受ける。筆者は、これが原文が一文でしか触れなかった最も実用的な利点だと見ている。キーと関数を結び付ける方法は <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a> で別に扱った。</p>
<p>まとめると、筆者の判断はこうだ。原文の順番(hook、pure component、domain object、Strategy、network client)はそのまま従う価値がある。段階ごとに、その段階の不便が次の段階の理由になるからだ。ただし最後まで進む必要はなく、今抱えている不便がどの段階のものかを見て、そこで止まればよい。render と無関係なコードが多ければ hook まで、同じ判定が散らばっていれば domain object まで、一つの変更で複数のファイルを直しているなら Strategy までだ。そして Strategy を使うなら、まずインスタンスを作る場所を決め、fetch を effect に置くなら cleanup と dependency から書く。原文が省いたこの二つが、実際に持ち込んだときに真っ先に壊れる場所だった。</p>
<p>正解はないが、この記事を読んでいる読者の皆さんも、自分のコードが今何段階目の不便を抱えているのか一度確かめてみてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer" target="_blank" rel="noopener noreferrer">Microsoft Azure Architecture Center, Anti-corruption Layer pattern</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/headless-component.html" target="_blank" rel="noopener noreferrer">Juntao Qiu, Headless Component</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/data-fetch-spa.html" target="_blank" rel="noopener noreferrer">Juntao Qiu, Data Fetching Patterns in Single-Page Applications</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>アーキテクチャ</category>
        </item>
        <item>
            <title><![CDATA[決定モデル、Jev と Kev]]></title>
            <link>https://hooninedev.com/ja/260922</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260922</guid>
            <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、先週 TypeSafe AI が公開した、テキストの代わりに確率を返すモデル Jev について話してみたい。モデルが返した確率にしきい値をかけて、人の確認なしに自動処理したい開発者のための記事だ。最後まで読めば、Jev の confidence がどう計算されるのか、キャリブレーションが分布によってどれだけ動くのか、そしてしきい値を自分のデータで決める手順が得られる。 最初に目に入...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、先週 TypeSafe AI が公開した、テキストの代わりに確率を返すモデル Jev について話してみたい。モデルが返した確率にしきい値をかけて、人の確認なしに自動処理したい開発者のための記事だ。最後まで読めば、Jev の <code>confidence</code> がどう計算されるのか、キャリブレーションが分布によってどれだけ動くのか、そしてしきい値を自分のデータで決める手順が得られる。</p>
<p>最初に目に入った用途はブラウザ自動化だった。Browserbase が Stagehand の <code>act()</code> に Jev をつなぐ <a href="https://github.com/browserbase/stagehand/pull/2953" target="_blank" rel="noopener noreferrer">PR</a> を出した。ページの<span class="glossary-term-source" data-glossary-key="accessibility-tree">アクセシビリティツリー</span>を <code>state</code> として送り、「次にクリックする要素はどれか」を選択肢で問う構造だった。40 個の課題で <code>act()</code> の中央値が 1.97 秒から 0.46 秒に縮み、147 回の操作のうち LLM に戻ったのは 4 回だった。Playwright スクリプトが selector 一つ変わるだけで壊れる場所を、確率を一つ返してもらう呼び出しで埋めたわけだ。この記事を書いている時点で PR はまだマージされていない。</p>
<p><picture><source type="image/webp" srcset="/content/260922/1.720w.webp 720w, /content/260922/1.1440w.webp 1440w, /content/260922/1.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/1.png" alt="Stagehand の act() が Jev を使う流れ。指示文とアクセシビリティツリーから候補リストを作り、Jev に「どれか」と「該当なしか」を問い、該当なしの確率が低ければその要素をクリックし、高ければ LLM に渡す。147 回中 143 回が Jev で終わった" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>その PR が引いた線が、この記事の問いだ。Jev の答えを受け入れる基準を<a href="https://www.browserbase.com/blog/what-is-jev" target="_blank" rel="noopener noreferrer">確率 0.7</a>に置き、越えなければ LLM に渡す。<strong>あのモデルが返す確率を信じて、コードの中に線を引いてよいのか。</strong></p>
<p>他人のベンチマークだけでは判断できないので、筆者のデータで検証してみることにした。このブログには音写された外来語を止めるゲートがある。ハングルの音写である <code>피커</code> ではなく <code>picker</code> と書こうという規則だが、正規表現が捕まえるのは確実な半分だけで、<code>멀티스레드</code> や <code>콜 스택</code> のように複合語の中にあったり韓国語にすでに定着していたりする語は筆者が毎回手で判断する。答えは「戻す」と「そのまま」の二つしかなく、分かる人なら 1 秒で判断でき、ビルドスクリプトの中で人の確認なしに合否を決めなければならない。Stagehand の selector 判断と同じ形だ。</p>
<p>そこで順序をこう決めた。Jev が何を返すのか、その確率がどんな学習から出てくるのか、筆者のゲートに渡すとどんな数字が出るのか、そしてその数字をどこに使えるのか、だ。</p>
<h2 id="テキストを生成しないモデル"><a class="anchor" href="#テキストを生成しないモデル">テキストを生成しないモデル</a></h2>
<p>公開日は2026年9月15日だ。同社はこれを LLM とは呼ばず、System One Model という新しいカテゴリとして扱う。この記事では決定モデルと呼ぶ。創業者の Diogo Almeida は OpenAI で <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT 論文</a>の4番目の著者として名を連ねた人物だ。ChatGPT の直系の祖先となったその手法を作ったチームにいて、いまはその手法の限界を指摘する側に立っている。</p>
<p>Jev は文を生成しない。答えの形をあらかじめ決めて渡すと、その中から選び、確率を一緒に返す。質問タイプは三つだけだ。</p>
<table>
<thead>
<tr>
<th>タイプ</th>
<th>問うもの</th>
<th>返すもの</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>choice</code></td>
<td>このうちどれか</td>
<td>選択、<code>probabilities</code>、<code>confidence</code></td>
</tr>
<tr>
<td><code>score</code></td>
<td>どの水準か</td>
<td>スコア、<code>legend</code>、<code>probabilities</code>、<code>confidence</code></td>
</tr>
<tr>
<td><code>noul</code></td>
<td>真か</td>
<td>0から1のあいだの確率ひとつ</td>
</tr>
</tbody>
</table>
<p>endpoint も一つだ。<code>POST /v1/systemone</code> に <code>state</code>（評価する内容）、<code>model</code>、<code>questions</code>（質問のマップ）を送れば答えが返る。同じ <code>state</code> に対する質問を複数まとめて一つのリクエストに入れられ、質問を増やしても応答時間はほとんど伸びない。</p>
<p>なぜ速いのかは、<a href="/260610" target="_blank" rel="noopener noreferrer">トークンの仕組み</a>でまとめた <span class="glossary-term-source" data-glossary-key="prefill">prefill</span> と decode の非対称で説明できる。LLM が遅いのは出力を1トークンずつ順に絞り出すからだが、Jev にはその decode の段階がない。入力を一度並列に読み、確率をそのまま読み出す。だから出力トークンの料金が最初から存在せず、入力のみ100万トークンあたり $0.042 だ。</p>
<p>Archer Hume という開発者が API を約1万回呼び出し、外側から動作を復元した<a href="https://archerhume.com/posts/jevs-architecture-unmasked/" target="_blank" rel="noopener noreferrer">分析</a>がある。入力360トークンで中央値57.5ms、29,835トークンで218ms、質問を1,500個入れたときで610msだった。そのうち最も直接的な証拠は、選択肢が200個の応答が2個のものと同じ速度で返ってきたという観察だ。答えを書くのに時間がかからないという意味である。</p>
<h2 id="人が見ていない判断"><a class="anchor" href="#人が見ていない判断">人が見ていない判断</a></h2>
<p>速度と価格が目を引くが、Almeida が公開の発表記事で投げかけた問いは別の方にあった。モデルは何年も前から対話では超人的なのに、自動化はどこへ行ったのか、という問いだ。</p>
<p>彼の答えは、二つの仕事は種類が違うというものだ。チャットボットと copilot とコーディング agent は、人が横で見ながら満足することが目標だ。サーバーで静かに回りながら誰も見ていない判断はそうではない。前者を assistant、後者を automation と呼ぶなら、これまでに出てきたモデルはすべて前者のために作られてきた。</p>
<p><a href="/260622" target="_blank" rel="noopener noreferrer">harness 設計</a>をまとめたときにも、似た区別をしたことがある。エージェントシステムの中には、人が見ていない判断が無数にある。このリクエストを人に回すべきか。このコマンドを実行してよいか。いまはそれらを全部、高価な LLM に尋ねている。筆者の正規表現ゲートが覆えない残りの半分も、そうした判断だ。</p>
<h2 id="rlhfが残した空白"><a class="anchor" href="#rlhfが残した空白">RLHFが残した空白</a></h2>
<p>ところで、なぜ新しい学習法が必要だったのだろうか。既存のモデルに「はい」か「いいえ」だけを答えさせてはいけないのだろうか。</p>
<p>答えの手がかりは<span class="glossary-term-source" data-glossary-key="calibration">キャリブレーション</span>にある。正解率が何パーセント当てるかなら、キャリブレーションは自分が何パーセント当てるかを知っているかどうかであり、そのズレを測る指標が <span class="glossary-term-source" data-glossary-key="ece">ECE</span>（expected calibration error）だ。確率の区間ごとに言った確率と実際の的中率の差を標本比率で重み付けして平均した値で、0が完璧である。ところが、人の選好を最適化する <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span>（reinforcement learning from human feedback）を経た後に、この目盛りがずれた例がある。<a href="https://arxiv.org/abs/2303.08774" target="_blank" rel="noopener noreferrer">GPT-4 技術レポート</a>の Figure 8 では、事前学習モデルの ECE が <strong>0.007</strong> だったのに対し、post-training を経たモデルは <strong>0.074</strong> と10倍以上悪くなった。レポートはその原因を書いておらず、筆者も原因を確定する一次資料は見つけられなかった。定義と、原因がどこまで明らかになっているかは<a href="/260917" target="_blank" rel="noopener noreferrer">LLMのキャリブレーションと過信</a>に別にまとめておいた。</p>
<p><strong>ただし ECE 一つでは足りない。</strong> すべての入力に0.6を出す定数予測器でも、実際の的中率が60%なら ECE は0になる。確率が正直なだけで案件ごとに分かれないなら、線を引く場所がない。だからキャリブレーションとは別に、<strong>確率が実際に分かれるか</strong>も一緒に見なければならず、後で使う「誤差予算の中で自動処理できる比率」が、その二つを一つの数字にまとめた指標である。</p>
<p>ソフトウェアの立場で重要なのはこの部分だ。確率が正直で、かつ分かれるなら、線を引ける。0.95を超えれば自動処理し、その下は人に回すという構造が作れる。</p>
<p>TypeSafe はその場所を狙って三番目の道を出したと言う。人の選好を最適化する RLHF、機械が採点できる正解を最適化する RLVR（reinforcement learning with verifiable rewards）、そしてキャリブレーションされた決定を最適化するという RLCD である。</p>
<p>ただし RLCD として公開されているのは出力契約の3行がすべてだ。この記事を書いている2026年9月23日の時点で、TypeSafe のドキュメントにも発表記事にも論文や技術レポートはない。同じ略語を使う <a href="https://arxiv.org/abs/2307.12950" target="_blank" rel="noopener noreferrer">ICLR 2024 の論文</a>が別にあるが、そちらは reinforcement learning from contrastive distillation で別の手法だ。検索するときは混ざらないよう注意する。</p>
<h2 id="confidenceの正体"><a class="anchor" href="#confidenceの正体">confidenceの正体</a></h2>
<p>では、Jev が返す数字は正直なのか。答える前に、まず見ておくことがある。<strong>返ってくる数字は一つではない。</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/3.720w.webp 720w, /content/260922/3.1440w.webp 1440w, /content/260922/3.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/3.png" alt="Jevの応答でprobabilitiesは学習によって作られ、confidenceはその分布を算術で畳んだ値であり、キャリブレーションの主張はprobabilitiesにのみかかっていることを示す図" style="max-width:min(720px, 100%);height:auto;" width="1560" height="840" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p><code>Choice</code> と <code>Score</code> の応答には <code>probabilities</code> と <code>confidence</code> が一緒に入っている。前者は選択肢全体にわたる確率分布で、後者は0から1のあいだの数字ひとつだ。コードでしきい値をかけるとき、まず手が伸びるのは <code>confidence</code> の方である。</p>
<p>この値は、モデルが作ったものではない。TypeSafe の organization が公開している <a href="https://github.com/typesafe-ai/system-one-adapter-python" target="_blank" rel="noopener noreferrer">system-one-adapter-python</a> の <code>_utils/confidence_metrics.py</code> は全部で32行で、<code>Choice</code> の部分がこうなっている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">def</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> choice_confidence</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs: list[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) -> </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    """Scale peak choice probability from uniform to certainty."""</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    normalized_probs </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> _normalize(probs)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    uniform_probability </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> /</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability)</span></span></code></pre></figure>
<p>モデルの呼び出しもなく、学習されたパラメータもない。<code>probabilities</code> のベクトルが一つ入り、実数が一つ出る。式で書くと <code>c = (p_max − 1/K) / (1 − 1/K)</code> で、選択肢が三つで最大確率が0.8なら0.7になる。</p>
<p>このリポジトリはプロダクションの Jev サーバーではなく、LLM で同じ API を模倣する代替実装だ。だからこれだけで断定することはできない。ところが、同じ式を指す証拠がもう二つある。TypeSafe 公式ドキュメントの <a href="https://docs.typesafe.ai/confidence" target="_blank" rel="noopener noreferrer">confidence ページ</a>はこの値を "a statistic computed from the probability distribution" と記し、デモコードには三つの選択肢用の近似式として <code>(3 × largest probability − 1) / 2</code> を載せている。K が3なら上の式と同じだ。そして<a href="https://github.com/SamuelSacco/jev-exploration" target="_blank" rel="noopener noreferrer">主張の監査台帳</a>が、実際の API で、どちらも誤りの選択肢を二つだけ与えた応答の勝者が0.52のとき <code>confidence</code> が0.04で返ってきたと記録した。K が2なら <code>(0.52 − 0.5) / (1 − 0.5) = 0.04</code> だ。式と一致する。</p>
<p>同じリポジトリで <code>Score</code> はまったく別の式を使う。最頻の水準からの平均絶対距離を、一様分布のそれで割ってから<strong>1から引く。</strong> ドキュメントは <code>Score</code> の式を公開していない。つまり <code>confidence</code> という一つの名前の下に計算式が二つあり、タイプが違えば同じ数字でも意味が違う。<code>Noul</code> に <code>confidence</code> がないのも同じ理由だ。畳むべき分布がない。</p>
<p><picture><source type="image/webp" srcset="/content/260922/4.720w.webp 720w, /content/260922/4.1440w.webp 1440w, /content/260922/4.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/4.png" alt="p_max の目盛りで 1/K を 0 に、1 を 1 に引き伸ばして confidence の目盛りを作る図。選択肢が三つなら 0.80 は 0.7 に移り、1/3 未満は捨てられ、順序は変わらない" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>ドキュメントもこれを隠していない。別の計算を使いたければ <code>probabilities</code> 全体を渡すので好きにしてよい、とまで案内している。<strong>書いてある内容であり、読まずに使う側の問題だ。</strong></p>
<p>ところが、<a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" target="_blank" rel="noopener noreferrer">公開の発表記事</a>の比較表は二つをくっつけて書いている。"Calibrated: higher confidence means higher accuracy" と書かれている。読む人は <code>confidence</code> がキャリブレーションされた値だと受け取るが、キャリブレーションの主張が実際にかかっているのは <code>probabilities</code> の方だ。</p>
<p>この区別が数字でどれだけ開くかは、測定がある。<code>scienthoon</code> という開発者が公開した<a href="https://github.com/scienthoon/jev-ood-calibration" target="_blank" rel="noopener noreferrer">独立したキャリブレーション測定</a>が、<code>confidence</code> を「当たる確率」として読んだときの ECE を別に測っている。OpenBookQA で0.035、HellaSwag で0.078、合成した集合で0.18だ。同じ集合の元の確率はそれぞれ0.024、0.029、0.107だった。<strong>三つの集合すべてで <code>confidence</code> の方が悪い。</strong></p>
<p>なぜ悪くなるのかは式にある。K が固定なら <code>c</code> は <code>p_max</code> の単調増加変換なので、順序は保存される。判別力はそのままで、しきい値だけ書き換えればよい。<strong>壊れるのは情報ではなく目盛りだ。</strong> K が質問ごとに違うと、問題がもう一つ増える。引き伸ばす倍率も変わる。同じ <code>p_max</code> 0.8が、選択肢二つでは0.6になり、五つでは0.75になる。選択肢の数が混ざったワークロードでしきい値を一つだけ使った瞬間、それが問題になる。</p>
<h2 id="分布によって変わるキャリブレーション"><a class="anchor" href="#分布によって変わるキャリブレーション">分布によって変わるキャリブレーション</a></h2>
<p>では、<code>probabilities</code> の方は信じてよいのだろうか。</p>
<p>Jev を実際に呼び出して測った記録が公開されている。Jared Palmer が作ったオープンソースの再現実装 <a href="https://github.com/jaredpalmer/kev" target="_blank" rel="noopener noreferrer">Kev</a> のリポジトリには <code>runs/jev-*</code> の directory があり、<code>usage.json</code> が <code>"model": "typesafe-ai/jev"</code> として、Vercel AI Gateway を経た実際の呼び出しであることを記録している。<code>jev-*</code> の directory 六つの <code>usage.json</code> を合わせると、2026年9月18日と20日にわたって5,364回呼び出し、入力が2,418,260トークンだ。以下の七つの集合のうち <code>scienthoon</code> は、その開発者の結果ファイルを変換して取り込んだものなので、この合計には入っていない。</p>
<p>価格が実際に入力だけ課金されることは、別のところで確認できる。フィッシングのベンチマークの著者が TypeSafe の dashboard を見て書き残している。二回回した456万トークンのうち入力が366万、出力が90万だったが、請求は $0.15 で、その値が入力だけを計算したものと一致した。</p>
<p>同じ directory の <code>report.json</code> から評価集合7個の指標を取り出して整理するとこうなる。以下で<strong>確信度は <code>confidence</code> フィールドではなく <code>probabilities</code> の最大値</strong>である。過信は <code>평균 확신도 − 정확도</code>（平均確信度 − 正解率）で、合計5,743件だ。判断の根拠を消した「分からない」項目を正解率の採点から外す <code>clean</code> ブロックの値である。</p>
<table>
<thead>
<tr>
<th>評価集合</th>
<th>n</th>
<th>正解率</th>
<th>平均確信度</th>
<th>過信</th>
<th>ECE</th>
</tr>
</thead>
<tbody>
<tr>
<td>semif</td>
<td>144</td>
<td>0.965</td>
<td>0.965</td>
<td>-0.000</td>
<td>0.011</td>
</tr>
<tr>
<td>transfer-v9</td>
<td>1,046</td>
<td>0.854</td>
<td>0.880</td>
<td>+0.026</td>
<td>0.034</td>
</tr>
<tr>
<td>transfer-v4</td>
<td>656</td>
<td>0.857</td>
<td>0.903</td>
<td>+0.046</td>
<td>0.049</td>
</tr>
<tr>
<td>transfer-v2</td>
<td>560</td>
<td>0.855</td>
<td>0.900</td>
<td>+0.045</td>
<td>0.055</td>
</tr>
<tr>
<td>decision-v4</td>
<td>1,264</td>
<td>0.845</td>
<td>0.906</td>
<td>+0.061</td>
<td>0.067</td>
</tr>
<tr>
<td>decision-v2</td>
<td>1,200</td>
<td>0.833</td>
<td>0.904</td>
<td>+0.071</td>
<td>0.074</td>
</tr>
<tr>
<td>scienthoon</td>
<td>873</td>
<td>0.753</td>
<td>0.851</td>
<td>+0.099</td>
<td>0.105</td>
</tr>
</tbody>
</table>
<p>二つのことが読み取れる。</p>
<p>第一に、<strong>ECE が10倍近く動く。</strong> 0.011から0.105だ。同じ採点器、同じモデルである。「キャリブレーションされている」はモデルの性質ではなく、モデルと分布の関係なのである。分布が変わればキャリブレーションが崩れること自体は、すでに知られた結果だ。ここで新しいのは、<strong>キャリブレーションを学習目標として掲げたモデルでも同じ形が出る</strong>という点である。</p>
<p>ECE が最も大きい集合の0.105は、先に見た post-RLHF の GPT-4 の0.074よりも大きい。ただし、この二つを順位として読んではいけない。課題が違ううえ、ECE は区間をいくつに分けるか、標本がどの区間に偏っているかによって値が動く推定量なので、別々の採点器から出た数字を突き合わせても比較は成立しない。</p>
<p>第二に、<strong>測定された ECE が過信ひとつでほぼ全部説明される。</strong> 二つの列の差は7個すべてで0.002から0.011のあいだだ。ECE は区間ごとの差の加重平均なので全体の過信より小さくなりようがないが、その差がほぼ0だということは、<strong>区間ごとのズレの符号が一方向に偏っている</strong>という意味である。区間ごとに食い違って打ち消し合うノイズではない。</p>
<p>なぜそうなるかは、縦に読むと見える。平均確信度は <code>semif</code> を除けば0.851から0.906のあいだに6個が固まっている。同じ区間で正解率は0.753から0.857へ、2倍の幅で動く。<strong>確信度は分布が変わってもあまり動かず、正解率だけが動く。残った差がそのまま ECE だ。</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/5.720w.webp 720w, /content/260922/5.1440w.webp 1440w, /content/260922/5.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/5.png" alt="Jev 実呼び出しの評価集合 7 個で、平均確信度は 0.851 から 0.906 の間に固まり、正確度だけが 0.753 から 0.965 まで動き、その差が ECE とほぼ一致することを示す図表" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>この性質が実務でどう現れるかは、別のベンチマークが見せてくれる。フィッシングメール2,000件に Claude Haiku 4.5 を対照群として置いた<a href="https://github.com/anisselbd/jev-phishing-bench" target="_blank" rel="noopener noreferrer">測定</a>で、Jev の正解率は62.6%、Haiku は81.3%だった。ところが、もっと目を引くのは ECE だ。リポジトリが報告した ECE は Jev が0.154、Haiku が0.097で、主張の監査台帳が Jev の <code>P(phishing)</code> の値を0から1まで10区間で測り直すと0.170になる。どちらで読んでも<strong>キャリブレーションを学習目標として掲げたモデルが、RLHF で作った LLM にキャリブレーションで負けた。</strong> 同じ著者がリンクのホスト一覧の規則一つだけで91.6%を出した。</p>
<p>同じ指標を複数の集合で見ると、その幅はもっとはっきりする。<span class="glossary-term-source" data-glossary-key="error-budget">誤差予算</span>5%で自動処理できる比率が、<code>scienthoon</code> の集合で0.486、<code>transfer-v9</code> で0.695、<code>semif</code> で1.000だ。Kev の採点器はこの値を、標本の中でしきい値を選んだ最大値だと書き残しており、デプロイ後の誤差の保証ではない。<strong>そのどれか一つをモデルのスペックのように引用してはいけない。</strong></p>
<h2 id="音写判別ゲート"><a class="anchor" href="#音写判別ゲート">音写判別ゲート</a></h2>
<p>他人のベンチマークを読むことと、自分のデータで測ってみることは違う。そこで冒頭のゲートを実際に通してみた。</p>
<p>筆者はまだ Jev の API キーを持っていない。代わりに、先に触れた <strong>Kev-9B をローカルで回した。</strong> Qwen3.5-9B-Base に rank-16 <span class="glossary-term-source" data-glossary-key="lora">LoRA</span> と pointer head を載せた再現実装で、Apache-2.0 だ。以下の数字は Jev の数字ではない。Kev の学習データは英語の決定課題であり、同じ著者の測定でも Kev-9B は英語の課題ですら Jev に劣る。誤差予算5%の自動化比率が0.45から0.57なのに対し Jev は0.70で、MMLU-Pro は0.52対0.84だ。そして TypeSafe のドキュメントも Jev について、<a href="https://docs.typesafe.ai/models" target="_blank" rel="noopener noreferrer">英語が主な学習言語であり CJK は同等ではない</a>と明らかにしている。</p>
<p>評価集合は、このリポジトリの韓国語記事18本から取り出した。ラベルの正解基準は、<strong>このリポジトリがその単語を一貫してどの表記で書いているか</strong>である。韓国語技術文書のコミュニティの合意ではなく、このブログの慣習だという意味で、両者が分かれる単語では、モデルがコミュニティ基準では正しく、この基準では間違うことがありうる。</p>
<ul>
<li><strong>ゲート集合102文</strong>：ゲートがすでに知っている単語だ。本文が英語で書いている <code>calendar</code>、<code>picker</code>、<code>adapter</code> のような単語をハングルの音写に戻した<strong>反事実の文</strong>が陽性23件、<code>write-post.md</code> が例外として明示した <code>리렌더링</code> や <code>콜 스택</code> が実際に使われている文が陰性79件である。ここでは現在の正規表現ゲートが定義上100%を当てる。</li>
<li><strong>保留集合60文</strong>：<strong>ゲートが一度も見たことのない単語だ。</strong> 機械学習で <span class="glossary-term-source" data-glossary-key="held-out-set">held-out</span> と呼ぶ側だ。 本文が英語だけで書く <code>loader</code>、<code>mutation</code>、<code>prefill</code> を音写に戻したものが陽性30件、本文がハングルだけで書く <code>리듀서</code>、<code>스냅샷</code>、<code>런타임</code> が陰性30件である。ここでは正規表現は陽性を一つも捕まえられない。</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260922/6.720w.webp 720w, /content/260922/6.1440w.webp 1440w, /content/260922/6.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/6.png" alt="ゲート集合 102 文と保留集合 60 文の陽性・陰性の構成、そして正規表現ゲートがゲート集合は全部覆うが保留集合の陽性は一つも捕まえないことを示す図" style="max-width:min(720px, 100%);height:auto;" width="1560" height="860" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>陽性が筆者の作った反事実の文で、陰性が実際の文だという非対称は、先に明らかにしておく。そして<strong>有効な標本は文の数ではなく単語の数だ。</strong> ラベルが単語単位で決まるので、同じ単語が出てくる文どうしは独立した観測ではない。ゲート集合は24種、保留集合は13種である。</p>
<p>以下の表は「この単語を英語に戻すべきか」を問う <code>noul</code> の質問一つだけを集計したものだ。同じリクエストに質問を四つ入れたが、残りの三つは否定形と <code>Choice</code> の変形、そしてラベルをきちんと立てられなかった複合語の判定なので、ここには混ぜていない。<strong>5%予算の自動化比率</strong>は、確信度の高い方から切り下げていき、その上の区間の誤差が5%を超えない最大の比率である。</p>
<table>
<thead>
<tr>
<th></th>
<th>ゲート集合</th>
<th>保留集合</th>
</tr>
</thead>
<tbody>
<tr>
<td>文 / 単語</td>
<td>102 / 24種</td>
<td>60 / 13種</td>
</tr>
<tr>
<td>現在の正規表現ゲート</td>
<td><strong>1.000</strong></td>
<td>0.500</td>
</tr>
<tr>
<td>Kev-9B 正解率</td>
<td>0.225</td>
<td>0.500</td>
</tr>
<tr>
<td>単語単位の正解率</td>
<td>6/24</td>
<td>7/13</td>
</tr>
<tr>
<td>多数クラスのベースライン</td>
<td>0.775</td>
<td>0.500</td>
</tr>
<tr>
<td>ECE</td>
<td>0.664</td>
<td>0.336</td>
</tr>
<tr>
<td>平均確信度</td>
<td>0.876</td>
<td>0.821</td>
</tr>
<tr>
<td>確信度0.9以上の比率</td>
<td>0.490</td>
<td>0.317</td>
</tr>
<tr>
<td>その区間の実際の的中</td>
<td>0.240</td>
<td>0.526</td>
</tr>
<tr>
<td><strong>5%予算の自動化比率</strong></td>
<td><strong>0.010</strong></td>
<td><strong>0.000</strong></td>
</tr>
</tbody>
</table>
<p>多数クラスのベースラインは、文を読まずに多い方のラベルだけを出したときのスコアだ。モデルはその下である。</p>
<p>最後の行がこの実験の答えだ。<strong>保留集合では、しきい値をどれだけ高く取っても、5%の誤差の中で自動処理できる決定は一件もなかった。</strong></p>
<p>予測分布を見れば、何が起きたのかが分かる。モデルは<strong>162文すべてに「戻すべきだ」と答えた。</strong> 単語37種すべてである。陽性は全部当たり、陰性は全部外した。だから保留集合の0.500は実力ではなく、集合が均衡しているから出た数字だ。</p>
<p>それでいて平均確信度は0.876だった。ゲート集合で確信度0.9を超えたものが49%なのに、その区間の実際の的中は0.240である。</p>
<p>構造的な恒等式も成り立たなかった。「戻すべきか」と「そのままにすべきか」の確率を足すと、ゲート集合で平均1.655になり、102文すべてが1から0.1以上ずれた。</p>
<p>設計上そうなっている。二つの質問は互いを読めない別々の評価であり、TypeSafe 自身も <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" target="_blank" rel="noopener noreferrer">jaggedness のページ</a>に、<code>refund</code> 0.72と <code>not_refund</code> 0.47を足して1.19になる例を載せている。</p>
<p>実務で引っかかる点はここだ。<strong><code>Noul</code> に合わせて調整したしきい値を <code>Choice</code> に移してはいけない。</strong> 同じ判断なのに結論が分かれる文が14%から17%あった。</p>
<p>何を聞いても「はい」と答えるモデルなら、この結果は無意味だ。まずそれから確認した。</p>
<table>
<thead>
<tr>
<th>状態 / 質問</th>
<th>聞いたこと</th>
<th>答え</th>
</tr>
</thead>
<tbody>
<tr>
<td>韓国語 / 韓国語</td>
<td>この文は英語で書かれているか?</td>
<td>0.02</td>
</tr>
<tr>
<td>韓国語 / 韓国語</td>
<td>この文は料理法を説明しているか?</td>
<td>0.04</td>
</tr>
<tr>
<td>韓国語 / 韓国語</td>
<td>この文は韓国語で書かれているか?</td>
<td>0.98</td>
</tr>
<tr>
<td>韓国語 / 英語</td>
<td>Is this sentence written in English?</td>
<td>0.01</td>
</tr>
<tr>
<td>英語 / 英語</td>
<td>Is this sentence about software?</td>
<td>0.96</td>
</tr>
</tbody>
</table>
<p><strong>韓国語を読み、「いいえ」と言い、0.02と0.98を分ける。</strong> 全域的な迎合バイアスではない。排除できるのはそこまでだ。ゲートの質問の文言そのものが悪い可能性は残る。</p>
<h2 id="規則をstateに入れた結果"><a class="anchor" href="#規則をstateに入れた結果">規則をstateに入れた結果</a></h2>
<p>では、判別に必要な知識を直接与えれば変わるだろうか。TypeSafe のドキュメントが勧めるドメイン適応の方式がこれだ。重みには触れず、参考資料を <code>state</code> に載せて送る。</p>
<p><code>write-post.md</code> にすでに書かれている規則の段落を <code>state</code> に一緒に入れて、もう一度回した。入力が1文あたり100トークンから450トークンに増えた。<strong>ゲート集合は<span class="glossary-term-source" data-glossary-key="data-leakage">リーク</span>だ。</strong> その参考資料が、ゲートの単語と例外の単語を名前で並べているからである。だからそちらは「参考資料を読んではいるのか」を見る対照群で、本当の試験は保留集合だ。</p>
<table>
<thead>
<tr>
<th></th>
<th>ゲート（リーク）</th>
<th>ゲート +規則</th>
<th>保留</th>
<th>保留 +規則</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>noul</code> 正解率</td>
<td>0.225</td>
<td>0.676</td>
<td><strong>0.500</strong></td>
<td><strong>0.500</strong></td>
</tr>
<tr>
<td>単語単位の正解率</td>
<td>6/24</td>
<td></td>
<td>7/13</td>
<td><strong>5/13</strong></td>
</tr>
<tr>
<td>陽性の正解</td>
<td>23/23</td>
<td>1/23</td>
<td>30/30</td>
<td>8/30</td>
</tr>
<tr>
<td>陰性の正解</td>
<td>0/79</td>
<td>68/79</td>
<td>0/30</td>
<td>22/30</td>
</tr>
<tr>
<td>5%予算の自動化比率</td>
<td>0.010</td>
<td>0.314</td>
<td>0.000</td>
<td>0.050</td>
</tr>
<tr>
<td>レイテンシ中央値</td>
<td>2,924ms</td>
<td>6,903ms</td>
<td>2,908ms</td>
<td>6,918ms</td>
</tr>
</tbody>
</table>
<p>レイテンシは M2 Max で回したローカル推論なので、先に見た Jev の API レイテンシとは同じ軸ではない。ここで見るべきは絶対値ではなく、参考資料を入れたときの2.4倍である。</p>
<p><strong>均衡の取れた保留集合で、文単位の正解率は0.500から0.500へ、まったく動かなかった。</strong> 単語単位では7/13から5/13へ、むしろ下がった。</p>
<p><picture><source type="image/webp" srcset="/content/260922/7.720w.webp 720w, /content/260922/7.1440w.webp 1440w, /content/260922/7.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/7.png" alt="ゲートにない単語 60 文で、規則の段落を state に入れる前後の比較。前は戻すべき 30 文を全部当て、そのまま置く 30 文を全部外し、後は 8 文と 22 文を当てた。正解数はどちらも 30" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>ゲート集合の0.225から0.676は、実力の向上ではない。以前は102文すべてに「戻す」と答えていたが、参考資料を渡すと12文にだけそう答えた。陰性が0/79から68/79へ上がった一方、陽性は23/23から1/23へ崩れた。その集合は陰性が79対23で多いので、「おおむねそのまま置け」という定数が自動的により高いスコアを得る。<strong>区別を学んだのではなく、傾きを変えただけだ。</strong></p>
<p>だからといって、モデルが文を読んでいないわけではない。同じ単語の同じ文なのに、参考資料を渡すと文ごとに予測が散らばった。<code>로더</code> の標準偏差が0.096から0.217へ、<code>뮤테이션</code> が0.042から0.236へ増えた。<strong>読んではいるが、区別ができない。</strong></p>
<p>そしてこの結果は、前節の解釈を揺さぶりもする。state のテキスト一つで出力が丸ごとひっくり返るなら、初回の「全部戻す」もモデルの限界ではなく、質問の文言の不足かもしれない。この記事はその可能性を排除できていない。</p>
<p>一つだけは目に見えてよくなった。「戻す」と「そのまま」の確率の和が、保留集合で1.508から0.963へ近づき、1から大きく外れた文が59個から18個に減った。<strong>ところが正解率は動かなかった。</strong> 二つの質問の確率が互いに噛み合うことと、その確率が当たっていることは別の話だ。</p>
<h2 id="決定モデルの居場所"><a class="anchor" href="#決定モデルの居場所">決定モデルの居場所</a></h2>
<p>ここまで見ると、このモデルのカテゴリ全体が役に立たないという結論に傾きやすいが、公開されたベンチマークを並べてみるとそうではない。</p>
<table>
<thead>
<tr>
<th>課題</th>
<th>n</th>
<th>Jev</th>
<th>比較対象</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/bitnovus/jev-spam-eval" target="_blank" rel="noopener noreferrer">スパム、分布内</a></td>
<td>18,514</td>
<td>98.3%</td>
<td>TF-IDF 回帰 98.4%</td>
</tr>
<tr>
<td>スパム、分布外</td>
<td>2,876</td>
<td><strong>98.6%</strong></td>
<td>TF-IDF 回帰 <strong>73.0%</strong></td>
</tr>
<tr>
<td>フィッシング</td>
<td>2,000</td>
<td>62.6%</td>
<td>Haiku 4.5 81.3%、ルールのベースライン 91.6%</td>
</tr>
<tr>
<td><a href="https://github.com/anessbelbati/jev-rerank-bench" target="_blank" rel="noopener noreferrer">rerank 英語8種</a></td>
<td>1,617</td>
<td>nDCG@10 0.692</td>
<td>Cohere Rerank 4 Pro 0.691</td>
</tr>
</tbody>
</table>
<p>rerank の行だけが検索順位の品質指標なので、他の行の正解率とは軸が違う。</p>
<p><strong>分布の内側では、正規表現や作り込んだ分類器が勝つか引き分ける。</strong> 筆者のゲート集合で正規表現が1.000対0.225で勝ったのと同じ位置だ。ラベルを集められるなら、小さなモデルを学習させる方が安く、速く、正確である。それは昔からやってきたことだ。</p>
<p><strong>差が開くのは、分布が新しくラベルがないときだ。</strong> <span class="glossary-term-source" data-glossary-key="out-of-distribution">分布外</span>のスパムで98.6%対73.0%である。作り込んだ分類器は自分が見たことのない形の前で崩れるが、こちらは持ちこたえる。決定モデルの居場所は、<strong>データを集めてラベルを付けて学習させることができない判断</strong>だ。毎回状況が新しいのでラベルを集められないのに、判断は秒単位で下さなければならない場所である。</p>
<p>筆者の音写判別がなぜ失敗したのかも、この枠組みで説明できる。その判断に必要なのは一般的な推論ではなく、<strong>韓国語の技術文書でどの語が定着したかという特定ドメインの知識</strong>であり、英語の決定課題で学習した9Bの再現実装にとって、それは分布外ですらなく、単に持っていない知識だ。規則を文章で渡しても駄目だった理由がここにある。</p>
<p>TechCrunch の記事で、Pi harness を作る Earendil の CTO である Armin Ronacher が<a href="https://techcrunch.com/2026/09/18/a-new-kind-of-ai-model-from-a-chatgpt-inventor-is-thrilling-developers/" target="_blank" rel="noopener noreferrer">こう語っている</a>。</p>
<blockquote>
<p>At the end of the day, it delegates the hallucination problem a little bit to the user.</p>
</blockquote>
<p>ハルシネーションをなくしたのではなく、判断を開発者に渡したということだ。TypeSafe のドキュメントも、キャリブレーションは予測の束に対して成り立つ性質であって、個別の答えが当たっているという保証ではないと書いている。そして公開の発表記事のハルシネーション率0%の棒の下の Nuance 項目には "Our number is not empirical" と書かれている。形式は保証され、中身は保証されない。</p>
<h2 id="jevable-の194プロジェクト"><a class="anchor" href="#jevable-の194プロジェクト">jevable の194プロジェクト</a></h2>
<p>筆者のデータでは失敗したので、ほかの人はどこに使っているのかを見た。<a href="https://jevable.com/" target="_blank" rel="noopener noreferrer">jevable.com</a>は、Nikunj という開発者が X に投稿された Jev プロジェクトを審査して集めた独立のキュレーションだ。2026年9月23日時点で194件あり、登録日は9月16日から20日の間に集中している。152件が9月18日の一日に入ってきた。公開から三日後だ。</p>
<table>
<thead>
<tr>
<th>カテゴリ</th>
<th>件数</th>
</tr>
</thead>
<tbody>
<tr>
<td>Games</td>
<td>39</td>
</tr>
<tr>
<td>Developer tools</td>
<td>34</td>
</tr>
<tr>
<td>Productivity</td>
<td>31</td>
</tr>
<tr>
<td>Agents</td>
<td>19</td>
</tr>
<tr>
<td>Experiments</td>
<td>18</td>
</tr>
<tr>
<td>Creative tools</td>
<td>16</td>
</tr>
<tr>
<td>Data &#x26; research, Finance, Browser extensions, Robotics, Marketing</td>
<td>37</td>
</tr>
</tbody>
</table>
<p>カテゴリではなく「何を尋ねているか」で括り直すと、三つの形が出てくる。以下の数値はすべて投稿者が自分の投稿に書いた値だ。</p>
<p><strong>第一に、分類とルーティング。</strong> メール1,500件の分類、税務書類の分類、競合他社の広告1,891件を19秒、$0.12で分類、PR の diff ひとつに typed check 14個、Android の通知から広告の判別、建設図面26枚を2.9秒で分類。冒頭の音写ゲートと同じ形だ。前の節の表が言ったとおりラベルが毎日たまる場所なので、時間がたてば自分のラベルで学習した小さな分類器が勝つか、引き分ける。Jev の利点はラベルのない初日にある。</p>
<p><strong>第二に、行動選択のループ。</strong> Browser Use につないで航空券の検索を7秒、$0.0039で終えたという browser agent、音声で Mac を操作する computer use、Mario が死ぬたびに VM を四つに fork して生き残る方を選ぶゲーム、3D キャラクターの口と眉と視線など十項目をメッセージごとに決める表情エンジン。ステップごとに action space が変わるのでラベルを集められず、判断は秒未満に下げなければならない。分布外のスパムで差が開いたのと同じ場所だ。Games が39件で最大のカテゴリである理由もここにあると見ている。ゲームは間違えても巻き戻せる行動選択のループだ。</p>
<p><strong>第三に、確率をユーザーに見せる UI。</strong> 答えの代わりに判定だけを返す Ask Jev、次の質問を確率で選ぶ分岐フォーム JevForm、技術的な深さやドラマ性のようなスライダー六本で Hacker News のトップ画面を並べ直す Upweight。しきい値をコードに埋め込まず人が確率を読む場所なので、この記事が問題にしたキャリブレーションの要求が最も低い。</p>
<p>一つ目立つことがある。194件の説明文のうち、コストを書いたものが30件、速度を書いたものが53件ある一方で、正確さや基準線を書いたものは7件だ。説明文は元の投稿の冒頭だけを収めているので下限だが、方向は明らかだ。速くて安いことは初日にわかり、当たっているかは測ってみないとわからないのに、測る側がまれである。その7件のひとつが <code>jevcal</code> だ。みんなしきい値を勘で選んでいると言い、自分のデータと目標の正確さを入れるとしきい値と自動処理の比率を返す道具である。次の節が勧める手順をそのまま道具にしたものだ。</p>
<h2 id="線を引く手順"><a class="anchor" href="#線を引く手順">線を引く手順</a></h2>
<p>この記事の結論はこれだ。<strong>モデルが返した確率をスペックのように読まず、自分のデータで直接測って線を引かなければならない。</strong></p>
<p>測る手順はこうだ。ラベルのある100件ほどを集めて、確率の区間ごとの実際の的中率の表を作り、しきい値を上から下げながら、その上の区間の誤り率を見る。誤差予算を決めたなら、その予算を守れる最大のカバレッジが、そのまま自動化できる比率だ。筆者の実験で確信度0.9以上の区間の実際の的中が0.240だったというのは、102文で明らかになった。</p>
<p><strong>目盛りがずれていることは、たいてい直せる。</strong> 過信が全区間に均等に広がっているなら、温度ひとつでほとんど合わせられる。Kev もそうして ECE を0.106から0.042へ下げた。問題は、その温度を合わせるにはその分布のラベルが必要だということで、プロダクションで手元にないのがまさにそれだ。しかも温度は確率の順序を変えられないので、自動化できる比率はその分だけ上がらない。だから目盛りより順序が、ECE より誤差予算のカバレッジの方が実務的な指標である。</p>
<p>ドキュメントが例に使った0.9をそのままコードに埋め込んでいたら、このゲートは静かに38件を誤って直していただろう。エラーが出ないので気づきにくい失敗だ。TypeSafe のドキュメントもその例のすぐ下に、自分のデータで試してみるようにと書いている。コードに埋め込まれるのは、たいていその下の文章ではなく上の数字の方だ。</p>
<p>キーがなくても、測ることだけなら今日からできる。Kev が Apache-2.0 で公開されていて、ノートパソコンで動く。</p>
<p>いま運営しているサービスで、大きなモデルに「はい」か「いいえ」だけを尋ねている呼び出しがいくつあるか、数えてみてほしい。その呼び出しを決定モデルへ移す前に、移したあとに引くべき線がどこなのかを先に測ってみることをお勧めしたい。筆者はその順序を守ったからこそ、ゲートには手を付けないと決められた。Jev のキーを受け取れたら同じ集合をもう一度回してみるつもりで、そのとき結論が変われば、それも書くつもりだ。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2411.15124" target="_blank" rel="noopener noreferrer">Lambert et al., Tulu 3: RLVRの命名</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/themsquared/jev-benchmark" target="_blank" rel="noopener noreferrer">themsquared/jev-benchmark, ツール呼び出しの危険度判定</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>キャリブレーション</category>
        </item>
        <item>
            <title><![CDATA[overlay-kit]]></title>
            <link>https://hooninedev.com/ja/260921</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260921</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、オーバーレイを宣言的に扱うインターフェースについて話してみたい。 筆者は宣言的なコードが好きだ。オーバーレイを表示し、閉じ、その中で非同期処理を行うという一連の振る舞いは、そのオーバーレイ自身が管理するほうがよいと考えている。呼び出す側が isOpen を持って切り替える方式は、状態を扱うコードと、その状態が描画されるコンポーネントとを別々の場所に引き離してしまう。 ところが筆者が...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、オーバーレイを宣言的に扱うインターフェースについて話してみたい。</p>
<p>筆者は宣言的なコードが好きだ。オーバーレイを表示し、閉じ、その中で非同期処理を行うという一連の振る舞いは、そのオーバーレイ自身が管理するほうがよいと考えている。呼び出す側が <code>isOpen</code> を持って切り替える方式は、状態を扱うコードと、その状態が描画されるコンポーネントとを別々の場所に引き離してしまう。</p>
<p>ところが筆者が会社で担当していた画面のモーダルは、まさにその逆だった。開いているかどうかをグローバルストアに持たせ、呼び出し側が開き、呼び出し側が閉じていた。モーダルを閉じるコードも、結果を受け取るコールバックも、呼び出し側が直接書いていた。そのため、これを宣言的な方向へどう移すかを長く考え、結局は自分で作り直した。</p>
<p>その作業をしながら <a href="https://github.com/toss/overlay-kit" target="_blank" rel="noopener noreferrer">overlay-kit</a> を見てみた。筆者が目指していたインターフェースと近かった。この記事では overlay-kit の宣言的なインターフェースを見ていき、社内のモーダルシステムをどう設計したかを記録しておく。</p>
<h2 id="状態として持っていたモーダル"><a class="anchor" href="#状態として持っていたモーダル">状態として持っていたモーダル</a></h2>
<p>筆者は<a href="/260518" target="_blank" rel="noopener noreferrer">状態管理</a>についての記事で、モーダルの開閉フラグを最も単純なローカル状態の例として挙げた。ひとつのコンポーネントの中だけで使い、外部が知る必要も知る権利もない状態だと書いた。その見解は今も変わらない。ただし、モーダルが数十種類になり、互いに重なって表示され、結果値を呼び出し側へ返さなければならなくなった瞬間から、その状態はコンポーネントの中にとどまらない。</p>
<p>筆者が引き継いだコードは、開いているモーダルの一覧をグローバルストアに置いていた。社内コードなので、以下の例は構造だけを残して識別子を変えてある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [state.openPopup, state.closePopup]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> handleAddNote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    itemId,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onClose</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">note</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(note);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [closePopup, openPopup, itemId]);</span></span></code></pre></figure>
<p>このコードで気になった箇所は三つある。</p>
<p><strong>第一に、開く関数をフックから取り出さなければならない。</strong> <code>openPopup</code> と <code>closePopup</code> はストアから選び取る値なので、コンポーネントの中でしか手に入らない。そのため、モーダルを開くコードはコンポーネントの外へ出られない。APIレスポンスを横取りする場所やルーターガードのように、フックを使えない場所からモーダルを表示したければ、その関数を引数として渡していくしかない。</p>
<p><strong>第二に、結果がコールバックで返ってくる。</strong> ノートを保存したのか、ユーザーがそのまま閉じたのかを、<code>onSubmit</code> と <code>onClose</code> という二つの経路で受け取る。モーダルを開いたあとにやることがあれば、その処理はコールバックの中に入る。モーダルを二つ続けて開く必要があれば、コールバックの中にコールバックができる。</p>
<p><strong>第三に、閉じる責任が呼び出し側にある。</strong> <code>closePopup(PopupType.ADD_NOTE)</code> を呼び出し側が呼ぶ。モーダルが自分自身を閉じるのではなく、モーダルを開いた側がその種類を覚えていて閉じる。だから同じ定数がひとつの関数の中に三回出てくる。</p>
<p>三つに共通するのは、モーダルをひとつ表示するために呼び出し側が三種類のコードを書かなければならない、ということだ。開くコード、結果を受け取るコールバック、そして閉じるコードである。</p>
<p>何を表示するかを決めるところまでが呼び出し側の役目だとすれば、ここではその後の仕事まで呼び出し側が抱え込んでいた。とくに <code>closePopup(PopupType.ADD_NOTE)</code> は閉じる対象を文字列定数で名指しする。別のモーダルの定数を書いても同じ列挙型なので型検査を通ってしまい、見当違いのモーダルが閉じる。</p>
<h2 id="結果を待つインターフェース"><a class="anchor" href="#結果を待つインターフェース">結果を待つインターフェース</a></h2>
<p>では、この三つのうち何を減らせるだろうか。筆者がたどり着いた答えは、モーダルを開くことを関数呼び出しにし、その関数が終了結果を含む Promise を返すようにすることだった。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openModalAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD_NOTE'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { itemId });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.reason </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'confirmed'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(result.value);</span></span></code></pre></figure>
<p>変わったのは三つだ。開く関数がフックではなくモジュール関数になり、コンポーネントの外でも呼べるようになった。結果がコールバックの引数ではなく戻り値になり、後続の処理が呼び出し関数の流れの中に残った。そして閉じる仕事はモーダルコンポーネント自身が担うようになった。</p>
<p>戻り値の型が <code>Promise&#x3C;ModalResult&#x3C;V>></code> になったことで、モーダルの終了がひとつの値にまとまった。</p>
<p>Promise は一度しか終わらない。すでに終わった Promise を再び resolve しても何も起きないので、終了理由をその値ひとつにすべて詰め込む必要がある。そこで <code>reason</code> を五つに分けた。ユーザーが確定したのか、明示的にキャンセルしたのか、値なしで閉じたのか、同じ種類の新しいモーダルに置き換えられたのか、全体の後片付けで強制終了されたのかである。真偽値ひとつにすると「キャンセル」と「画面ごと片付けられた」が同じ値になってしまうが、後処理が必要な流れではその二つを区別しなければならなかった。</p>
<p>逆に、終わらせなければ呼び出し側は永遠に待つ。コールバックは呼ばなくても何も起きないが、<code>await</code> は戻らないまま残る。そのため、モーダルを画面から取り除く経路が増えるたびに、その経路も Promise を終わらせているかを確認する必要があった。置き換えと強制終了がそれぞれ <code>reason</code> をひとつずつ持っているのは、そのためである。</p>
<h2 id="overlay-kit-の呼び出し側"><a class="anchor" href="#overlay-kit-の呼び出し側">overlay-kit の呼び出し側</a></h2>
<p>toss の overlay-kit の呼び出し側はこうなっている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> confirmed</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">openAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ConfirmDialog</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isOpen}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onConfirm</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p>筆者が良いインターフェースだと思った点は三つある。</p>
<p><strong>何が開くのかが呼び出し側にある。</strong> 筆者が作ったほうは <code>'ADD_NOTE'</code> という文字列キーを渡す。そのキーがどのコンポーネントなのかは registry ファイルが知っている。文字列キーと、そのモーダルを読み込む <code>import()</code> 関数を組にしたオブジェクトがひとつ入っているファイルだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> modalImporters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../add-note-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  CONFIRM</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../confirm-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 모달 종류만큼 이어진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>overlay-kit は JSX をその場に書く。コードを読む人は、このファイルへ移動しなくても何が出るのかが分かる。</p>
<p><strong>結果の型が呼び出し側で決まる。</strong> <code>openAsync&#x3C;boolean></code> の型引数がそのまま <code>close</code> の引数型であり、<code>await</code> の結果型になる。モーダルごとに結果の型を宣言して表に登録する必要がない。</p>
<p><strong>閉じることと消すことが分かれている。</strong> コントローラーが受け取る props は <code>overlayId</code>、<code>isOpen</code>、<code>close</code>、<code>unmount</code> の四つだ。<code>close</code> は <code>isOpen</code> を <code>false</code> にして、コンポーネントはそのまま残す。実際に取り除くのは <code>unmount</code> である。<a href="https://overlay-kit.slash.page/ko/docs/guides/introduction" target="_blank" rel="noopener noreferrer">公式ドキュメントのオーバーレイの開閉についての案内</a>はこう説明している。</p>
<blockquote>
<p>このような違いが生じるのは、<code>close</code> を使うときにオーバーレイが閉じるアニメーションを見せるため、メモリに残し続けているからです。</p>
</blockquote>
<p>筆者が作ったほうにはこの区別がない。閉じれば一覧からすぐ外れる。つまり閉じるアニメーションをコンポーネントライブラリ側に任せていたということであり、それが可能な画面だけを扱っていたということでもある。</p>
<h2 id="overlayopen-からレンダーまで"><a class="anchor" href="#overlayopen-からレンダーまで">overlay.open からレンダーまで</a></h2>
<p>インターフェースが気に入ったら、次に気になるのは中身だ。ひとつのモジュール関数が React ツリーの中にコンポーネントを描くには、その間をつなぐ何かが必要になる。</p>
<h3 id="イベントと-reducer"><a class="anchor" href="#イベントと-reducer">イベントと reducer</a></h3>
<p><code>overlay.open</code> は状態を変えない。イベントをひとつ飛ばす。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlayId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> options?.overlayId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> componentKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dispatchOpenEvent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  dispatchOpenEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ controller, overlayId, componentKey });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlayId;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>受け取る側は <code>OverlayProvider</code> だ。Provider は <code>useReducer</code> を持っていて、イベントを購読して reducer に <code>ADD</code> を入れる。ここで注意して見るべきなのは、<code>isOpen</code> が <code>false</code> で入るという点である。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/index.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useReducer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(overlayReducer, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  current: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayOrderList: [],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayData: {},</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">componentKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    overlay: { id: overlayId, componentKey, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, isMounted: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, controller },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, []);</span></span></code></pre></figure>
<p>そしてコントローラーがマウントされた次のフレームに <code>OPEN</code> を入れて <code>isOpen</code> を <code>true</code> にする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/content-overlay-controller.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'OPEN'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, overlayId });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [overlayDispatch, overlayId]);</span></span></code></pre></figure>
<p>ここで筆者の予想と違った部分がある。</p>
<p><strong>状態が React の外にない。</strong> モジュール関数で開く API なのでストアもモジュールレベルにあるだろうと思っていたが、実際には Provider の中の <code>useReducer</code> だった。React の外にあるのはイベントバスだけで、それは状態を持たない。オーバーレイは Provider の <code>children</code> の隣に兄弟としてレンダーされる。<code>createPortal</code> を使わず、<code>react-dom</code> を import もしない。</p>
<p>もうひとつ目に留まることがある。呼び出し側から見れば <code>overlay.open()</code> の一行だが、その中はイベントを飛ばし、reducer に <code>ADD</code> を入れ、一フレーム待ってから <code>OPEN</code> をもう一度入れるという順序の決まった手続きだ。宣言的なインターフェースは命令型のコードをなくしたのではなく、境界の内側へ移したのである。</p>
<h3 id="開く側と閉じる側"><a class="anchor" href="#開く側と閉じる側">開く側と閉じる側</a></h3>
<p>先ほど見た <code>requestAnimationFrame</code> と、その前の節で見た <code>close</code> と <code>unmount</code> の分離は、一見すると別物に見える。実際には同じ制約の両端をそれぞれ塞ぐ仕掛けだ。</p>
<p>その制約とは、CSS トランジションが<strong>開始状態と終了状態の両方を実際にレンダーしないと動かない</strong>ということである。<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions" target="_blank" rel="noopener noreferrer">MDN の CSS トランジションのドキュメント</a>は、DOM に追加したばかりの要素についてこう警告している。</p>
<blockquote>
<p>This is treated as if the initial state had never occurred and the element was always in its final state.</p>
</blockquote>
<p>ここで失敗が二つの形で現れる。開くときは開始状態がないので動かず、閉じるときは終了状態が描かれる前に要素が消えるので動かない。</p>
<table>
<thead>
<tr>
<th></th>
<th>開く側</th>
<th>閉じる側</th>
</tr>
</thead>
<tbody>
<tr>
<td>問題</td>
<td>開始状態がない</td>
<td>終了状態を描く前に消える</td>
</tr>
<tr>
<td>解法</td>
<td>閉じたまま先にマウントし、次のフレームで開く</td>
<td>開いたまま残して <code>isOpen</code> だけ切り、あとで取り除く</td>
</tr>
<tr>
<td>仕掛け</td>
<td><code>ADD(isOpen: false)</code>、rAF、<code>OPEN</code></td>
<td><code>CLOSE</code>、アニメーション、<code>REMOVE</code></td>
</tr>
<tr>
<td>タイミングを知っている側</td>
<td>ライブラリ</td>
<td>利用者</td>
</tr>
</tbody>
</table>
<p>閉じる側が利用者の担当になる理由は最後の行にある。開く側は一フレームで足りるという定数をライブラリが知っている。閉じる側は、アニメーションが200ミリ秒なのか400ミリ秒なのかをライブラリは知りようがない。</p>
<h4 id="unmount-に渡された閉じる側"><a class="anchor" href="#unmount-に渡された閉じる側"><code>unmount</code> に渡された閉じる側</a></h4>
<p>閉じる側が成り立つ根拠は reducer にある。<code>CLOSE</code> は <code>overlayData</code> の <code>isOpen</code> だけを反転させ、<code>overlayOrderList</code> には触れない。Provider はその一覧をたどってレンダーするので、一覧に残っているあいだコンポーネントは生きている。<code>REMOVE</code> が一覧から外して初めてアンマウントされる。</p>
<p><strong>ただし rAF が常に必要なわけではない。</strong> react-transition-group は<a href="https://reactcommunity.org/react-transition-group/transition" target="_blank" rel="noopener noreferrer">既定では初回マウントで enter トランジションを実行しない</a>。<code>in</code> がすでに <code>true</code> でも飛ばしてしまい、<code>appear</code> を有効にする必要がある。overlay-kit の rAF は <code>isOpen</code> をマウント後に <code>false</code> から <code>true</code> へ変えてくれるので、この場合を覆う。</p>
<h3 id="promise-で包む20行"><a class="anchor" href="#promise-で包む20行">Promise で包む20行</a></h3>
<p><code>openAsync</code> は別実装ではなく、<code>open</code> を包んだ薄い層だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openAsync</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deprecatedLegacyContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">param</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(param);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> reject</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reason</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(reason);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlayProps, close, reject };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(props, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">deprecatedLegacyContext);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>コントローラーに渡る <code>close</code> を差し替えているのがすべてだ。もともとの <code>close</code> は引数を取らないが、ここでは引数を取って Promise を resolve したあとに元の <code>close</code> を呼ぶ。結果の型が呼び出し側で決まる理由もここにある。<code>T</code> が <code>close</code> の引数型であり、Promise の型でもある。</p>
<p>ただしこの構造には穴がひとつある。resolve は <code>close</code> が呼ばれたときにしか起きない。そのため、オーバーレイを <code>unmount</code> だけで取り除くと Promise は永遠に pending のまま残る。<a href="https://github.com/toss/overlay-kit/issues/169" target="_blank" rel="noopener noreferrer">issue #169</a> に挙がっており、解決が必要だと考えているが、まだ issue はオープンのままだ。</p>
<p>resolve のタイミングが <code>close</code> だという点は、別の意味も持つ。閉じるアニメーションが終わった時点ではなく、閉じ始める時点だということだ。確認を取って API を呼ぶ流れなら問題ないが、確認を取って次のモーダルを開く流れでは、前のモーダルが消えていくあいだに次のモーダルが上がってくる。</p>
<h3 id="設計が一周した場所"><a class="anchor" href="#設計が一周した場所">設計が一周した場所</a></h3>
<p>設計の過程をたどるためにコミット履歴を追ってみた。そして、この構造が最初からこうだったわけではないと分かった。1.0 は今と同じイベントと reducer の組み合わせだった。1.2.0 で状態をモジュールレベルの外部ストアへ移して <code>useSyncExternalStore</code> で読むように変え、1.8.0 で再び reducer に戻した。</p>
<p>戻した理由は <a href="https://github.com/toss/overlay-kit/issues/148" target="_blank" rel="noopener noreferrer">issue #148</a> に書かれている。</p>
<blockquote>
<p><code>useSyncExternalStore</code> resolves tearing issues, but it doesn't work well with suspense.</p>
</blockquote>
<p>オーバーレイの中でデータを取得して suspend すると、同期的な入力に応答している最中にコンポーネントが suspend したという React の警告が出た。外部ストアへ移した本来の目的はフックなしでもオーバーレイの状態を読めるようにすることだったが、意図したとおりには解決できなかったと当時の PR 本文に書かれている。結局、目的はフックで解決され、保存方式だけが元の場所に戻った。</p>
<p>筆者がこの記録から得たのは結論ではなく基準だ。状態を React の外に置けば呼び出し地点は自由になるが、その代わり React のスケジューリングとずれる余地が生まれる。overlay-kit は呼び出し地点だけを外に置き、状態は中に置いた。</p>
<h2 id="同じ-id-で開き直したとき"><a class="anchor" href="#同じ-id-で開き直したとき">同じ id で開き直したとき</a></h2>
<p>reducer を読んでいて引っかかった箇所があった。<code>ADD</code> の先頭にこんな分岐がある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/reducer.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">case</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] != </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">].isOpen === </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlay</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.overlayData[action.overlay.id];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (overlay </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.isOpen) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      current: action.overlay.id,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      overlayData: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayData, [action.overlay.id]: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span></code></pre></figure>
<p><code>overlayId</code> を指定して開いたオーバーレイを <code>close</code> したあと、同じ id で開き直すとこの分岐に入る。ところが戻り値が<strong>既存の <code>overlay</code> オブジェクト</strong>をそのまま使っている。</p>
<p>新しく入ってきた <code>action.overlay</code> の <code>controller</code> はどこにも使われていない。</p>
<p>読んだだけでは確信が持てなかったので、ライブラリのリポジトリを取得してテストを自分で付けてみた。異なる内容を描くコンポーネントを二つ用意し、同じ id で順に開いた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerA</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>AAA&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerB</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>BBB&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerA, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerB, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span></code></pre></figure>
<p>結果はこうだった。overlay-kit 1.9.0 時点の main ブランチで vitest を使って実行した。</p>
<table>
<thead>
<tr>
<th>手順</th>
<th>レンダーされたもの</th>
</tr>
</thead>
<tbody>
<tr>
<td>A を開いて <code>close</code> のあと B で開き直す</td>
<td><code>AAA</code>。B が捨てられる</td>
</tr>
<tr>
<td>A を開いて <code>unmount</code> のあと B で開き直す</td>
<td><code>BBB</code>。正しく置き換わる</td>
</tr>
<tr>
<td>カウンターを1増やして <code>close</code> のあと開き直す</td>
<td><code>1</code>。コンポーネントの状態が保たれる</td>
</tr>
</tbody>
</table>
<p>三行目はドキュメントが機能として説明している挙動なので予想どおりだ。問題は一行目である。</p>
<h3 id="無視される新しい-jsx"><a class="anchor" href="#無視される新しい-jsx">無視される新しい JSX</a></h3>
<p>ここでもうひとつ確認した。同じ reducer の後半にはこんなコメントが付いている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/**</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> * </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">@description</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> Brings the overlay to the front when reopened after closing without unmounting.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> */</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">overlayOrderList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayOrderList.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">item</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> item </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> action.overlay.id), action.overlay.id],</span></span></code></pre></figure>
<p>閉じたあとに開き直すと最前面へ持ってくる、という説明だ。ところが手前の分岐が先に <code>return</code> するため、この行はその状況では実行されない。オーバーレイを二つ重ねて開き、下に敷かれたほうを <code>close</code> してから開き直してみたところ、DOM の順序は変わらずそのままだった。</p>
<h3 id="コメントと違う実際の挙動"><a class="anchor" href="#コメントと違う実際の挙動">コメントと違う実際の挙動</a></h3>
<p>ただし、これが画面上ですぐ問題として見えるかどうかは確認していない。テストは DOM の順序までしか見ておらず、多くのダイアログライブラリは独自の z-index やポータルを使うので、DOM の順序がそのまま視覚的な順序になるわけではない。</p>
<p>筆者が作ったほうは同じ状況をオプションとして公開している。同じ種類のモーダルを開き直すとき、既存のものを保つか新しい props で置き換えるかを呼び出し側が選ぶ。置き換えを選べば既存の Promise は <code>replaced</code> で終了する。先ほど結果の型を五つに分けた理由のひとつがこれである。</p>
<p><strong>ここで二つの設計の違いが現れる。</strong> 筆者の方式はモーダルをデータとして扱う。文字列キーと props がストアにあるので、あとから置き換えたり更新したりできる。overlay-kit はモーダルを関数として扱う。コントローラーがクロージャなので、一度保存されると外からその中身を変える方法がない。</p>
<h2 id="呼び出し側に-jsx-を書く代償"><a class="anchor" href="#呼び出し側に-jsx-を書く代償">呼び出し側に JSX を書く代償</a></h2>
<p>では、呼び出し側に JSX を書く方式は文字列キー方式より常に優れているのだろうか。筆者はそうは思わない。支払う代償が三つある。</p>
<p><strong>コード分割が既定から外れる。</strong> 呼び出し側が JSX を書くということは、呼び出し側がそのコンポーネントを import するということだ。モーダルが重い編集画面で、そのボタンを押すユーザーが少数なら、その重さが一覧画面のバンドルに入る。</p>
<p>コントローラーの中で <code>React.lazy</code> は動作する。先ほどのテストと同じやり方で測ってみたところ、<code>open</code> の前にはモジュールが読み込まれず、開いたときに <code>Suspense</code> の fallback が先に出てから本体がレンダーされた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Heavy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lazy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'./HeavyModal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}>&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Heavy</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClose</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{close} />&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>つまり違いは可否ではなく既定値だ。</p>
<p>筆者が作ったほうは registry ファイルの値がすべて <code>import()</code> 関数なので、コード分割が構造上強制される。overlay-kit は呼び出し側ごとに手で書く必要がある。</p>
<p><strong><code>unmount</code> を呼ぶ場所を利用者が決めなければならない。</strong> <code>close</code> だけ呼んで終えると、オーバーレイの情報がメモリに残る。公式ドキュメントもこの点を警告しながら、デザインシステムごとにどこへ <code>unmount</code> を掛けるべきかを個別に案内している。MUI と Mantine には遷移が終わるコールバックがあるのでそこへ掛ければよいが、そうしたコールバックがないライブラリではアニメーションの長さぶんタイマーを置くよう案内している。案内文書がライブラリごとに分かれていること自体が、この決定のコストである。</p>
<p><strong>オーバーレイが Provider の位置でレンダーされる。</strong> ポータルを使わないので、React Native でも同じコードが動くという利点がある。逆に <code>OverlayProvider</code> の下で提供したコンテキストは、オーバーレイの中からは見えない。ルーターやフォームのコンテキストに依存するモーダルを開くときは、この点を先に確認する必要がある。</p>
<h2 id="宣言的という言葉の範囲"><a class="anchor" href="#宣言的という言葉の範囲">宣言的という言葉の範囲</a></h2>
<p>ここまで書いてきて、ひとつ引っかかることがある。筆者はこのインターフェースを宣言的と呼んで記事を始めたが、<code>overlay.openAsync(...)</code> はどう見ても関数呼び出しだ。</p>
<p>まず定義から置いてみよう。<a href="https://react.dev/learn/reacting-to-input-with-state" target="_blank" rel="noopener noreferrer">React 公式ドキュメント</a>は二つをこう分けている。</p>
<blockquote>
<p>In React, you don't directly manipulate the UI--meaning you don't enable, disable, show, or hide components directly. Instead, you <strong>declare what you want to show,</strong> and React figures out how to update the UI.</p>
</blockquote>
<p>同じドキュメントが挙げる比喩のほうが鮮明だ。命令型は車に乗った人に道をひとつずつ伝えることであり、宣言的なほうはタクシーに乗って目的地だけを告げることである。</p>
<p><strong>宣言的なインターフェースは命令型の手続きをなくすのではなく、境界の反対側へ移してその境界に名前を付けることだ。</strong> 先ほど見た overlay-kit の内部がまさにそうである。呼び出し側は一行なのに、中ではイベントを飛ばして二回 dispatch する手続きが回る。</p>
<p>では、各プロジェクトがこの言葉をどこに使っているのかを見てみよう。overlay-kit のドキュメントは従来の方式を命令型と呼ぶ。そのとき指しているのは、<code>useState</code> で開閉フラグを持ち、イベントハンドラーでその値を反転させるコードだ。逆に宣言的と呼んでいるのは、オーバーレイを状態ではなく振る舞いとして扱う側である。toss 技術ブログの<a href="https://toss.tech/article/frontend-declarative-code" target="_blank" rel="noopener noreferrer">宣言的なコードについての記事</a>は、この言葉をより広く定義している。宣言的なコードとは抽象度が上がったコードであり、オーバーレイを表示する振る舞いを抽象化したフックがその例だというものだ。</p>
<p>そこで筆者はこの言葉をこう狭めて使うことにした。<strong>呼び出し側が閉じるコードを書かないこと。</strong> いつ閉じるか、閉じるあいだ何が残っているかはオーバーレイ側が決め、呼び出し側は何を表示するかと、結果として何を受け取るかだけを書く。</p>
<p>この基準で見れば、<code>useState</code> で <code>isOpen</code> を持っているコードと、グローバルストアに開いている一覧を置いて呼び出し側が閉じるコードは同じ側にある。保存場所が違うだけで、閉じる文を呼び出し側が書いている点は同じだからだ。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>筆者が会社のコードで直したものは、結局のところ保存場所ではなかった。閉じるコードを誰が書くか、だった。</p>
<p>グローバルストアを使っていた時代にも状態はすでにコンポーネントの外にあったが、開閉の文は依然として呼び出し側にあった。<code>await</code> 一行に変えて初めて、その文がモーダルの中へ入った。</p>
<p>overlay-kit は同じ場所にたどり着いたうえで、さらに一歩進んだ。何を表示するかまで呼び出し側に書かせたのだ。筆者はそのインターフェースが読みやすいと思う。そして書きながら、その一歩の代償が何なのかも分かった。</p>
<p><strong>モーダルを関数として扱うか、データとして扱うかの違いだった。</strong> 呼び出し側に JSX を書くとコントローラーはクロージャになり、クロージャは一度保存されると外から中身を変えられない。同じ id で開き直したときに新しい JSX が無視されるのは、その構造から生まれる。文字列キーで扱えばモーダルはストアに収まった値になり、置き換えも更新もコード分割もできるが、呼び出し側は名前しか知らなくなる。</p>
<p>ライブラリを選ぶとき、インターフェースが気に入るという感覚はかなり信頼できる合図だと思う。ただし、その感覚が正確に何を指しているのかを一度は言葉にしてみるべきだ。言葉にしてみなければ、気に入ったのがインターフェースなのか、そのライブラリを使う人たちの評判なのかを区別できない。この記事を読む読者の方々にも、いま使っているライブラリについて一度その作業をしてみることをお勧めしたい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://overlay-kit.slash.page/" target="_blank" rel="noopener noreferrer">overlay-kit 公式ドキュメント</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/desko27/react-call" target="_blank" rel="noopener noreferrer">desko27/react-call</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>ライブラリ</category>
        </item>
        <item>
            <title><![CDATA[LLMのキャリブレーションと過信]]></title>
            <link>https://hooninedev.com/ja/260917</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260917</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、モデルのキャリブレーションと、RLHF の後に現れる過信について話してみたい。モデルが返す確率や確信度を、コードの中で判断基準として使いたい開発者のための記事だ。最後まで読めば、正解率とキャリブレーションがどう違うのか、ECE が何を測るのか、そして人の選好で仕上げたモデルの確率が実際の的中率とどうずれるのか、その原因がどこまで明らかになっているのかを説明できるようになる。 筆者は...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、モデルのキャリブレーションと、RLHF の後に現れる過信について話してみたい。モデルが返す確率や確信度を、コードの中で判断基準として使いたい開発者のための記事だ。最後まで読めば、正解率とキャリブレーションがどう違うのか、ECE が何を測るのか、そして人の選好で仕上げたモデルの確率が実際の的中率とどうずれるのか、その原因がどこまで明らかになっているのかを説明できるようになる。</p>
<p>筆者は TypeSafe AI が公開した決定モデル Jev の主張を検証する中で、まずこの概念を整理する必要があった。Jev は文章の代わりに選択肢ごとの確率を返し、キャリブレーションを学習目標に掲げている。その主張を検討するには、キャリブレーションとは何か、既存のモデルでなぜずれるのかを先に知る必要があった。</p>
<h2 id="正解率とキャリブレーション"><a class="anchor" href="#正解率とキャリブレーション">正解率とキャリブレーション</a></h2>
<p>まず正解率と<span class="glossary-term-source" data-glossary-key="calibration">キャリブレーション</span>を区別しなければならない。正解率は何パーセント当てるかであり、キャリブレーションは自分が何パーセント当てるかを知っているかどうかだ。降水確率70%と言った日だけを集めたとき、実際に10回のうち7回雨が降ったなら、その予報はキャリブレーションがよく取れている。正解率が高いという意味ではない。自分の限界を知っているという意味だ。<strong>60%しか当てないモデルでも、自分で60%だと言えばキャリブレーションは満点である。</strong></p>
<p>目盛りがずれる方向は二つある。モデルが言った確率が実際の的中率より高ければ overconfidence（過信）、低ければ underconfidence と呼ぶ。言った確率を横軸に、その確率区間の実際の的中率を縦軸に置くと、二つが分かれる場所がひと目で見える。こうして描いた図を reliability diagram と呼ぶ。</p>
<p><picture><source type="image/webp" srcset="/content/260917/1.720w.webp 720w, /content/260917/1.1440w.webp 1440w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260917/1.png" alt="横軸はモデルが言った確率である confidence、縦軸は実際の的中率である accuracy。点線の対角線が perfect calibration で、対角線の下に垂れたオレンジ色の曲線が overconfidence、対角線より上の領域が underconfidence" style="max-width:min(720px, 100%);height:auto;" width="1440" height="1200" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>曲線が対角線の上に乗っていれば、言ったとおりに当てたということだ。対角線の下に垂れるほど、言った確率に比べて当たらない。</p>
<p>そのズレを測る指標が <span class="glossary-term-source" data-glossary-key="ece">ECE</span>（expected calibration error）だ。<a href="https://arxiv.org/abs/1706.04599" target="_blank" rel="noopener noreferrer">Guo et al. 2017</a> の定義に従えば、確率の区間ごとに「言った確率」と「実際の的中率」の差を、その区間の標本比率で重み付けして平均した値で、0が完璧である。下はその式を写したコードだ。10問中6問を当てるモデルが、毎回0.6と言う場合と毎回0.95と言う場合を比べる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Guo et al. 2017 의 식 (3). 구간은 등간격 10개(논문 실험은 15개), 경계는 왼쪽 닫힘</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">preds</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bins</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ n: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, hit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, conf: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">correct</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins[Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> correct </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> p</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">abs</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 10문제 중 6문제를 맞힌다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> answers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ correct: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.6 이라고 말할 때</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.95</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.95 라고 말할 때</span></span></code></pre></figure>
<p>2026-10-08 に Node v24.16.0 で実行した結果だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>0.000</span></span>
<span data-line=""><span>0.350</span></span></code></pre></figure>
<p>正解率はどちらも0.6だ。言った確率が変わっただけなのに、ECE は0から0.35に上がる。ただし式に絶対値が入っているので、ECE はズレの大きさだけを測り、方向はわからない。毎回0.25と言いながら6問当てても、ECE はまったく同じ0.35になる。overconfidence なのか underconfidence なのかは、reliability diagram か、平均確信度から正解率を引いた値を見なければわからない。</p>
<h2 id="rlhfの目的関数"><a class="anchor" href="#rlhfの目的関数">RLHFの目的関数</a></h2>
<p>では、人のフィードバックで仕上げたモデルは、なぜこの目盛りがずれるのだろうか。まず <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span>（reinforcement learning from human feedback）が何を最適化しているのかを見る必要がある。人の選好比較から報酬モデルを立てる骨格は <a href="https://arxiv.org/abs/1706.03741" target="_blank" rel="noopener noreferrer">Christiano et al. 2017</a> で生まれ、これを言語モデルに移した初期の仕事が <a href="https://arxiv.org/abs/1909.08593" target="_blank" rel="noopener noreferrer">Ziegler et al. 2019</a> で、<a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT</a> が指示追従に適用した。このとき押し上げる値は、<strong>人の選好を真似た報酬モデルの点数</strong>だ。言語モデルでの仕事は、強化学習を始める前のモデルから離れすぎないよう引き留める KL penalty を報酬に加え、InstructGPT は事前学習データの勾配も混ぜたが（PPO-ptx）、押し上げる対象はやはりその点数である。</p>
<p>チャットボットにとって、人の選好は正しい目標だ。問題は、その選好が不確実さを表に出した口調を避ける側に傾いていることにある。<a href="https://arxiv.org/abs/2401.06730" target="_blank" rel="noopener noreferrer">Zhou et al. 2024</a> は、四つの公開選好データセットで、アノテーターが "I'm not sure, maybe" のような弱める表現を含む答えをあまり選ばないことを示した。差は小さいが有意だった。ただし、確信を強める表現を含む答えをより多く選んだわけではない。<a href="https://arxiv.org/abs/2410.09724" target="_blank" rel="noopener noreferrer">Leng et al. 2025</a> は、報酬モデルが答えの実際の品質と関係なく、高い確信度の数値を書いた答えに高い点数を与えることを示した。どちらもトークン確率ではなく、文章の中の確信表現（verbalized confidence）についての結果だ。RLHF に代わる学習法を打ち出した TypeSafe も、自社の<a href="https://docs.typesafe.ai/introduction/machine-learning-primer" target="_blank" rel="noopener noreferrer">入門ドキュメント</a>で、RLHF が自信ありげに聞こえるハルシネーション（confident-sounding hallucinations）に報酬を与えうると書いている。代替案を売る側の立場である。</p>
<h2 id="gpt-4のキャリブレーション曲線"><a class="anchor" href="#gpt-4のキャリブレーション曲線">GPT-4のキャリブレーション曲線</a></h2>
<p>トークン確率でも同じ方向のズレが観測されている。<a href="https://arxiv.org/pdf/2303.08774v6#page=12" target="_blank" rel="noopener noreferrer">GPT-4 技術レポート</a>本文の Figure 8 は、事前学習モデルと post-training モデルの reliability diagram を並べて置いているが、そのキャプションがこうだ。</p>
<blockquote>
<p>Right: Calibration plot of the post-trained GPT-4 model on the same subset of MMLU. The post-training hurts calibration significantly.</p>
</blockquote>
<p>横軸は MMLU の多肢選択問題の選択肢 A、B、C、D それぞれにモデルが付けた確率（logprob）で、縦軸はその区間の実際の的中率だ。Figure 8 に刻まれた数字では、事前学習モデルの ECE が <strong>0.007</strong> で、PPO（RLHF で報酬モデルの点数が上がる方向にモデルを更新するときに使う強化学習アルゴリズム）を経たモデルが <strong>0.074</strong> だ。10倍以上悪くなった。</p>
<p>Figure 8 の右（PPO）のパネルでは、0.4以上の区間の棒は対角線の下にあるが、0.1から0.3の区間はむしろ対角線の上だ。0.1から0.9の棒は実際の的中率およそ0.3から0.5に平らに集まっていて、モデルが言う確率が上がっても的中率はほとんどついてこない。0.074 にはこの underconfidence の区間も混ざっている。そして Guo et al. が指摘したように、reliability diagram は区間ごとの標本数を示さないので、棒が対角線から離れた距離と、その区間が ECE に足した分は比例するとは言えない。</p>
<p>このパネルは上の概念図と二つの点で異なる。まず、概念図の曲線のように対角線の下に垂れるだけでなく、対角線を一度横切る。また、選択肢四つの確率をすべて区間に入れているので、上のコードのように選んだ答え一つの確率で測る定義とも異なる。だから 0.074 と上のコードの 0.35 を同じ目盛りに置いて比べはしない。</p>
<p>では、なぜ悪くなったのか。レポート本文は、事前学習モデルはよくキャリブレーションされていて、post-training の後にキャリブレーションが下がったとだけ書き、原因は述べていない。手がかりは Anthropic の <a href="https://arxiv.org/abs/2207.05221" target="_blank" rel="noopener noreferrer">Kadavath et al. 2022</a> にある。彼らが自社の言語モデルから学習させた RLHF ポリシーは、見かけ上キャリブレーションがとても悪かった。論文はこれを驚くことではないとし、RL fine-tuning が最も多くの報酬を受ける振る舞いのほうへ予測を寄せる傾向があるからだと書いた。本題ではなく、短く添えた実験である。ところが、すべての評価に同じ温度 T=2.5 を一つ適用すると、三つの評価でキャリブレーションの問題がおおむね解消された。温度は logit を割る値で、1より大きければ答えの順序はそのままに確率分布だけを平らに広げる。筆者は、値一つでおおむね解消されたという結果を、ズレが一部の区間に集まったのではなく、分布全体が一方に狭まった形だという意味に読む。論文は、より強い RL 学習はこのやり方では直せない形でキャリブレーションを損なうかもしれない、という但し書きも添えている。</p>
<p>これは Anthropic のモデルの結果なので、Figure 8 の原因としてそのまま持ち込むことはできない。前の節の研究も文章の中の確信表現を扱っている。人が不確かな口調を避けるという結果とトークン確率のズレを直接つなぐ一次資料を、筆者は見つけられなかった。</p>
<h2 id="コードが受け取る確率"><a class="anchor" href="#コードが受け取る確率">コードが受け取る確率</a></h2>
<p>Figure 8 の確率はトークンの対数確率だ。同じ種類の値を受け取るには、<a href="https://github.com/openai/openai-openapi/blob/506aff0a8099581b50e119b87f8f2692cdad043f/openapi.yaml" target="_blank" rel="noopener noreferrer">OpenAI API の仕様</a>どおり Chat Completions のリクエストで <code>logprobs</code> を <code>true</code> にし、<code>top_logprobs</code> でトークン位置ごとに受け取る候補の数を0から20のあいだで決める。モデルに答えと一緒に確信度を数字で言わせて受け取る値は、文章で述べた確信であり、トークン確率とは別の値だ。</p>
<p>二つの値を扱った研究は、それぞれ異なる組を比べている。<a href="https://arxiv.org/abs/2305.14975" target="_blank" rel="noopener noreferrer">Tian et al. 2023</a> は、ChatGPT、GPT-4、Claude のような RLHF モデルの中で、文章で述べた確信が条件付き確率よりおおむねキャリブレーションがよく、三つのベンチマークで ECE を相対的に50%ほど減らした場合が多かったと報告した。ただしこの比較で、重みが公開されていないモデルの条件付き確率は、API のトークン確率ではなく、同じ質問を10回サンプリングして答えが出た割合で推定した値である。先に見た Leng et al. は、RLHF 以前のモデルと比べて、RLHF モデルは文章で述べる確信でより overconfident だと報告した。二つの結果は両立しうる。そして 0.074 は2023年の GPT-4 post-training モデルが MMLU の一部で出した値なので、いま API で呼んでいるモデルにそのまま当てはめることはできない。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、キャリブレーションは何パーセント当てるかではなく、自分が何パーセント当てるかを知っているかの問題であり、ECE はそのズレの大きさを測る数字だ。方向は reliability diagram か、平均確信度から正解率を引いた値を見なければわからない。RLHF は人の選好を真似た報酬モデルの点数を押し上げる。Kadavath et al. は、その最適化が多くの報酬を受ける側へ予測を狭める傾向があると見ている。人は不確実さを表に出した答えをあまり選ばず、報酬モデルは品質と関係なく高い確信度の数値を書いた答えに高い点数を与えるという研究がある。GPT-4 では post-training の後に ECE が 0.007 から 0.074 として刻まれたが、OpenAI はその原因までは書いていない。コードが受け取る確率がトークン確率なのか文章で述べた確信なのかによってキャリブレーションも違って出るので、どちらにしても自分のデータでもう一度測る必要がある。</p>
<p>キャリブレーションを学習目標に掲げた決定モデル Jev の確率が本当に正直なのかは、<a href="/260922" target="_blank" rel="noopener noreferrer">決定モデル、Jev と Kev</a>で扱う。</p>
<p>この記事を読んでいる皆さんも、モデルが返した確率をしきい値としてコードに埋め込む前に、そのモデルが何を目標に学習されたのか、自分のデータで目盛りが合っているのかを一度は測ってみてほしい。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>キャリブレーション</category>
        </item>
        <item>
            <title><![CDATA[共有言語]]></title>
            <link>https://hooninedev.com/ja/260723</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260723</guid>
            <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、他職種とのコミュニケーションについて書いてみたい。 開発をしていて、私がコードを書くのと同じくらい時間を割いているのは、何を作るべきかを突き止める作業だ。企画者と要件をやり取りし、デザイナーと画面をすり合わせ、ドメインを知る人に用語が何を指すのかを尋ねる。そして、この過程がずれたときのコストがコードを書き間違えたコストよりはるかに大きいことを、何度か経験してきた。 最近、James Sam...]]></description>
            <content:encoded><![CDATA[<p>今回は、他職種とのコミュニケーションについて書いてみたい。</p>
<p>開発をしていて、私がコードを書くのと同じくらい時間を割いているのは、何を作るべきかを突き止める作業だ。企画者と要件をやり取りし、デザイナーと画面をすり合わせ、ドメインを知る人に用語が何を指すのかを尋ねる。そして、この過程がずれたときのコストがコードを書き間違えたコストよりはるかに大きいことを、何度か経験してきた。</p>
<p>最近、James Samuelが書いた<a href="https://softwareleads.substack.com/p/engineering-leaders-day-to-day-activities" target="_blank" rel="noopener noreferrer">エンジニアリングリーダーの日常業務</a>という記事を読んだ。著者はリーダーの仕事を六つに分けるのだが、最初に扱っているのが情報収集だった。あらゆる決定と方向と行動が、いま何が起きているのかについての正確な理解に依存するからだという。</p>
<p><picture><source type="image/webp" srcset="/content/260723/1.720w.webp 720w, /content/260723/1.1440w.webp 1440w, /content/260723/1.1951w.webp 1951w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/1.png" alt="管理者の活動を 6 つの円で表した図。情報収集、計画、リーディング、意思決定への影響、意思決定、情報共有がつながっている" width="1951" height="1673" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>私はいつかリーダーの役割を担いたいと考えているが、この箇所を読んで浮かんだのは少し違うことだった。<strong>リーダーが最初に扱う仕事が情報収集なら、実務者にとってその対応物は要件の把握だ</strong>。いま要件を扱っているやり方が、のちに組織の情報を扱うやり方になる。</p>
<p>この記事でリーダーシップの話をしたいわけではない。いま実務者として他職種とコミュニケーションを取るとき、何を間違えていて、何を道具として使えるのかという話だ。結論から言うと、私はこの問題が態度だけの問題ではないと考えるようになった。代わりに使える表現としてubiquitous languageというものがあるので、それが何なのかを見ていこう。</p>
<h2 id="同じ単語違う意味"><a class="anchor" href="#同じ単語違う意味">同じ単語、違う意味</a></h2>
<p>Martin Fowlerは<a href="https://martinfowler.com/bliki/BoundedContext.html" target="_blank" rel="noopener noreferrer">Bounded Context</a>という記事で電力会社の事例を挙げている。私が経験したずれのかなりの部分は、この形だった。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>ここで「meter」という単語は、組織の異なる部分で微妙に違う意味を持っていた。送電網とある場所の接続なのか、送電網とある顧客の接続なのか、それとも物理的な計器そのものなのか。</p></div><div class="quote-original" lang="en"><p>here the word 'meter' meant subtly different things to different parts of the organization: was it the connection between the grid and a location, the grid and a customer, the physical meter itself</p></div></blockquote>
<p>同じ「meter」という単語が、組織の部署ごとに微妙に違うものを指していたということだ。そしてFowlerは、こうした混乱が特定の事例における偶然ではないと付け加える。CustomerやProductのような多義語で同じ混乱が繰り返されるのを見てきた、というのだ。</p>
<p><strong>これは会議の参加者が会議下手だったから起きたことではない</strong>。組織に異なる文脈があれば同じ単語が分かれていくのは自然であり、それを放置すればコードまで分かれていく。</p>
<p>この話の技術的な背景については<a href="/260418" target="_blank" rel="noopener noreferrer">ドメインモデル</a>で一度扱った。あのときはコードの中でモデルをどう表現するかを論じた。この記事では、そのモデルが作られる前の、人と人との対話の段階を扱う。</p>
<h2 id="二つの意味を持つ単語"><a class="anchor" href="#二つの意味を持つ単語">二つの意味を持つ単語</a></h2>
<p>ではなぜ、こうしたことが構造的に繰り返されるのか。説明は二筋ある。</p>
<p>一つは組織構造の側だ。Mel Conwayが1968年にDatamationへ発表した論文<a href="https://melconway.com/Home/Committees_Paper.html" target="_blank" rel="noopener noreferrer">How Do Committees Invent?</a>は、組織が自らのコミュニケーション構造を柔軟に変えられない度合いに応じて、その組織は自分自身のイメージを、生み出すあらゆる設計物へ刻印すると述べている。一般にConwayの法則として引用される一文は、実は著者が後年みずから定式化した表現であり、1968年の本文にあるこの記述のほうが直接的だと感じる。</p>
<p>私がこの文から実務的に読み取ったのは「刻印する」という表現だ。用語が分かれることが対話で終わらず、<strong>成果物にそのまま残る</strong>という話だからである。先ほどの「meter」がそうだ。「meter」の範囲を互いに違う形で理解したまま各自が作れば、その差は議事録ではなく、テーブル名やAPIレスポンスのフィールドや画面の文言に残る。そしてそこからは、直すコストが会話をもう一度するコストとは比べものにならないほど大きくなる。</p>
<p>ここで実務者にできることとできないことが分かれる。組織のコミュニケーション構造を変えるのは実務者の手を離れている。しかし<strong>用語の境界を目に見えるようにすること</strong>は、いまの場所からでもできる。この文書で言う単語の意味がどの範囲なのかを書き留め、他のチームが使う単語と違うのなら違うと明示する作業だ。後で見る道具はすべて、その作業を楽にするための装置である。</p>
<p>もう一つは、もう少し根本的だ。認知科学の側から見れば、協働はそもそも共有された背景の上でしか成立しない。Herbert ClarkとSusan Brennanが1991年に書いた<a href="https://web.stanford.edu/~clark/1990s/Clark,%20H.H.%20_%20Brennan,%20S.E.%20_Grounding%20in%20communication_%201991.pdf" target="_blank" rel="noopener noreferrer">Grounding in Communication</a>は、ピアノのデュエットを例に始まる。二人の演奏者は膨大な量の共有情報、すなわちcommon groundを前提としなければ、内容を合わせることすら始められないというのだ。ここでのcommon groundは相互知識と相互信念、相互の前提を指す。そして著者らは、あらゆる集団行動がcommon groundとその蓄積の上に築かれると明言する。</p>
<p>私がこの二筋を並べて見たあとに得た視点はこうだ。<strong>用語がずれるのは事故ではなく、既定の状態である</strong>。合っている状態のほうが、維持コストを払わなければならない例外なのだ。ではそのコストを誰がどう払うのかが、実務の問いになる。</p>
<h2 id="積極的な監視"><a class="anchor" href="#積極的な監視">積極的な監視</a></h2>
<p>Evansは2003年に出したDomain-Driven Designで、ubiquitous language、日本語でいう<strong>共有言語</strong>というパターンを提示する。しばしば「developerとドメインエキスパートが同じ用語を使おう」という程度に要約されるが、原書を読むと処方ははるかに具体的だ。モデルを言語の骨格とし、チーム内のあらゆるコミュニケーションとコードでその言語を粘り強く使うとチームで約束せよと求める。そしてそれに続く文が、私にとって最も重要だった。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>ドメインエキスパートは、ドメインの理解を伝えるのに不自然または不十分な用語や構造に異議を唱え、developerは設計を破綻させる曖昧さや非一貫性に目を光らせる。</p></div><div class="quote-original" lang="en"><p>Domain experts object to terms or structures that are awkward or inadequate to convey domain understanding, while developers watch for ambiguity or inconsistency that will trip up design.</p></div></blockquote>
<p>ドメインエキスパートはドメインの理解を伝えるのに不自然だったり不足していたりする用語と構造に異議を唱え、<strong>developerは設計を破綻させる曖昧さや非一貫性を監視する</strong>ということだ。</p>
<p>この文を読んで、私は自分の役割についての理解を修正した。それまで私は、要件定義の会議で自分を受信者だと考えていた。企画が決まればそれを正確に受け取って実装するのが自分の仕事だと思っていた。ところがEvansが規定したdeveloperの役割は受信ではなかった。<strong>曖昧さを見つけて返すことだ</strong>。これは消極的な協力ではなく、積極的な監視である。</p>
<p>（ちなみにFowlerは自身の記事でこの文を「Domain experts <strong>should</strong> object ... developers <strong>should</strong> watch」と訳している。原書に助動詞はない。些細な差だが、原書のほうがより断定的に読める。そうするのが望ましいという勧告ではなく、そうすることがこのパターンの定義だという語調だ。）</p>
<p>では曖昧さはどうやって見つけるのか。Evansはここにも答えを与えている。対話で繰り返し使えば用語解釈の差が現れる、というのだ。私はこの文をこう読んだ。曖昧さは文書を精読して見つけるものではない。仕様書をどれだけ丁寧に読んでも、「meter」という単語が複数の意味を持つという事実は現れない。その単語を実際の事例に当てて繰り返し使ってみて初めて分かれる。「ほかの方はmeterをどんな意味で理解していますか？」といった質問が出る瞬間が、その地点だ。</p>
<h2 id="最小協力努力の原理"><a class="anchor" href="#最小協力努力の原理">最小協力努力の原理</a></h2>
<p>この方法を知っていても実践できない時期はあるだろう。では、なぜ繰り返し尋ねないのか。</p>
<p>もう一度ClarkとBrennanの論文に戻ろう。著者らは、語られたことをcommon groundの一部にしていく過程にgroundingという名前を与える。そして人々がこの過程でどう振る舞うかについて、一つの原理を提示する。最小協力努力の原理（the principle of least collaborative effort）だ。人は必要以上に懸命に働くことを好まない、という観察から出てきた原理である。</p>
<p>ここで重要な箇所が出てくる。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>最小協力努力の原理によれば、人々は必要なだけの共同努力でgroundingしようとするはずだ。しかし何に努力がかかるのかは、コミュニケーションの媒体によって劇的に変わる。</p></div><div class="quote-original" lang="en"><p>By the principle of least collaborative effort, people should try to ground with as little combined effort as needed. But what takes effort changes dramatically with the communication medium.</p></div></blockquote>
<p><strong>努力がかかる地点が媒体によって劇的に変わるということだ</strong>。ある媒体で使える確認手法が、別の媒体では端から不可能だったり、可能でもコストがはるかに大きかったりする。著者らは特に、話した瞬間に相手が受け取らない媒体では、他人が誤解を正してくれることを当てにするコストが非常に大きくなるため、話し手がそれを避けようとすると指摘する。</p>
<p>ここで一つ断っておくことがある。この論文は1991年に書かれ、著者らが比較した媒体は対面の会話や電話、手紙、留守番電話といったものだ。<strong>SlackやNotionは扱っていない</strong>。媒体によってgroundingコストが変わるという枠組みは著者らのものであり、それをいまの非同期な業務チャネルに適用するのは私の解釈である。</p>
<p>そう適用して見ると、私がなぜ繰り返し尋ねなかったのかが説明できる。仕様書をテキストで受け取ってテキストで確認する環境では、曖昧な箇所ごとに確認メッセージを送る作業はコストが大きい。答えがいつ来るか分からず、何度も尋ねると理解できていないように見えるのではないかと心配になる。だから人は自然と自分の解釈へ移っていく。</p>
<p>ここで力が二つあるという点を区別しておく必要がある。一つは媒体が確認コストを上げるということで、これはClarkとBrennanの話だ。もう一つは何度も尋ねると無能に見えるのではないかという心配で、これは彼らの原理が予測するものではなく、後で出てくる心理的安全性の側の話である。正確に言えば、最小協力努力の原理が予測するのは<strong>より安い確認方法を選ぶ</strong>であって、確認をやめるではない。確認を畳んで推測に移るのは原理の作動ではなく、groundingの失敗だ。そしてその解釈が間違っていたという事実は、実装が終わったあとに現れる。</p>
<h2 id="十分な水準への到達"><a class="anchor" href="#十分な水準への到達">十分な水準への到達</a></h2>
<p>では曖昧なものすべてについて尋ねなければならないのだろうか。それは現実的ではないし、私はそうする人を見たこともない。この問いについても論文は基準を提示している。grounding criterionだ。相手が話し手の意図を<strong>現在の目的に十分な水準まで</strong>理解したと双方が互いに信じている状態を指す。著者らはその前に、完璧な理解はそもそも不可能だという文を添えている。</p>
<p>私にとってこの基準が実用的に感じられた理由は、目標を下げてくれるからだ。要件を完璧に理解する必要はない。<strong>いまやろうとしている作業に十分なだけ互いに信じられればよい</strong>。そして著者らは、目的が変われば基準も変わらなければならないと述べる。</p>
<p>この部分は実務では、確認の強度を調整する基準になる。まだ方向を探っている場で「ここでmeterはどんな意味に解釈されますか？」に食い下がると、会話が前に進まない。その段階で必要なのは、何をなぜやろうとしているのかについての合意であって、境界値の確定ではない。逆に実装に入る直前には、その質問が必ず出なければならない。このときは「meter」がどの範囲なのかについて互いに同じものを思い浮かべていると信じられなければならず、そうでなければ誤った前提の上にコードが積み上がる。</p>
<p>だから私は、同じ曖昧さに出会っても<strong>いまがどの段階かによって違う扱いをする</strong>。探索の段階では一覧に書き留めて先へ進み、実装の直前にはその一覧を開いて一つずつ閉じていく。確認を先送りすることと確認を諦めることは別の作業だ。</p>
<p>この地点で併せて使いやすい道具が一つある。Matthew SkeltonとManuel PaisのTeam Topologiesは、チーム間の相互作用を三つのモードに分ける。定められた期間ともに新しいものを発見するcollaboration、一方が提供し一方が消費するX-as-a-Service、一方が他方を助けメンタリングするfacilitationだ。</p>
<p>もともとは組織設計の話だが、私はこれを会議単位に縮めて使うのが実用的だと感じた。<strong>いまこの会話がどのモードなのかを互いに違う形で認識していると、会議はずれる</strong>。企画者はすでに決まったことを伝える場だと思っているのにdeveloperはともに発見する場だと思っていれば、developerの質問は協働ではなく難癖に聞こえる。逆の場合もある。developerが確定した仕様を受け取りに来たのに企画者はまだ探索中だったなら、developerは仕様がないともどかしく感じる。</p>
<p>だから私は最近、会議の序盤にこれを先に確認する。「これはまだ開いているものですか、それとも決まったことを確認する場ですか？」この質問一つが、その後の会話の性格を変える。grounding criterionを目的に合わせて調整する作業と同じ話だ。</p>
<h2 id="解像度が合わないと会話は空回りする"><a class="anchor" href="#解像度が合わないと会話は空回りする">解像度が合わないと会話は空回りする</a></h2>
<p>モードを合わせたとしても残る問題がある。私がよく経験したもう一つのずれは、互いに違う<strong>解像度</strong>で話している状況だった。</p>
<p>企画者が文章で渡してくる要件は、たいてい抽象的すぎる。「ユーザーが予約履歴を手軽に確認できるように」といった文からは、画面が何枚あるのか、どこからどこへ遷移するのかが出てこない。逆にデザイナーが渡してくる案は具体的すぎる。ボタンの色と余白まで決まっているので、肝心の「この流れは正しいのか」を議論しにくい。</p>
<p>BasecampのRyan Singerが書いたShape Upが、この問題を正確に突いている。ワイヤーフレームや具体的な視覚レイアウトから始めると、不要なディテールに囚われて必要なだけ広く探索できなくなる、というのだ。そこで<a href="https://basecamp.com/shapeup/1.3-chapter-04" target="_blank" rel="noopener noreferrer">Shape Upが提案するもの</a>は、その中間の表現である。breadboardingと呼ばれ、電気工学から概念を借りている。ブレッドボードは実機の部品と配線はすべて揃っているが、工業デザインはない試作品だ。だから描くものもちょうど三つだ。遷移できる場所（places）、ユーザーが操作できるもの（affordances）、そしてその操作がユーザーをどこへ連れていくかを示す接続線（connection lines）である。</p>
<p>私がこの技法を良いと思った理由は、developerが作れる成果物だからだ。企画者にもっと詳しく書いてくれと頼んだり、デザイナーの案を待ったりせずに、いま理解した流れをその場で描いて「こう理解しましたが合っていますか」と返せる。Evansの言う監視（曖昧さを返すこと）を実行する具体的な方法というわけだ。そして前節の視点から見れば、これはgroundingコストを下げる装置である。一枚の図が、テキストでやり取りする何度もの確認を代替する。</p>
<p>ところがこの解像度は、思ったより維持するのが難しい。流れを描いていると自然に「ここのボタンは右下に置いたほうがよくないか」といった話へ降りていく。私も何度かそうなった。一度そうやって降りると、その場で議論されていた問い、つまりこの流れは正しいのかが静かに消える。まだ流れが確定していないのにボタンの位置を決めていると、後で流れが変わったときにその議論は丸ごと捨てられる。</p>
<p>だから私はこの段階で、<strong>わざと下手な図</strong>を保とうとする。四角と矢印と名前だけの図であれば、相手もディテールを指摘しようとせず流れにだけ反応する。図の完成度が低いことが、この道具においては機能なのだ。（きれいに描きたい気持ちを抑えるのが思いのほか難しい）</p>
<p>抽象化のレベルという概念そのものが協働の道具になりうるという点は、<a href="/260201" target="_blank" rel="noopener noreferrer">抽象化</a>で扱った話ともつながる。あのときはコードの抽象化レベルを論じたが、対話にも同じものがある。</p>
<h2 id="ルールと例と質問を分ける"><a class="anchor" href="#ルールと例と質問を分ける">ルールと例と質問を分ける</a></h2>
<p>解像度を合わせるもう一つの方法は、議論そのものを構造化することだ。</p>
<p>Cucumberプロジェクトを率いたMatt Wynneが2015年に紹介した<a href="https://cucumber.io/blog/bdd/example-mapping-introduction/" target="_blank" rel="noopener noreferrer">Example Mapping</a>を見てみよう。彼は多くのチームが要件の議論を苦手とする理由を、構造がないせいで時間がかかり退屈になるからだと診断する。だから定期的にも一貫してもやらなくなる、というわけだ。私もまさにそうだった。要件定義の会議が長引くと次からは短く終わらせようとし、短く終わらせれば曖昧なものがそのまま残る。</p>
<p>Example Mappingは四色のカードで議論を分ける。黄色はいま扱っているストーリー、青はルールあるいは受け入れ基準、緑はそのルールを説明する具体的な例、そして赤は答えの分からない質問だ。</p>
<p>私はこの中で<strong>赤いカードが核心だと考えている</strong>。残りの三つは整理の技法だが、赤いカードは「分からない」を会議の正式な成果物にする装置だ。分からないという事実を口にすることが、会議を遅らせる行動ではなく、会議が生み出すべき結果になる。</p>
<p>これがなぜ重要かは、逆の場合を思い浮かべると明らかになる。赤いカードのない会議で曖昧な箇所に出会うと、選択肢は二つだ。いま食い下がって会議を延ばすか、先へ進んで後で一人で解釈するか。前節で見たとおり、人はたいてい後者を選ぶ。ところがカードが一枚あると、三つ目の選択肢が生まれる。<strong>書き留めて先へ進みつつ、消えないようにすることだ</strong>。会議は進み続け、曖昧さはなくならないまま一覧に残る。</p>
<p>緑のカードも似た役割を果たす。ルールだけを書けば皆が同意するが、そのルールを説明する具体的な例を書こうとした瞬間に解釈が分かれる。Evansの言う「対話で繰り返し使えば差が現れる」を会議の様式へ移し替えたわけだ。ルール一つに例を三つずつ付けてみろと言えば、そのうちの一つでたいてい「あれ、この場合はどうなりますか？」が出てくる。（私の経験上、その質問が出ない会議は皆が理解した会議ではなく、各自が違う形で理解した会議だった）</p>
<p>似た発想でBDDの側で長く使われてきたThree Amigosもある。ビジネスと開発とテストという三つの視点がそれぞれ違う質問を投げるというもので、<a href="https://www.agilealliance.org/glossary/three-amigos/" target="_blank" rel="noopener noreferrer">Agile Allianceの用語集</a>はその質問を、何の問題を解こうとしているのか、その問題を解く解法をどう作るのか、そしてこれはどうか何が起こりうるか、と整理している。三つ目の質問が出ない会議がどうなるかは、あえて説明しなくてもよさそうだ。</p>
<h2 id="分からないことを確認する手順を作る"><a class="anchor" href="#分からないことを確認する手順を作る">分からないことを確認する手順を作る</a></h2>
<p>ところで、なぜ私たちは赤いカードをうまく出せないのか。ここで心理的安全性の話をしないわけにはいかない。</p>
<p>Amy Edmondsonが1999年にAdministrative Science Quarterlyへ発表した<a href="https://web.mit.edu/curhan/www/docs/Articles/15341_Readings/Group_Performance/Edmondson%20Psychological%20safety.pdf" target="_blank" rel="noopener noreferrer">論文</a>が、チーム心理的安全性という概念を正式に打ち立てた研究だ。（論文自身は1965年のScheinとBennisの研究を源流として明かしている）チーム心理的安全性を、チームが対人関係上のリスクを取っても安全な場所だという共有された信念として定義する。分からないと言うこと、反対意見を出すこと、ミスを認めることがこれに当たる。論文は製造業企業の51チームを調査し、心理的安全性が学習行動と関連し、その学習行動が心理的安全性とチーム成果の間を媒介するという結果を示した。</p>
<p>ところが私がこの論文で最も実用的だと感じた文は、定義ではなくそのすぐ次だった。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>たいていの場合、この信念は暗黙的である傾向がある。個人もチーム全体も当然のものとみなし、直接の注意を向けない。</p></div><div class="quote-original" lang="en"><p>For the most part, this belief tends to be tacit—taken for granted and not given direct attention either by individuals or by the team as a whole.</p></div></blockquote>
<p>この信念はたいてい<strong>暗黙的</strong>であり、個人もチームも直接の注意を向けないということだ。</p>
<p>ここからは私の推測だ。論文が述べたのはこの信念が暗黙的だというところまでであり、だから様式を作ればよいという処方は論文にはない。ただ、暗黙的なものが宣言では変わらないというのは何度も見てきた。「うちのチームは分からないことを聞いてもいいチームです」と言うことでは、なかなか変わらない。だから私は前節の赤いカードが重要だと考えている。<strong>分からないという言葉を様式の中の欄にしてしまえば、それを埋める作業は勇気ではなく手続きになる。</strong></p>
<p>もちろん反論は可能だ。安全でないチームでは赤いカードの欄もただ空のままだろう。もっともな指摘であり、この道具が安全性を生み出すと主張するつもりはない。ただ、欄があれば少なくとも<strong>空であるという事実が目に見える</strong>。誰も分からないことがなくて空なのか、言い出しにくくて空なのかを尋ねる場ができる。</p>
<p><picture><source type="image/webp" srcset="/content/260723/3.720w.webp 720w, /content/260723/3.1440w.webp 1440w, /content/260723/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/3.jpg" alt="DDD コミュニティの Bounded Context Canvas のテンプレート。目的、戦略分類、ユビキタス言語、前提、検証指標、未解決の問いの欄に分かれている" width="1680" height="1100" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>同じ発想が実際の文書様式にも入っている。DDDコミュニティが作った<a href="https://github.com/ddd-crew/bounded-context-canvas" target="_blank" rel="noopener noreferrer">Bounded Context Canvas</a>を見ると、一つのコンテキストを設計し記録する協働ツールなのだが、欄の構成が興味深い。名前と目的、戦略的分類、ドメイン上の役割、インバウンドとアウトバウンドのコミュニケーション、そして<strong>Ubiquitous Language</strong>、ビジネス上の決定、<strong>Assumptions</strong>、検証指標、<strong>Open Questions</strong>だ。</p>
<p>共有言語を書く欄と、前提を書く欄と、開かれた質問を書く欄がそれぞれ別にある。キャンバスの説明は、目的を書くという行為そのものがぼんやりした考えを明確に言語化させ、チーム全体を同じページに置くと述べている。</p>
<p>私はこの発想が、コミュニケーションの問題に対する最も現実的なアプローチだと考えている。人の態度を変えようとする代わりに、<strong>言いにくいことに欄を作ってやるのだ</strong>。良い構造は人にうまくやれと要求する代わりに、うまくやりやすくしてくれる。</p>
<h2 id="これが好みの問題ではない理由"><a class="anchor" href="#これが好みの問題ではない理由">これが好みの問題ではない理由</a></h2>
<p>ここまで読むと、こういう反応が出るかもしれない。良い話だが、結局はコミュニケーションを頑張ろうということではないか。それは性格の問題ではないか。</p>
<p>私もしばらくはそう考えていた。ところがこの部分にはデータがある。</p>
<p>DORAは組織文化を扱うとき、社会学者Ron Westrumの分類を持ち込む。権力志向のpathological、規則志向のbureaucratic、成果志向のgenerativeの三つだ。そして<a href="https://dora.dev/capabilities/generative-organizational-culture/" target="_blank" rel="noopener noreferrer">DORAの公式文書</a>は、自分たちの研究結果をこう要約している。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>高い信頼を持ち、情報の流れを重視する組織文化は、ソフトウェアデリバリーの成果を予測する。</p></div><div class="quote-original" lang="en"><p>organizational culture that is high-trust and emphasizes information flow is predictive of software delivery performance</p></div></blockquote>
<p>信頼が高く<strong>情報の流れを重視する</strong>組織文化が、ソフトウェアデリバリーの成果を予測するということだ。コミュニケーションがうまくいくチームは気分がよい、という話ではなく、成果と一緒に動く変数だという話である。</p>
<p>ただし、ここで予測するという言葉を因果として読んではいけない。DORAのデータは同じ回答者が文化と成果を一緒に答えた調査であり、組織レベルの相関だ。組織に満足している人が両方とも甘めに答えた可能性が残っているし、組織レベルの関係から個人はこう行動せよという処方がただちに導かれるわけでもない。だからこの資料で言えるのはここまでだ。情報の流れは好みの領域だけにあるのではなく、成果と一緒に動く場所にある。</p>
<p>同じ文書は、Westrumの言う良い情報の三つの性質も整理している。受け手が必要とする問いに答えを与え、時機が適切であり、受け手が効果的に使える形で提示される、というものだ。三つとも基準が<strong>受け手</strong>にあるという点は注目に値する。たくさん共有することが良い情報の流れではない。</p>
<p>GoogleのProject Aristotleもよく引用される資料だ。<a href="https://rework.withgoogle.com/intl/en/guides/understand-team-effectiveness" target="_blank" rel="noopener noreferrer">reWorkで公開された内容</a>によれば、180チームを調査し数百の変数に35以上の統計モデルを回した結果、チーム効果性に影響を与える五つの要因として心理的安全性、信頼性、構造と明確さ、意味、影響力を提示した。そして結論は、チームに誰がいるかよりも、そのチームがどのように協働したかが重要だった、というものである。</p>
<p>ただ一つ断っておきたい。公式ページは五つの要因を重要度順に並べていると明記しており、心理的安全性を最初に置いている。ところが、この研究を引用した記事によく見られる「圧倒的に一位」といった大きさの表現は、そのページにはない。順序があるということと、残りを圧倒するということは別の話だ。だからこの記事では大きさを主張しない。心理的安全性の重要性を語るなら、前節のEdmondsonの原論文に依るのが正確である。</p>
<h2 id="ふたたびリーダーの話へ"><a class="anchor" href="#ふたたびリーダーの話へ">ふたたびリーダーの話へ</a></h2>
<p>最初に引用した記事に戻ろう。</p>
<p>James Samuelは情報収集を最初に扱いながら、実務者時代のやり方が通用しなくなると述べる。個人コントリビューターのときは自分の仕事の全体像を持っているが、人に対する責任を負うようになると以前のやり方は使えない、というのだ。そこで必要な力として、ノイズを取り除き情報を統合して一貫した現実の像にする能力を挙げる。どんなマネージャーもすべてを処理することはできないからだ。</p>
<p>私はこれが、先に見たWestrumの良い情報の三つの性質と同じ話だと感じた。たくさん集めることではなく、使える形にすることだ。そしてそれは、いま要件を扱いながらやっていることと変わらない。仕様書の文から曖昧な部分を選り分け、breadboardで描き直して確認し、分からないことを赤いカードとして残す作業こそが、ノイズを取り除いて一貫した像を作る練習である。</p>
<p>著者が意思決定について残した言葉も記憶に残る。確実性を待つこともそれ自体が一つの決定であり、コストを伴うということだ。要件が完全に明確になるまで待つことも同じである。だから完璧な理解ではなく現在の目的に十分な水準というgrounding criterionが、実用的な基準になる。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>まとめるとこうだ。</p>
<p>他職種とのコミュニケーションの失敗は、たいてい態度だけの問題ではない。組織に異なる文脈があれば同じ単語が分かれていくのが既定の状態であり、合っている状態のほうがコストを払って維持しなければならない例外だ。そのコストを払う方法が共有言語であり、その中でdeveloperの役割は要件を書き取ることではなく、曖昧さを監視して返すことである。</p>
<p>ところが返す作業には努力がかかり、人はその努力を最小化しようとする。だから私は意志に頼る代わりに三つを使うことにした。この会話がどのモードなのかを先に確認すること、文章とデザイン案の間の解像度で流れを描いて返すこと、そして分からないことを書く欄を会議の様式の中に作っておくことだ。三つとも態度を直せと要求せず、やりやすくする側である。</p>
<p>私はまだリーダーではないし、この記事に書いたこともその立場で検証したわけではない。チームの規模や組織文化が違えば通用しないものもあるだろう。ただ一つは、いま確信できそうだ。のちに情報を扱うやり方は新しく学ぶものではなく、いま要件を扱っている習慣がそのまま大きくなった形になるだろう。だから私は最近、会議で「それはどういう意味ですか」と尋ねることを、以前ほど恥ずかしがらないようにしている。</p>
<p>この記事を読んでくださっている方も、最近の会議でうなずいたけれど実は確信がなかった単語が一つでも思い浮かぶなら、次はその単語をもう一度使ってみることをお勧めしたい。繰り返し使えば現れる。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/bliki/UbiquitousLanguage.html" target="_blank" rel="noopener noreferrer">Martin Fowler, Ubiquitous Language</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://www.domainlanguage.com/ddd/reference/" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design Reference</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://teamtopologies.com/key-concepts" target="_blank" rel="noopener noreferrer">Team Topologies, Key Concepts</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://basecamp.com/shapeup/1.2-chapter-03" target="_blank" rel="noopener noreferrer">Basecamp, Shape Up: Set Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.eventstorming.com/book/" target="_blank" rel="noopener noreferrer">Alberto Brandolini, EventStorming</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://domainstorytelling.org/" target="_blank" rel="noopener noreferrer">Stefan Hofer, Henning Schwentner, Domain Storytelling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://gojko.net/books/specification-by-example/" target="_blank" rel="noopener noreferrer">Gojko Adzic, Specification by Example</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>コラボレーション</category>
            <category>ドメイン</category>
            <category>DDD</category>
            <category>コミュニケーション</category>
        </item>
        <item>
            <title><![CDATA[Harness（Systems）Engineering]]></title>
            <link>https://hooninedev.com/ja/260622</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260622</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、prompt engineeringとcontext engineering、そしてその次について考えてみたい。 直前の記事であるtoken節約法をまとめながら、ずっと頭から離れない問いが一つあった。「個別のpromptを工夫する段階から、不要な情報を取り除き、必要な情報を選び取る技術（context engineering）へ重心が移っている」と考えていたが、記事を書き終えると自然に次の...]]></description>
            <content:encoded><![CDATA[<p>今回は、prompt engineeringとcontext engineering、そしてその次について考えてみたい。</p>
<p>直前の記事である<a href="/260611" target="_blank" rel="noopener noreferrer">token節約法</a>をまとめながら、ずっと頭から離れない問いが一つあった。「個別のpromptを工夫する段階から、不要な情報を取り除き、必要な情報を選び取る技術（context engineering）へ重心が移っている」と考えていたが、記事を書き終えると自然に次の問いが続いた。では、コンテキストの次には何が来るのか。</p>
<p><img src="/content/260622/3.webp" alt="prompt・context・harness engineeringで構成される、信頼できるAIシステムの3層構造" width="1680" height="920" loading="eager" fetchpriority="high" decoding="async"></p>
<p>「今後の方向」を語る文章には慎重さが必要だ。未来には数多くの選択肢があるはずなので、ここでは<strong>すでに公開された一次資料から読み取れる重心の移動</strong>を整理することに焦点を当てる。多少の推論は含まれるが、さまざまな観点から読んでもらえればと思う。</p>
<h2 id="promptからcontextへ"><a class="anchor" href="#promptからcontextへ">promptからcontextへ</a></h2>
<p>まず用語を整理しよう。しばらく業界の中心的な話題は<strong>prompt engineering</strong>だった。モデルに送る一度の指示をどううまく書くか、つまり明確な指示文、良い例、出力形式をどう設計するかという問題である。</p>
<p>やがて仕事の単位が大きくなった。一回限りの質問ではなく、数十ターンにわたって動くagentが一般化すると、一つのpromptではなく、<strong>モデルが各ターンで見るcontext全体</strong>（system prompt + ツール定義 + 会話履歴 + 検索結果 + メモリ）をどう構成するかが、より重要になった。これを<strong>context engineering</strong>と呼ぶ。Anthropicは2025年9月の「Effective context engineering for AI agents」でこの枠組みを整理し、Chroma研究チーム（Hong et al.）が同年発表したcontext rot研究が定量的な根拠を加えた。GPT-4.1、Claude 4、Gemini 2.5、Qwen3など18のモデルを対象に、単語をそのまま書き写す程度の単純な課題でさえ、入力が長くなるほど性能が不均一に崩れることを示した。モデルが100番目のtokenと10,000番目のtokenを同じように扱うという一般的な仮定は、実際には成り立たない。「contextは長いほどよい」ではなく、「何が入っているかと同じくらい、どう配置されているかが重要だ」という結論が、詰め込むことから選び取ることへの転換を後押しし、この用語は急速に定着した。</p>
<p>ここで、よくある誤解を一つ指摘しておきたい。prompt engineeringがcontext engineeringに<strong>置き換えられた</strong>わけではない。promptをうまく書くことは今も基本であり、context engineeringはその上に載る上位概念に近い。（良いコードの書き方から良いシステムの設計へ関心が移ったからといって、codingが不要になるわけではないのと同じだ。）したがって正確な表現は「乗り換えた」ではなく、「<strong>包含しながら広がった</strong>」である。</p>
<p>では改めて、その広がりはcontextで止まったのだろうか。そうは見えない。</p>
<h2 id="anthropicエンジニアリングブログ"><a class="anchor" href="#anthropicエンジニアリングブログ">Anthropicエンジニアリングブログ</a></h2>
<p>方向を見極める最も誠実な方法は、この分野を実際に牽引する組織が何を書いているかを時系列で読むことだと思う。Anthropicのエンジニアリングブログを「Effective context engineering」（2025年9月）以降たどると、タイトルだけでも重心が次にどこへ移ったかが見えてくる。</p>
<ul>
<li>2025年10月、Agent Skillsでagentを強化する</li>
<li>2025年11月、Code execution with MCP：より効率的なagent</li>
<li>2025年11月、Effective harnesses for long-running agents</li>
<li>2026年1月、Demystifying evals for AI agents</li>
<li>2026年1月、AI-resistantな技術評価の設計</li>
<li>2026年2月、agentic coding評価におけるインフラノイズの定量化</li>
<li>2026年3月、Harness design for long-running application development</li>
<li>2026年4月、Scaling Managed Agents：頭脳と手を分離する</li>
<li>2026年5月、製品全体でClaudeをcontainする方法</li>
</ul>
<p>一覧から一歩引いて見ると、キーワードは三つにまとまる。<strong>harness</strong>、<strong>eval</strong>、そして<strong>containment</strong>である。contextをうまく満たし、空ける方法から一段上がり、agentというシステム全体を設計し、測定し、統制する方向へ議論が移っている、と読める。（もちろん、これは一社の重点にすぎないという限界がある。ただし、同社がcoding agentのエコシステムで占める位置を考えれば、単一vendorの関心事として片づけるのも難しい。）</p>
<p>一つずつ見ていこう。</p>
<h2 id="harness"><a class="anchor" href="#harness">harness</a></h2>
<p>harnessという言葉は少し馴染みが薄いかもしれない。直訳すれば馬具、つまり馬に装着して力を望む方向へ引き出す装置である。AI agentにおけるharnessは、<strong>modelの外側でmodelを囲み、仕事をさせる骨格全体</strong>を指す。どのtoolをどの順番で使えるようにするか、一度失敗したらどう復旧するか、権限をどこまで与えるか、loopをいつ止めるか、といったものだ。</p>
<p>context engineeringが「modelに何を見せるか」という問題なら、harness設計は「modelをその中でどう動かすか」という問題だ。一つ外側の層にある。modelを優秀な新人にたとえるなら、contextは新人に渡す業務資料、harnessは新人が働く作業環境と手順書に近い。同じ人でも環境が乱雑なら成果は不安定になり、整った手順の上では同じ能力でさらに遠くまで進める。</p>
<p>その重要性は、<a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents" target="_blank" rel="noopener noreferrer">Anthropicのエンジニアリングチームが実際に直面した失敗</a>にはっきり表れている。彼らは最上位のコーディングモデルであるOpus 4.5をClaude Agent SDK上に載せ、「claude.aiのクローンを作って」といった高レベルな指示だけを与えて複数セッションを動かしたが、モデル自体が賢くても本番運用レベルのアプリにはならなかった。失敗は二つの形で繰り返された。一つは、一度にすべてを終わらせようとして実装途中でcontextを使い切り、次のセッションに作りかけの機能を引き継がせるケース。もう一つは、後から入ったセッションが「すでにかなり進んでいる」と判断し、残る仕事を放置して完了を宣言するケースだ。（長い仕事を複数人が交代勤務でつなぐのに、交代者が前任者の記憶をまったく持たない状況を想像すればよい。）</p>
<p>解決策はモデルをさらに賢くすることではなく、骨格を変えることだった。最初のセッションには環境構築専用のprompt（initializer agent）を与え、200を超える機能仕様を<code>feature_list.json</code>に展開させ、開発サーバーを立ち上げる<code>init.sh</code>と進捗ログ（<code>claude-progress.txt</code>）を作らせた。それ以降のセッション（coding agent）は毎回ちょうど一つの機能だけを処理し、git commitと進捗メモで整理された状態を残して終了する。次のセッションはまずその進捗ファイルとgit logを読み、「前の担当者がどこまで進めたか」を把握してから引き継ぐ。同じモデルでも、この骨格に載せると結果が変わった。成否を分けたのはモデルではなくharnessだったということだ。（興味深いことに、Anthropicの処方は新しいものではない。機能一覧、小さな単位のcommit、進捗メモ、毎回実行するsmoke testは、熟練した開発者が日々行っていることそのものだ。agentにとって良い骨格とは、結局、良いエンジニアリングの習慣を環境に埋め込むことに近い。）</p>
<p>この流れがtoken節約の議論と接する点も明確だ。前の記事で扱ったsubagent隔離、tool定義のdiet、model routingは、個別に見れば別々の節約手法だが、まとめれば結局、<strong>一つのharnessをどう設計するか</strong>の一部である。どの作業をどのmodel laneへ送り、どのtoolだけを有効にし、verboseな探索をどこへ隔離するかを決める全体がharness設計だ。節約はその設計の副産物に近い。</p>
<p>しかしharness設計には奇妙な罠がある。<strong>よくできた骨格ほど、modelが改善すると古くなる</strong>ことだ。harnessは本質的に「modelが単独ではできないこと」についての仮定の集合であり、modelがそれを自力で行えるようになった瞬間、その仮定は余計なものになる。Anthropicが示す<a href="https://www.anthropic.com/engineering/managed-agents" target="_blank" rel="noopener noreferrer">事例</a>がまさにそうだ。Sonnet 4.5にはcontext上限が近づくと作業を急いで終わらせる癖（context anxiety）があり、骨格にcontext reset機構を入れて対応した。ところが同じ骨格をOpus 4.5に載せると、その癖自体が消えており、苦労して加えたresetは重荷になった。modelが一段賢くなるたびに、骨格の一部はこうして期限切れになる。</p>
<p>そこで、さらに一歩進んだ発想が現れる。特定の骨格を巧みに作るのではなく、<strong>骨格が変わっても揺らがないインターフェースを設計する</strong>ことだ。AnthropicのManaged Agentsはこの方向にあり、その着想の源は意外にもオペレーティングシステムである。OSが数十年も存続したのは、ハードウェアをプロセスやファイルといった抽象へ仮想化し、まだ存在しないプログラムまで収める器を先に作ったからだ。<code>read()</code>の一行は、1970年代のディスクでも現代のSSDでも同じように動く。同じ発想でagentを三つに分ける。判断する<strong>頭脳</strong>（Claudeとharness）、実際に手を動かす<strong>手</strong>（code execution sandbox・ツール）、そして起きたすべてを追記する<strong>セッションログ</strong>だ。三つを分離すればコンテナが落ちても頭脳はtool call errorとして処理でき、harnessが落ちてもセッションログの最後の地点から再び立ち上がれる。副次的にコストとレイテンシも下がった。コンテナを本当に必要なときだけ起動することで、time to first token（TTFT）は中央値で約60%、p95で90%以上低下したと報告されている。（ここでtoken節約の記事と再びつながる。前の記事ではprompt cachingのため静的な部分を先頭に集めるよう勧めたが、「cache hit率を高めるためにcontextをどう配置するか」は、まさにこの頭脳のそばのharnessが担う仕事である。）</p>
<h2 id="eval"><a class="anchor" href="#eval">eval</a></h2>
<p>二つ目の流れは、個人的に最も興味深かった。2026年初頭の記事は、申し合わせたかのように<strong>評価（eval</strong>）へ集まっている。</p>
<p>理由を考えると自然だ。contextを適切に構成したのか、harnessをうまく設計したのか、本当にcostを削減できたのかを、<strong>何で確認するのか？</strong> agentが長く複雑な仕事を自律的に処理するほど、「結局、これはうまく動いたのか」を人が一つずつ目で確かめるのは難しくなる。最終的に信頼の根拠は測定へ移る。だからこそ「agent評価をどう設計するか」「評価自体のnoiseをどう除くか」「modelが評価に気づいて行動を変えるeval awarenessをどう扱うか」といったテーマが前面に出てきた。</p>
<p><picture><source type="image/webp" srcset="/content/260622/4.720w.webp 720w, /content/260622/4.1440w.webp 1440w, /content/260622/4.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260622/4.png" alt="pass@k と pass^k の違いを示すグラフ。試行回数が増えると pass@k は 100% に近づき、pass^k は 0% に落ちる" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>agentの評価が難しいのは、一回限りの質疑応答とは性質が違うからだ。agentは複数turnにわたりtoolを呼び、stateを変えながら進むため、一度の失敗が後方へ伝播し、蓄積する。さらに同じ入力でも実行ごとに結果が揺れる。<a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropicは</a>この非決定性を二つの指標に分ける。<strong>pass@k</strong>はk回の試行中に一度でも成功する確率なので、試行を増やすほど上がる。<strong>pass^k</strong>はk回すべてに成功する確率なので、試行を増やすほど下がる。一度だけ正解すればよいcode生成ではpass@1が重要で、毎回安定して動く必要があるcustomer support agentではpass^kが核心となる。（per-trial成功率が75%なら、三回連続で成功する確率は0.75³、約42%まで急落する。「だいたいうまくいく」と「毎回うまくいく」の隔たりはこれほど大きい。）</p>
<p>では、一度の試行を何で採点するのか。同じ記事はgraderを三種類に分ける。<strong>code-based</strong>（testの合否、static analysis、tool call検証）は速く、安く、客観的だが、正解が複数あるopen-endedな課題には弱い。<strong>model-based</strong>（LLM-as-judge、rubric採点）は微妙な品質まで捉えられるが、非決定的なので人の採点と定期的に較正する必要がある。<strong>human-based</strong>は最も正確だが、遅く高価だ。実務では三つを組み合わせ、可能なら決定論的な採点を土台にし、model採点で補助する。もう一つ区別がある。<strong>capability eval</strong>は「このagentは何を成し遂げられるか」を問うため、低いscoreから始めて登る余地を与える。<strong>regression eval</strong>は「以前できたことが今もできるか」を問うため、ほぼ100%を維持しなければならない。scoreが下がれば、どこかが壊れた合図だ。</p>
<p>ここには意外に見落とされやすい罠がある。<strong>scoreが低い原因はagentではなく評価側かもしれない。</strong> Anthropicの報告では、Opus 4.5がCORE-Benchで最初に42%を記録したが、調べると「96.124991…」を期待して「96.12」を誤答扱いする硬直した採点、曖昧な問題仕様、再現不能な確率的課題が原因だった。bugを修正し、制約を緩めたscaffoldで再実行するとscoreは95%へ跳ね上がった。そこで彼らは一つの原則を強調する。<strong>scoreを額面どおりに信じず、transcript（実行記録）を自分で読め。</strong> frontier modelが100回試して0%なら、たいていmodelが無能なのではなく問題が壊れている。</p>
<p>測定が進歩すると基準線そのものが急速に動く点も興味深い。代表的なコーディングエージェントのベンチマークであるSWE-bench Verified（実際のGitHubの課題を与え、テストの合否で採点する）で、最先端モデルのスコアは1年で30%台から80%以上へ上がった。この段階では簡単な問題がすべて解かれ、スコアが天井（飽和）に達し、大きな能力向上が小さなスコア差としてしか見えない逆説が生じる。実際、あるコードレビュースタートアップは当初、単発の評価だけを見て新モデルに冷淡だったが、より長く複雑な仕事を測るエージェント型評価へ切り替えて初めて改善を正しく認識したという。したがって評価は一度作って終わりではなく、より難しいものへ更新し続ける生きた資産となる。（Anthropicはこれを安全工学の「スイスチーズモデル」にたとえる。穴の開いたチーズ一枚では防げないが、自動評価、プロダクション監視、人による実行記録レビューを重ねれば、一層を抜けた失敗が次の層で捕まる。）</p>
<h2 id="containment"><a class="anchor" href="#containment">containment</a></h2>
<p>三つ目は少し性質が違う。costやperformanceではなく、<strong>安全と統制</strong>の問題だ。</p>
<p>agentがより多くのtoolを持ち、より自律的に動くほど、一度の失敗が及ぼす範囲（blast radius）も広がる。fileを削除し、外部へrequestを送り、権限のある操作を代行するagentなら、「どれほど上手にできるか」と同じくらい「誤ったとき被害をどこで止めるか」が重要だ。2026年5月に<a href="https://www.anthropic.com/engineering/how-we-contain-claude" target="_blank" rel="noopener noreferrer">Anthropicが製品全体のcontainmentを扱った記事</a>を前面に出したことも、この文脈で読める。同記事はagentのriskを三つに分ける。悪意または不注意から有害なことを命じる<strong>user misuse</strong>、誰にも指示されていない行動をmodelが自ら行う<strong>model misbehavior</strong>、tool・file・networkを通じて外部から入る<strong>external attack</strong>だ。興味深い指摘は、modelが賢くなればriskが単純に減るわけではないことだ。能力の低いmodelは状況を誤読して明白な失敗をするが、能力の高いmodelは失敗が減る一方、誰も明記していない制約を回避する予想外の経路を、より巧みに見つける。</p>
<p>彼らが強調する核心は、「人が毎回監督する方式」の限界である。Claude Codeは当初、書き込み・実行・network accessのたびにuserへ承認を求めて安全を確保したが、telemetryではuserが承認要求の約93%をそのまま許可していた。承認画面が増えるほど一つひとつへの注意が薄れる<strong>approval fatigue</strong>が起きたのだ。人のclickに頼る確率的防御には、結局穴が残る。そこで重心は「agentが何をしているか監視する」から「agentがそもそも何をできるか制限する」へ移る。防御は三層に重ねる。sandbox・VM・egress controlなどの<strong>environment layer</strong>、system prompt・classifierなどの<strong>model layer</strong>、MCP・plugin・検索結果などの<strong>external content layer</strong>だ。中心原則は、<strong>決定論的に阻止するenvironment layerを最初に敷く</strong>ことにある。model layerの防御が弱いからではない。実際、prompt injectionを試すGray Swan benchmarkではone-shot攻撃の成功率は約0.1%と最高水準だ。ただし100回適応的に攻撃すると5〜6%まで上がり、確率的防御は本質的に命中率100%にはなれない。だから最後にぶつかる硬い境界を置く。（OS-level sandboxを導入すると承認画面が84%減ったという。安全装置がむしろ摩擦を減らしたわけだ。）</p>
<p><picture><source type="image/webp" srcset="/content/260622/2.720w.webp 720w, /content/260622/2.1440w.webp 1440w, /content/260622/2.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/2.png" alt="containmentの3層防御構造：environment layer（決定論的、最後の防衛線）の上にmodel layerとexternal content layer（確率的）を重ねる" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1198" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>token節約とは遠く見えるが、実は同じ根を持つ。どちらも「<strong>エージェントに何を、どこまで持たせるか</strong>」という問題だ。Anthropicは二つの事例でこの関係を示す。一つは社内レッドチームが社員をフィッシングし、悪意あるプロンプトでClaude Codeを実行させた事例だ。ひそかに差し込んだ指示は<code>~/.aws/credentials</code>を読み、外部へPOSTさせ、25回中24回成功した。ユーザーが直接入力した指示だったためモデルの分類器には不審な点がなかった。防いだのは賢いモデルではなく、認証情報を最初からサンドボックス外に置く環境境界と外向き通信の制御だった。もう一つはより微妙だ。egress許可リストは<code>api.anthropic.com</code>を正常に通したが、攻撃者が仕込んだファイルが攻撃者自身のAPIキーでAnthropicのファイルアップロードAPIを呼び、データが攻撃者のアカウントへ流出した。サンドボックスは完全に動作したのにデータは漏れた。許可リストを「宛先フィルター」ではなく、「そのドメインで可能なすべての機能を許可する権限」と見るべきだったという教訓だ。（Anthropicが繰り返し強調する原則はこれである。検証済みのハイパーバイザー、システムコールフィルター、コンテナランタイムは正常に耐え、<strong>実際に壊れたのは、その上に自分たちが作った部品</strong>だった。）使わないMCPツールを外すことも同じ文脈にある。コストを節約すると同時に攻撃対象領域を減らす。軽量に保つ設計は安く正確なだけでなく、より安全でもある。</p>
<h2 id="まとめると"><a class="anchor" href="#まとめると">まとめると</a></h2>
<p>三つの流れを一文に縮めるとこうなる。関心の単位は、<strong>prompt（一度の指示）からcontext（各turnで見せるもの）へ、さらにagent system全体（どう動かし、どう測り、どう閉じ込めるか）へ</strong>一段ずつ外側に広がっている。harnessは「どう動かすか」、evalは「うまく動いたとどう知るか」、containmentは「誤って動いたときどう止めるか」にそれぞれ対応する。</p>
<p><picture><source type="image/webp" srcset="/content/260622/1.720w.webp 720w, /content/260622/1.1440w.webp 1440w, /content/260622/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/1.png" alt="promptからcontext、さらにagent system全体へ関心が広がる3段階の拡張構造と、harness・eval・containmentの三つの流れ" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1122" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>もう一度念を押すと、この三つは筆者が<strong>すでに公開された資料から読み取った重心の移動</strong>であり、「2026年後半にはこれが標準になる」といった予言ではない。ある流れはさらに大きくなり、別の流れは違う名前に吸収されるだろう。ただ確かなのは、promptもcontextも消えず、より大きな枠組みの一部として残ることだ。新しい言葉が古い言葉を消すのではなく、その上に一層を積むように分野は動いてきたし、今後もそうなる可能性が高い。</p>
<p>costの話から始めて、ここまで来た。前の記事ではtokenの家計簿を開くことを勧めたが、この記事の提案は一歩先へ進む。節約したcostを<strong>何で確認するか</strong>（eval）、その節約を<strong>どんな骨格の上で再現可能にするか</strong>（harness）、その骨格が<strong>どこまで安全か</strong>（containment）を一緒に見てほしい。結局、最も長く残るのは特定の節約手法ではなく、自分のシステムを測定し、統制する習慣だと思う。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic, Effective context engineering for AI agents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" target="_blank" rel="noopener noreferrer">Anthropic, Harness design for long-running application development</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma Research, Context Rot</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>エージェント</category>
        </item>
        <item>
            <title><![CDATA[Kalyx]]></title>
            <link>https://hooninedev.com/ja/260617</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260617</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、筆者が作ったReactのheadless DatePickerライブラリ Kalyx について書こうと思う。 この記事は2026年6月に書いた振り返りを書き直したものだ。最初の記事が掲げた「7つのピッカーを1つのAPIで、競合ライブラリのカレンダー1つより小さく」は、改めて確かめてみると半分は誤りで、半分は差別化要因ではなかった。そこで、作った理由、既存の選択肢との違い、技術的な定義の順に...]]></description>
            <content:encoded><![CDATA[<p>今回は、筆者が作ったReactのheadless DatePickerライブラリ <strong>Kalyx</strong> について書こうと思う。</p>
<p>この記事は2026年6月に書いた振り返りを書き直したものだ。最初の記事が掲げた「7つのピッカーを1つのAPIで、競合ライブラリのカレンダー1つより小さく」は、改めて確かめてみると半分は誤りで、半分は差別化要因ではなかった。そこで、作った理由、既存の選択肢との違い、技術的な定義の順に整理し直す。</p>
<p>結論から言うと、Kalyxの違いはコンポーネントの数ではなく <strong>値モデル</strong> にある。基準バージョンは <code>@kalyx/react</code> 1.4.7と <code>@kalyx/core</code> 1.4.8（MIT、React 19専用）で、コードの引用は<a href="https://github.com/jiji-hoon96/kalyx" target="_blank" rel="noopener noreferrer">GitHubリポジトリ</a>の2026-09-16時点の <code>main</code>（<code>0bb302e</code>）に基づく。</p>
<h2 id="日付ピッカーを宣言的に使いたかった"><a class="anchor" href="#日付ピッカーを宣言的に使いたかった">日付ピッカーを宣言的に使いたかった</a></h2>
<p>作った理由は2つあった。複雑で使いにくい日付ライブラリをもっと宣言的かつシンプルに使いたかったこと、そしてそうしたライブラリが内部でどう作られているのかを学びたかったことだ。「使いにくい」という言葉は漠然としているので、筆者が引っかかった箇所が今の最新バージョンにも残っているかを、各ライブラリの型定義で改めて確認した。</p>
<h3 id="モードをpropでオンにするapi"><a class="anchor" href="#モードをpropでオンにするapi">モードをpropでオンにするAPI</a></h3>
<p>react-datepickerは、時刻選択を <code>showTimeSelect</code>、月選択を <code>showMonthYearPicker</code>、年選択を <code>showYearPicker</code>、範囲選択を <code>selectsRange</code> でオンにする。1つのコンポーネントがpropの組み合わせによって別物になる構造だ。そのコストは型に表れる。9.1.0の型定義では、<code>selectsRange</code> の値によって <code>onChange</code> のシグネチャが分岐する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// react-datepicker/dist/index.d.ts (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsRange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsMultiple</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    formatMultipleDates</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> never;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">event</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">MouseEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">KeyboardEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>よくできたunionだが、モードが増えるほど分岐は掛け算で増え、使う側はprop名だけを見て今どの組み合わせなのかを思い浮かべなければならない。（筆者が<a href="/260201" target="_blank" rel="noopener noreferrer">抽象化</a>の記事で扱った「誤ってまとめた抽象化は結合度を高める」と同じ形だ）筆者は「入力欄とポップオーバーとカレンダー」がJSXの構造そのものから読み取れることを望んだ。</p>
<h3 id="値の型とタイムゾーンが漏れる場所"><a class="anchor" href="#値の型とタイムゾーンが漏れる場所">値の型とタイムゾーンが漏れる場所</a></h3>
<p>react-datepickerとreact-day-pickerはネイティブの <code>Date</code> をやり取りする。どちらにもIANAタイムゾーンを受け取る <code>timeZone</code> propはあるにはある。react-datepickerはoptional peerとして <code>date-fns-tz</code> を要求し、react-day-pickerのものは実験的機能だが、どちらにしても値の型は依然として <code>Date</code> だ。<code>Date</code> は実行環境のローカルタイムゾーンで解釈されるため、ソウルで <code>new Date(2026, 3, 15)</code> の <code>toISOString()</code> は <code>2026-04-14T15:00:00.000Z</code> になる。4月15日を選んだのに、サーバーには14日に見える問題だ。react-datepickerの<a href="https://github.com/Hacker0x01/react-datepicker/issues/1018" target="_blank" rel="noopener noreferrer">"Date Selected is One Day Off"</a> issueは2017年9月に開かれ、2025年12月に閉じられた。</p>
<p>反対側のArk UIとReact Ariaは、<code>@internationalized/date</code> の <code>CalendarDate</code>、<code>ZonedDateTime</code> オブジェクトを使う。意味は正確だが、フォームの状態とサーバーのレスポンスがすべて文字列のアプリでは、境界ごとに変換コードが生まれる。</p>
<p>タイムゾーン対応が日付ライブラリの選択に縛られることもある。MUI X Date Pickers 9.13.0のアダプターコードを見ると、dayjs、Luxon、Momentのアダプターは <code>isTimezoneCompatible = true</code>、date-fns系は <code>false</code> だ。date-fnsを使うアプリが <code>timezone</code> propを使うには、日付ライブラリをもう1つ導入しなければならない。</p>
<p>モードはpropの組み合わせに、値は解釈が実行環境に左右される <code>Date</code> に、タイムゾーン対応は日付ライブラリの選択に散らばっている。<strong>1つの宣言で意図を書き表すのが難しかった</strong>。</p>
<h3 id="作りながら学ぶ"><a class="anchor" href="#作りながら学ぶ">作りながら学ぶ</a></h3>
<p>日付ピッカーは小さく見えるが、カレンダー計算、ロケール、タイムゾーンとDST、キーボード操作、SSRがすべて詰まっている。そこで、使う側ではJSXを見るだけで何を作っているのかが読み取れるように、作る側では値がどこで変換されるのかを説明できるくらいに絞り込むことを目標にした。</p>
<p>では、こうした要求をすでに解決しているライブラリは本当になかったのだろうか。</p>
<h2 id="既存の選択肢と何が違うのか"><a class="anchor" href="#既存の選択肢と何が違うのか">既存の選択肢と何が違うのか</a></h2>
<p>先に答えを言うと、ある。筆者はheadlessで複数のピッカーを備えたライブラリはないと思い込んで始めたが、改めて調べるとその前提は誤りだった。</p>
<h3 id="各選択肢が選んだ値モデル"><a class="anchor" href="#各選択肢が選んだ値モデル">各選択肢が選んだ値モデル</a></h3>
<p>2026-09-16にnpmの最新版をインストールし、型定義と公式ドキュメントで確認した内容だ。サイズ列は後で説明する同じ方法で測った値だ。</p>
<table>
<thead>
<tr>
<th>ライブラリ</th>
<th>headless</th>
<th>値の型</th>
<th>時刻入力</th>
<th>月・年の単独選択</th>
<th>gzip</th>
</tr>
</thead>
<tbody>
<tr>
<td>react-day-picker 10.0.1</td>
<td>いいえ（CSS同梱）</td>
<td><code>Date</code></td>
<td>なし</td>
<td>なし</td>
<td>20.0KB</td>
</tr>
<tr>
<td>react-datepicker 9.1.0</td>
<td>いいえ（CSS import）</td>
<td><code>Date</code></td>
<td><code>showTimeSelect</code></td>
<td>propで</td>
<td>45.4KB</td>
</tr>
<tr>
<td>MUI X 9.13.0</td>
<td>いいえ（Material）</td>
<td>アダプターオブジェクト</td>
<td>TimePicker</td>
<td><code>views</code></td>
<td>113.1KB</td>
</tr>
<tr>
<td>Ark UI 5.39.2</td>
<td>はい</td>
<td><code>@internationalized/date</code></td>
<td>別パッケージの <code>DateInput</code> セグメント（サイズ対象外）</td>
<td><code>minView</code></td>
<td>42.7KB</td>
</tr>
<tr>
<td>React Aria Components 1.21.1</td>
<td>はい</td>
<td><code>@internationalized/date</code></td>
<td><code>TimeField</code> セグメント</td>
<td>なし</td>
<td>75.3KB（DatePicker）、78.8KB（範囲・時刻込み）</td>
</tr>
<tr>
<td>Kalyx 1.4.7</td>
<td>はい</td>
<td>ISO 8601 UTC文字列</td>
<td>リスト形式のHourList、MinuteList</td>
<td>MonthPicker、YearPicker</td>
<td>18.9KB（DatePicker）、25.6KB（全部）</td>
</tr>
</tbody>
</table>
<p>react-day-pickerは<a href="https://daypicker.dev/guides/timepicker" target="_blank" rel="noopener noreferrer">公式ガイド</a>で "DayPicker does not include a built-in time picker" と明言している。MUIのサイズは <code>@mui/material</code> とemotionを含む値なので、すでにMUIのアプリであれば増加分はずっと小さい。</p>
<h3 id="headlessの完成形はすでにある"><a class="anchor" href="#headlessの完成形はすでにある">headlessの完成形はすでにある</a></h3>
<p>表で重要なのはArk UIとReact Ariaの行だ。<a href="https://ark-ui.com/docs/components/date-picker" target="_blank" rel="noopener noreferrer">Ark UI</a>は <code>DatePicker.Root</code> のようなdot notationの合成APIで、単一・複数・範囲選択と月・年単位の選択を提供する。<a href="https://react-aria.adobe.com/DatePicker" target="_blank" rel="noopener noreferrer">React Aria</a>はAdobeによるアクセシビリティ重視の実装だ。だからKalyxの居場所は最初に考えていたより狭い。</p>
<blockquote>
<p>headlessの完成形はすでにある。ただしArk UIとReact Ariaは値を <code>@internationalized/date</code> オブジェクトでやり取りし、時刻入力はセグメントフィールドで提供する。Kalyxは値をJSONにそのまま載るUTC時点の文字列に固定し、リストから選ぶTimePickerと月・年・週のピッカーを同じ合成APIに入れた。</p>
</blockquote>
<p>とはいえ「値が文字列だから良い」は弱い主張だ。<code>CalendarDate</code> も <code>toString()</code> を一度呼べば文字列になる。差別化要因は形式ではなく、<strong>その文字列が常に実際の瞬間（時点）であり、カレンダーのマス（座標）と混ざらないという契約、そしてその契約を守るテスト</strong> にある。</p>
<h3 id="バンドルサイズを同じ方法で測り直した"><a class="anchor" href="#バンドルサイズを同じ方法で測り直した">バンドルサイズを同じ方法で測り直した</a></h3>
<p>READMEバッジのバンドルサイズは、他のライブラリと比べられる量ではなかった。バッジの約19.5KBは <code>@kalyx/react</code> の <code>dist</code> ファイル1つで、このファイルは <code>@kalyx/core</code>、<code>@kalyx/adapter-date-fns</code>、<code>@floating-ui/react</code> を外部importとして残す。その数字を、依存関係込みの他ライブラリの数字の横に並べていたのだ。</p>
<p>そこで「利用側のアプリがimportを1行追加したとき、バンドルがどれだけ大きくなるか」ですべてを測り直した。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">echo</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "import { DatePicker } from '@kalyx/react'; export default DatePicker;"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> esbuild</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --bundle</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --minify</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --format=esm</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --platform=browser</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> \</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  --external:react</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react-dom</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react/jsx-runtime</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> gzip</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -6</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> wc</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -c</span></span></code></pre></figure>
<p>esbuild 0.28.2、2026-09-16の測定で、KBはバイト数を1024で割った値だ。react-datepickerはCSSを除いた。React Aria Componentsは、単一のDatePickerの組み立てに必要なexport 12個（<code>DatePicker</code>、<code>DateInput</code>、<code>Calendar</code>、<code>Popover</code>、<code>Dialog</code> など）と、範囲と時刻まで入れた14個（<code>DateRangePicker</code>、<code>RangeCalendar</code>、<code>TimeField</code> を含む）の2通りで測った。</p>
<p><picture><source type="image/webp" srcset="/content/260617/1.720w.webp 720w, /content/260617/1.1440w.webp 1440w, /content/260617/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/1.png" alt="同じesbuild条件で測ると、Kalyx DatePicker 18.9KB、react-day-picker 20.0KB、Kalyx全部 25.6KB、Ark UI 42.7KB、react-datepicker 45.4KB、React Aria Components 75.3KB（範囲・時刻込みで78.8KB）、MUI X 113.1KBの順に大きくなる。" style="max-width:min(720px, 100%);height:auto;" width="1680" height="980" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>同じ条件で正しい文は1つだ。DatePicker 1つ（18.9KB）は <code>DayPicker</code>（20.0KB）と同程度のサイズで、7種全部（25.6KB）はそれより大きい。（それすらDayPickerはカレンダーだけで、Kalyx DatePickerは入力欄とポップオーバーまで含む）サイズはimportの組み合わせとgzipレベルに敏感なので桁で読むのが妥当で、サイズはKalyxを選ぶ中心的な理由ではない。</p>
<h2 id="kalyxを技術的に定義すると"><a class="anchor" href="#kalyxを技術的に定義すると">Kalyxを技術的に定義すると</a></h2>
<blockquote>
<p>Kalyxは、すべての入出力をUTC時点の文字列に固定し、カレンダー座標と時点の間の変換を2つの関数だけに置き、その往復をランタイムが知るすべてのタイムゾーンについてプロパティテストで検査するheadlessなReact日付ピッカーだ。</p>
</blockquote>
<p>使う側のAPIはこうだ。<code>value</code> と <code>onChange</code> は <code>string | null</code> で、<code>displayTimezone</code> はどのゾーンのカレンダーで見せるかを決める。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{iso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">displayTimezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"America/New_York"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Input</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Calendar</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="値は時点カレンダーのマスは座標"><a class="anchor" href="#値は時点カレンダーのマスは座標">値は時点、カレンダーのマスは座標</a></h3>
<p>Kalyxの中には、形式が同じ2種類のISO文字列が流れている。<strong>座標</strong> はカレンダーグリッドの1マスで、<code>YYYY-MM-DDT00:00:00.000Z</code> と書くがタイムゾーンの概念を持たない。グリッド計算はUTCだけで回るので、実行環境に依存しない。<strong>時点</strong> は <code>onChange</code> から出ていく値で、<code>displayTimezone</code> におけるその日の0時という実際の瞬間だ。同じ1月15日が、ニューヨークでは <code>2026-01-15T05:00:00.000Z</code>、ソウルでは <code>2026-01-14T15:00:00.000Z</code> になる。</p>
<p><picture><source type="image/webp" srcset="/content/260617/2.720w.webp 720w, /content/260617/2.1440w.webp 1440w, /content/260617/2.1640w.webp 1640w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/2.png" alt="カレンダー座標 2026-01-15T00:00:00.000Z は civilMidnightFromUtcDay によってニューヨークとソウルの時点になり、calendarDayFromInstant によって再び座標に戻る。" style="max-width:min(720px, 100%);height:auto;" width="1640" height="1040" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>両者をつなぐ関数は <code>@kalyx/core</code> の2つだけだ。座標を時点に変える <code>civilMidnightFromUtcDay</code> と、その逆の <code>calendarDayFromInstant</code> だ。後者は「その時点はこのゾーンで何日か」を読み取り、UTC 0時の座標として書き直す。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:187-190</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> p</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> partsInTimezone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(iso), timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">UTC</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p.year, p.month </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, p.day)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ルールは方向だ。マスを選んで値をコミットするときは座標を時点に、保存値から表示する月とフォーカスを決めるときは時点を座標に変える。DatePicker Rootの <code>selectDate</code> は変換し、変換した時点で制約を検査し、通ればコミットする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DatePicker/Root.tsx:182-194</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> normalized</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  coordinate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> displayTimezone</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, displayTimezone)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> coordinate;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (normalized </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDateDisabled</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized, disabledRules, adapter, displayTimezone)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isControlled) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setUncontrolledValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.(normalized);</span></span></code></pre></figure>
<p>呼び出し箇所はここだけではない。<code>packages/react/src</code> を検索すると、2つの関数はDatePicker、RangePicker、DateTimePickerのRootとCalendar、Presets、キーボード移動のユーティリティ、headlessフック6つに散らばっている。それでも日付のマスをコミットしたりビューを決めたりする経路はすべて2つのどちらかを通る。時刻のコミットは <code>setTimeInTimezone</code> が担うが、内部では同じ内部関数 <code>resolveCivilDateTime</code> で変換する。</p>
<p>この契約はプロパティベーステスト（property-based test）で守る。すべての座標 <code>c</code> とゾーン <code>z</code> について <code>calendarDayFromInstant(civilMidnightFromUtcDay(c, z), z) === c</code> でなければならない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/__tests__/timezone.property.test.ts:86-94</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">it</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'round-trips every UTC calendar coordinate through civil midnight'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assert</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">property</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">utcCalendarCoordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">zone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">coordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> instant</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(instant, timeZone)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toBe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    RUNS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><a href="https://fast-check.dev/" target="_blank" rel="noopener noreferrer">fast-check</a> が2020年から2045年の間の日付をランダムに生成し、+5:45のKathmandu、+14のKiritimati、-11のNiueなど代表的なゾーン14個と組み合わせて300回回す。すぐ次のテストは、同じ往復を <code>Intl.supportedValuesOf('timeZone')</code> が返すゾーンすべてについて、ゾーンごとに12回ずつ検査する。筆者のローカルのNode 24.16では、そのリストは418個だ。1.4.8では、DSTの切り替えごとに境界時刻の変換結果をTemporal実装と照合する全数テストが加わった。</p>
<p>では、ユーザーが渡した <code>"2026-01-15T00:00:00.000Z"</code> をライブラリが自動で時点に正規化すればよいのではないか。文字列だけでは座標なのか時点なのか分からないので、この道は塞がっている。すでに時点であるソウルの値 <code>2026-01-14T15:00:00.000Z</code> に <code>civilMidnightFromUtcDay</code> をもう一度かけると <code>2026-01-13T15:00:00.000Z</code> になり、ソウルのようにオフセットが正のゾーンではレンダーのたびに1日ずつずれ続ける。（ニューヨークの値はもう一度かけても変わらない）何度適用しても結果が同じ <code>startOfDayInTimezone</code> に替えればずれは起きないが、座標を時点に変えるという本来の仕事ができない。</p>
<p>そこで正規化を諦め、契約をドキュメントで固定した。利用者はピッカーが出力した値をそのまま渡さなければならない。正直に言えば利用者に押しつけたコストで、<code>ISODateString</code> が <code>string</code> のエイリアスなのでコンパイラも防いでくれない。</p>
<h3 id="intlだけでdstを解く方法"><a class="anchor" href="#intlだけでdstを解く方法">IntlだけでDSTを解く方法</a></h3>
<p>座標を時点に変えるには「そのゾーンのその日の00:00」がUTCでいつなのかを知る必要があるが、オフセットは時点が分からないと求められない。しかもDSTの切り替え日には、現地時刻が存在しなかったり（spring forward）、2回存在したり（fall back）する。</p>
<p>Kalyxは <code>date-fns-tz</code> のようなライブラリなしでこれを解く。<code>Intl.DateTimeFormat(...).formatToParts</code> で「このUTCの瞬間はそのゾーンで何時か」を尋ねてオフセットを測り、そのオフセットを要求時刻の1日前と1日後の2地点で読む。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:251-254, 259</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">probeEpoch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getTimezoneOffsetMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probeEpoch).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), timeZone) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochBefore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochAfter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (epochBefore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> epochAfter) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(epochBefore).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>civilEpoch</code> は、求める現地時刻をUTCであるかのように読んだ値だ。オフセットは±14時間以内なので、1日前と1日後は近くの切り替えの前と後のオフセットを1つずつ与える。2つが同じなら切り替えはないので <code>formatToParts</code> 2回で終わり、グリッドの42マスごとに1回ずつ呼ばれるこの高速パスがコストを決める。（48時間以内に切り替えは1つという前提は、2020〜2045年のすべてのゾーンで成り立つ）</p>
<p>2つが違えば、2つの候補をそのゾーンで読み直し、要求した時刻と合うかを見る（<code>timezone.ts:261-279</code>）。fall backの重なりでは両方が合うので切り替え前のオフセットで作った早い方を選び、spring forwardの隙間ではどちらも合わないので同じ側を選んで隙間の長さだけ時刻を進める。実際に動かした結果はこうだ。</p>
<table>
<thead>
<tr>
<th>要求</th>
<th>状況</th>
<th>1.4.7</th>
<th>1.4.8</th>
</tr>
</thead>
<tbody>
<tr>
<td>New_York 2026-03-08 02:30</td>
<td>存在しない時刻</td>
<td><code>2026-03-08T07:30:00.000Z</code></td>
<td>同じ（03:30 EDT、時刻を進める）</td>
</tr>
<tr>
<td>New_York 2026-11-01 01:30</td>
<td>2回存在する時刻</td>
<td><code>2026-11-01T05:30:00.000Z</code></td>
<td>同じ（01:30 EDT、早い方）</td>
</tr>
<tr>
<td>London 2026-10-25 01:30</td>
<td>2回存在する時刻</td>
<td><code>2026-10-25T01:30:00.000Z</code>（遅い方）</td>
<td><code>2026-10-25T00:30:00.000Z</code>（01:30 BST、早い方）</td>
</tr>
</tbody>
</table>
<p>「隙間では時刻を進め、曖昧なら早い方を取る」は、<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime" target="_blank" rel="noopener noreferrer">MDNのTemporal.ZonedDateTimeドキュメント</a>が説明する <code>disambiguation</code> のデフォルト値 <code>"compatible"</code> と同じだ。1.4.7のLondonの行がずれた理由は後で扱う。</p>
<h3 id="アダプター境界は文字列メソッド21個"><a class="anchor" href="#アダプター境界は文字列メソッド21個">アダプター境界は文字列メソッド21個</a></h3>
<p>タイムゾーンをcoreが計算するなら、date-fnsやdayjsは何を担うのか。日付の演算とパースだ。その境界である <code>DateAdapter</code> はメソッド21個で、日付の引数と戻り値はすべてISO文字列だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/types.ts:71-102 (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DateAdapter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  parse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">formatStr</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  addDays</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  isSameDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  startOfDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  today</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // addMonths, isBefore, startOfMonth, getYear 등 15개</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>タイムゾーンを受け取るメソッドは <code>format</code>、<code>isSameDay</code>、<code>startOfDay</code>、<code>today</code> の4つで、その4つも計算はcoreに任せる。date-fnsアダプターの <code>format</code> は、<code>timezone</code> があればcoreの <code>formatInTimezone</code> を直接呼ぶ（<code>packages/adapter-date-fns/src/index.ts:112-115</code>）。だからどのアダプターを使ってもタイムゾーンの答えは同じコードから出てくるし、3つのアダプター（date-fns、dayjs、luxon）は <code>@kalyx/core/test-helpers</code> の <code>runAdapterConformanceTests</code> をそれぞれ回して同じ答えを出すか確認する。</p>
<p>2つのエントリーもこの境界で分かれる。デフォルトエントリーの <code>@kalyx/react</code> はモジュール読み込み時に <code>setDefaultAdapter(DateFnsAdapter)</code> を呼ぶので、インストールしてすぐ動く（<code>packages/react/src/index.ts:9-11</code>）。<code>@kalyx/react/headless</code> にはこの呼び出しがなくバンドルも分かれているので、date-fnsのコードは入らない。</p>
<p>この境界の形には代償があった。筆者は2026年6月にTemporalアダプターを畳んだ。Temporalの価値は <code>PlainDate</code>、<code>ZonedDateTime</code> のように <strong>型が意味を持ち運ぶこと</strong> にあるが、境界が文字列ではその意味が通り抜けられない。包んでも文字列に平坦化されるだけで、正確性の利得はないと判断した。振り返ると、前の節の「座標と時点が同じ <code>string</code>」という問題は、Temporalが <code>PlainDate</code> と <code>Instant</code> で型によって解くまさにその問題だ。文字列の境界はアダプターの交換を容易にした代わりに、型で解く道を塞いだことになる。</p>
<h3 id="7つのピッカーは3つのコンテキストの組み合わせ"><a class="anchor" href="#7つのピッカーは3つのコンテキストの組み合わせ">7つのピッカーは3つのコンテキストの組み合わせ</a></h3>
<p>Kalyxのピッカーは、DatePicker、RangePicker、TimePicker、DateTimePicker、MonthPicker、YearPicker、WeekPickerの7種だ。重要なのは数よりも、<strong>7つが独立した実装ではないという点</strong> だ。コンテキストは <code>DatePickerContext</code>、<code>RangePickerContext</code>、<code>TimePickerContext</code> の3つだけだ。</p>
<table>
<thead>
<tr>
<th>ピッカー</th>
<th>状態を持つRoot</th>
<th>コンテキスト</th>
<th>違いを生む場所</th>
</tr>
</thead>
<tbody>
<tr>
<td>DatePicker</td>
<td><code>DatePickerRoot</code></td>
<td>Date</td>
<td></td>
</tr>
<tr>
<td>MonthPicker、YearPicker</td>
<td>DatePickerのRoot実装</td>
<td>Date</td>
<td><code>selectionGranularity</code></td>
</tr>
<tr>
<td>RangePicker</td>
<td><code>RangePickerRoot</code></td>
<td>Range</td>
<td></td>
</tr>
<tr>
<td>WeekPicker</td>
<td><code>RangePickerRoot</code> そのまま</td>
<td>Range</td>
<td>Calendarに <code>selectionMode="week"</code></td>
</tr>
<tr>
<td>TimePicker</td>
<td><code>TimePickerRoot</code></td>
<td>Time</td>
<td></td>
</tr>
<tr>
<td>DateTimePicker</td>
<td>独自のRoot</td>
<td>DateとTimeの入れ子</td>
<td>2つのProviderを重ねる</td>
</tr>
</tbody>
</table>
<p>MonthPickerのRootは、表示形式のデフォルト値を <code>yyyy-MM</code> に変え、DatePickerのRoot実装に <code>selectionGranularity="month"</code> を渡すだけだ（<code>MonthPicker/Root.tsx:11-20</code>）。DatePickerの <code>selectDate</code> は、granularityが <code>month</code> なら座標をその月の1日に畳んだうえで、先に見た変換と制約検査をそのまま通る。DateTimePickerは2つのコンテキストを重ねて提供する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DateTimePicker/Root.tsx:401-402</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{dateContext}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{timeContext}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>だから <code>DateTimePicker.Calendar</code> は <code>DatePicker.Calendar</code> と同じコンポーネントで、自分がどのピッカーの中にいるのかを知らない。1か所の修正が同じコンポーネントを使うピッカー全部に届き、1か所の欠陥も全部に届く。先の図でDatePicker 1つが全体の74%だった理由も、この共有基盤だ。</p>
<h2 id="契約を守るためにかかったコスト"><a class="anchor" href="#契約を守るためにかかったコスト">契約を守るためにかかったコスト</a></h2>
<p>1.0以降の3か月は新機能よりもこの契約の確認に使い、例示ベースのテストなら素通りしていたはずの欠陥が3回出てきた。1つ目はプロパティテストが、2つ目はコードのクロスレビューが、3つ目はこの記事の検証が見つけた。</p>
<h3 id="sydneyの10月1日の1時間"><a class="anchor" href="#sydneyの10月1日の1時間">Sydneyの10月1日の1時間</a></h3>
<p>1つ目は、「<code>startOfDayInTimezone</code> の結果をそのゾーンで読むと00:00:00になる」というプロパティがAustralia/Sydneyで壊れたことだ。当時の実装はオフセットを1回しか測っていなかった。Sydneyは2034年10月1日02:00に+10から+11へ切り替わるが、その日の00:00はまだ+10なので、正解は <code>2034-09-30T14:00:00.000Z</code> だ。ところが「10月1日00:00をUTCとして読んだ地点」は切り替え後なので+11を返し、結果は前日の23:00になった。この反例は回帰テストとして残っている（<code>timezone.property.test.ts:276</code>）。</p>
<h3 id="ソウルでだけ通っていたテスト"><a class="anchor" href="#ソウルでだけ通っていたテスト">ソウルでだけ通っていたテスト</a></h3>
<p>2つ目は2026年8月3日のクロスレビューで出てきた。カレンダーで選択表示するマスを決めるコードが、変換を2回していた。マスはすでに座標なのに、マスと保存値の両方をそのゾーンの日付に変換していたのだ。「1月15日」を選んだ結果はこうだった。</p>
<table>
<thead>
<tr>
<th>ゾーン</th>
<th>保存値</th>
<th>選択表示されたマス</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Asia/Seoul</code>（+9）</td>
<td><code>2026-01-14T15:00:00.000Z</code></td>
<td>15日（正しい）</td>
</tr>
<tr>
<td><code>America/New_York</code>（-5）</td>
<td><code>2026-01-15T05:00:00.000Z</code></td>
<td>16日（誤り）</td>
</tr>
</tbody>
</table>
<p>正のオフセットのゾーンでは2回のずれが相殺されて偶然正しく、既存のテストはソウルしかカバーしていなかった。同じ点検で逆方向の違反も出てきた。表示する月を決めるときに時点へ <code>startOfMonth</code> を直接かけていたため、ソウルで1月1日を値として渡すと12月のカレンダーが開いた。</p>
<p>仕組みは違っても、破ったルールは1つだ。変換は方向ごとに決まった関数で1回だけ行う。</p>
<p>この事故で、coreの往復プロパティテストは「代表的なゾーンいくつか」から「ランタイムが知るゾーンすべて」に広がった。（React側のコンポーネントテストは、まだ <code>America/New_York</code> のような代表ゾーンを使っている）<strong>符号が変わる場所でだけ現れる欠陥は、サンプルでは捕まらない</strong>。</p>
<h3 id="londonで遅い方に解決されていた0130"><a class="anchor" href="#londonで遅い方に解決されていた0130">Londonで遅い方に解決されていた01:30</a></h3>
<p>3つ目は、この記事のDSTの表を他のゾーンに広げる途中で出てきた。1.4.7は要求時刻をUTCとして読んだ地点からオフセットを測っていたが、切り替え後のオフセットが0以上のゾーンではその地点がすでに切り替え後なので、遅いオフセットに収束した。temporal-polyfillで418ゾーンの2020〜2045年の切り替えを全数照合すると、重なりの切り替え3,395件のうち1,908件で遅い方を選んでおり、84ゾーンにまたがっていた。（隙間の切り替え3,394件はすべて正しく、オフセットが負のニューヨークは偶然正しかった）</p>
<p><a href="https://github.com/jiji-hoon96/kalyx/pull/226" target="_blank" rel="noopener noreferrer">#226</a>で1日前と1日後を読むように直して <code>@kalyx/core</code> 1.4.8としてリリースし、同じ照合を <code>timezone.dst-oracle.test.ts</code> として残した。（temporal-polyfillはテストでだけ使う）<code>'earlier'</code> と誤って書いていたソースコメントも <code>'compatible'</code> に直した。今回も <strong>サンプルが片方の符号に偏っていた。</strong></p>
<h3 id="正確性に使った3kb"><a class="anchor" href="#正確性に使った3kb">正確性に使った3KB</a></h3>
<p>これらの修正にはコードが要った。KalyxはデフォルトエントリーのバンドルにCIの上限を設けており、超えるPRは必須チェックで失敗する。12KBから始まり機能が入るたびに1KBずつ上げていたその上限を、2026年8月にタイムゾーンと制約の正確性を全面的に修正する際、17KBから20KBへ一気に引き上げた。</p>
<p>サイズはREADMEバッジに載せていたセールスポイントだった。負のオフセットのゾーンで1日ずつずれる日付ピッカーは、小さかろうが大きかろうが使えない。</p>
<p><strong>小さいと正しいのどちらかを選ばなければならないなら、正しい方だ</strong>。今の上限はぎりぎりだ。リポジトリの2026-09-11のバンドルバイトマップ文書によれば、<code>dist/index.cjs</code> をNodeのデフォルトgzipで測った値は20,259B、上限は20,480Bで、余裕は221Bだ。（依存関係を外部に残した自前ファイルのサイズなので、先の図とは別の量だ）次の機能は、まずバイトを回収しなければ入れられない。</p>
<h2 id="最初の想定とは違う結論"><a class="anchor" href="#最初の想定とは違う結論">最初の想定とは違う結論</a></h2>
<p>筆者が望んだのは、複雑な日付ライブラリを宣言的に使うことだった。作ってみると、宣言的な合成APIもheadlessの完成形もすでにあった。残った違いはもっと内側にあった。<strong>値を1つの時点に固定し、座標と時点の間の変換を2つの関数に絞り、その往復をすべてのタイムゾーンでテストによって守ること。</strong> IntlベースのDST処理、文字列のアダプター境界、3つのコンテキストで作った7つのピッカーは、その決定の結果だ。</p>
<p>学ぶという目標から見ると、一番大きく学んだのは「正しい」と主張する方法だった。最初の記事のバンドル比較は違う量を測っていたし、ソウルで通っていたテストはニューヨークで間違っていた。何を測り、どんなサンプルで確かめたのかを一緒に書かなければ、数字は簡単に自慢になる。</p>
<p>限界もはっきりしている。メンテナーは筆者1人で、React 19専用であり、<code>@kalyx/react</code> のnpmダウンロードは2026年9月5日から11日までで200回、そのうち156回が1.4.7のリリース日1日に集中している。座標と時点を型で区別できない問題はドキュメントだけで防いでおり、スタイルの接点である <code>classNames</code> と <code>data-*</code> 属性を公開APIとして保証するかどうかも決められていない。アクセシビリティは、カレンダーの <code>grid</code>、入力欄の <code>combobox</code>、時刻リストの <code>listbox</code> ロールと、矢印キー、Home/End、PageUp/PageDownの操作を備え、テストファイル8個の <code>jest-axe</code> 検査をCIで回しているが、React Ariaと同じくらい検証されていると言える根拠はまだない。</p>
<p>なので、すでにMUIのアプリならMUI Xを、セグメント入力と検証済みのアクセシビリティが優先ならReact Ariaを、カレンダー1つで足りるならreact-day-pickerを先に見るのが妥当だ。値がJSONで行き来するフォームで日付が1日ずつずれる問題を経験したことがあるなら、そのときにKalyxを覗いてもらえると嬉しいし、もっと良い解き方を知っているならGitHub Issueで教えてもらえるとありがたい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">pnpm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @kalyx/react</span></span></code></pre></figure>
<p>ドキュメントサイトの<a href="https://kalyx-docs-site.vercel.app/playground" target="_blank" rel="noopener noreferrer">Playground</a>で、7つのピッカーとlocale、timezoneの設定を実際に変えて試せる。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://kalyx-docs-site.vercel.app/" target="_blank" rel="noopener noreferrer">Kalyx公式ドキュメントサイト</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://mui.com/x/react-date-pickers/timezone/" target="_blank" rel="noopener noreferrer">MUI、Date and Time Pickers Timezone</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://floating-ui.com/" target="_blank" rel="noopener noreferrer">Floating UI公式ドキュメント</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>ライブラリ</category>
            <category>React</category>
            <category>DatePicker</category>
            <category>オープンソース</category>
        </item>
        <item>
            <title><![CDATA[AIトークン節約術]]></title>
            <link>https://hooninedev.com/ja/260611</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260611</guid>
            <pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、AIトークンを節約する方法について考えてみたい。 以前は性能やコストよりも、結果とプロセスに意識を向けていた。AIの成果物には穴が多く検証が欠かせなかったうえ、早く結果を出したい一心で、筆者を含む多くの人がトークン不足のたびに追加料金を払い、より上位のプランを使っていた。筆者も同じだった。（実のところ、最初の数か月はトークンにいくらかかっているかさえ気にしていなかった。） ところが、しばら...]]></description>
            <content:encoded><![CDATA[<p>今回は、AIトークンを節約する方法について考えてみたい。</p>
<p>以前は性能やコストよりも、結果とプロセスに意識を向けていた。AIの成果物には穴が多く検証が欠かせなかったうえ、早く結果を出したい一心で、筆者を含む多くの人がトークン不足のたびに追加料金を払い、より上位のプランを使っていた。筆者も同じだった。（実のところ、最初の数か月はトークンにいくらかかっているかさえ気にしていなかった。）</p>
<p>ところが、しばらくするとトークン使用量への警戒心が次第に強くなった。個人にとって月額料金は重く、企業にとっては人件費と運用費が深刻な課題になった。<a href="/260524" target="_blank" rel="noopener noreferrer">MCPとfunction calling</a>ではエージェントを外部システムにつなぐプロトコルを、<a href="/260526" target="_blank" rel="noopener noreferrer">コードインテリジェンスの4つの階層</a>ではコード探索ツールとGitHub Trendingを、<a href="/260529" target="_blank" rel="noopener noreferrer">コンテキストファイル</a>ではエージェントが読むルールファイルをまとめた。このように、これまでの記事はどのようなツールがあり、それをどう使いこなすかに重点を置いてきた。その視点は今も変わらないが、時間がたてば、結局もっとも知りたくなるのはコストの話だと思う。</p>
<p>トークンとは正確には何か、BPEでどう作られるか、prompt cachingが単価を下げるTransformer内部の仕組みは何かといった原理は、別の記事<a href="/260610" target="_blank" rel="noopener noreferrer">トークンの仕組み</a>にまとめた。本稿ではその土台に立ち、料金がどう発生し、どこで無駄が生まれるのかを確認したうえで、実証済みの節約パターンを整理する。主なパターンには、同じ作業を複数の戦略で呼び出して直接測ったトークン使用量を添えた。</p>
<h2 id="トークン費用はどのように発生するのか"><a class="anchor" href="#トークン費用はどのように発生するのか">トークン費用はどのように発生するのか</a></h2>
<p>まず、トークン費用が生まれ、請求される仕組みを要点だけ簡潔に見ていこう。</p>
<p>送信したすべてのテキスト（システムプロンプト、ツール定義、会話履歴、ユーザーメッセージ）が入力トークンとなり、モデルが生成した応答が出力トークンとなる。モデルにとって、呼び出しは毎回初めて見る入力だ。昨日の会話でも1分前の呼び出しでも、新しい呼び出しでは同じ内容を入力として丸ごと送り直す。（この単純な事実がトークン費用の核心だ。モデルに記憶はなく、こちらが毎回もう一度伝えている。）</p>
<p>ここにもう一つ変数が加わる。同じ呼び出しを繰り返すたび静的部分を送り直すのは高くつくため、主要なLLMプロバイダーはprompt cachingを導入した。静的な入力を一度キャッシュに保存し、次の呼び出しではキャッシュから読み出したトークンを大幅に安い価格で請求する仕組みだ。（BPEでトークンがどう作られ、TransformerのKV cacheを呼び出し間でどう再利用して単価を下げるかは「トークンの仕組み」で扱った。ここでは、その原理が費用にどう換算されるかに集中する。）</p>
<h3 id="入力トークン出力トークンキャッシュトークン"><a class="anchor" href="#入力トークン出力トークンキャッシュトークン">入力トークン、出力トークン、キャッシュトークン</a></h3>
<p>Anthropic SDKが応答として返す<code>usage</code>オブジェクトの四つのフィールドを見てみよう。</p>
<p><picture><source type="image/webp" srcset="/content/260611/1.720w.webp 720w, /content/260611/1.916w.webp 916w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/1.png" alt="Anthropic SDK のレスポンスにある usage オブジェクト。input_tokens、cache_creation_input_tokens、cache_read_input_tokens、output_tokens のフィールドが見える" width="916" height="638" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<ul>
<li><code>input_tokens</code> 送信した入力のうち、キャッシュ読み出しを除く部分</li>
<li><code>output_tokens</code> モデルが生成した応答</li>
<li><code>cache_creation_input_tokens</code> 今回の呼び出しで初めてキャッシュに保存されたトークン</li>
<li><code>cache_read_input_tokens</code> 既存のキャッシュから再び読み出されたトークン</li>
</ul>
<p>この四つには、それぞれ異なる単価が掛かる。もっとも高いのは出力トークンで、もっとも安いのはキャッシュから読み出したトークンだ。Anthropicの公式ドキュメントでは、キャッシュ読み出しは通常の入力単価の0.1倍、つまりちょうど10%である。キャッシュ書き込みは5分TTL（Time To Live、キャッシュ有効期間）で1.25倍、1時間TTLで2倍になる。初回に少し多く払い、2回目から90%引きになる構造だ。</p>
<p>ただし、キャッシュにはあまり知られていない制約もある。キャッシュ可能な最小トークン数はモデルごと、同じファミリーでもバージョンごとに異なる。Anthropicの公式文書によれば、Sonnet 4.6とOpus 4.8は1,024トークン、Opus 4.7は2,048トークン、Haiku 4.5と旧世代のOpus 4.5/4.6は4,096トークンだ。それより短いプロンプトは<code>cache_control</code>を指定しても、何の通知もなくキャッシュされない。<code>cache_control</code>のブレークポイントは1リクエストにつき最大4個で、キャッシュは<code>tools</code> → <code>system</code> → <code>messages</code>の階層順に読み込まれる。そのため、前方のツール定義が一つ変わるだけで、それ以降のキャッシュ全体が無効になる。</p>
<p><img src="/content/260611/2.webp" alt="prompt cacheは先頭から順に一致する必要があり、前方が変わると後続のキャッシュが無効になる" width="1680" height="840" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>キャッシュで単価を90%下げられるのは、TransformerのKV cacheを呼び出し間で再利用するからだ。その内部動作は「トークンの仕組み」で詳しく説明した。コスト面で覚えるべき要点は一つだけ。キャッシュヒットはプレフィックスが完全に一致したときにしか起こらないため、<strong>静的部分は前、毎回変わる動的部分は後ろ</strong>に置かなければならない。プロンプトの前方にタイムスタンプが一文字入るだけでも、その後ろのキャッシュはすべて無効になる。</p>
<p>ここで自然な疑問が浮かぶ。「マルチターンの会話はターンごとに入力が変わる。ならば、ほぼ毎回キャッシュが壊れるのではないか」。結論から言えば、そうではない。会話の入力は毎回書き直す一塊ではなく、<strong>前に積み上がった内容をそのまま残し、末尾に新しい発話だけを追加する（append）構造</strong>だからだ。システムプロンプトとツール定義、過去の質問と回答は確定するとそのまま固定され、今回の新しい質問だけが最後に付く。現在の入力を前回の入力と先頭から比べれば、最初に異なる箇所は必ず末尾の新しい質問になる。入力全体は毎回違っても、プレフィックスの大半は同じなのでキャッシュは生き残る。（毎ターンキャッシュが壊れるのは、動的な値をプロンプト前方に埋め込む誤った設計の場合だ。Claude Codeやopencodeは前方を固定して末尾にだけ追加するよう設計されており、利用者がキャッシュのために触る設定もない。入力の誤字や表記揺れがキャッシュに影響しないのも同じ理由で、その変化は常にキャッシュ対象の外側にある末尾で起こる。）</p>
<h3 id="aiの比較"><a class="anchor" href="#aiの比較">AIの比較</a></h3>
<p>2026年6月時点のAnthropic、OpenAI、Googleの公式料金を一つの表にまとめると、傾向がよく分かる。（単位は100万トークン当たりのUSD。コーディングワークフローで日常的に使うモデルを中心に選んだ。）</p>
<table>
<thead>
<tr>
<th>プロバイダー</th>
<th>モデル</th>
<th>入力</th>
<th>キャッシュ済み入力</th>
<th>出力</th>
</tr>
</thead>
<tbody>
<tr>
<td>Anthropic</td>
<td>Claude Opus 4.8</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$25.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Sonnet 4.6</td>
<td>$3.00</td>
<td>$0.30</td>
<td>$15.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Haiku 4.5</td>
<td>$1.00</td>
<td>$0.10</td>
<td>$5.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$30.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5 Pro</td>
<td>$30.00</td>
<td>$3.00</td>
<td>$180.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.1 Pro</td>
<td>$2.00</td>
<td>$0.20</td>
<td>$12.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.5 Flash</td>
<td>$1.50</td>
<td>$0.15</td>
<td>$9.00</td>
</tr>
</tbody>
</table>
<p>三社とも、出力単価は入力の5～6倍だ。つまり出力が長くなるほど、同じ答えでも費用は急増する。同じ価格帯内でもモデル間の単価差は3～6倍に広がる。単純な計算だけでも、<strong>出力を短くする、同じ答えなら安いモデルを使う、静的入力はキャッシュに載せる</strong>という三原則がコスト削減の柱だと分かる。（もう一度計算すると興味深い。呼び出しごとに1万トークンの静的コンテキストを100回再利用する場合、GPT-5.5の通常料金では$5、キャッシュ読み出しなら$0.50。つまり一つのコンテキストを100回再利用してキャッシュが実質的に削る額は$4.5で、初回の書き込み追加料金がほぼないため、2回目からすぐ黒字になる。）</p>
<h3 id="キャッシュ機構の違い"><a class="anchor" href="#キャッシュ機構の違い">キャッシュ機構の違い</a></h3>
<p>削減率が似ていても、内部構造はプロバイダーごとに異なる。違いが生まれる理由を知っておけば、方針を決めやすい。</p>
<table>
<thead>
<tr>
<th>項目</th>
<th>Anthropic</th>
<th>OpenAI</th>
<th>Google Gemini</th>
</tr>
</thead>
<tbody>
<tr>
<td>起動方式</td>
<td>明示的な<code>cache_control</code>ブレークポイント</td>
<td>自動（コード変更不要）</td>
<td>自動（implicit）と明示的（explicit）の両方をサポート</td>
</tr>
<tr>
<td>最小キャッシュトークン</td>
<td>1,024 ~ 4,096（モデル別）</td>
<td>1,024+（128単位で増加）</td>
<td>2,048 ~ 4,096（モデル別）</td>
</tr>
<tr>
<td>キャッシュ書き込み費用</td>
<td>1.25x（5分）/ 2x（1h）の入力単価</td>
<td>無料</td>
<td>無料（代わりに時間単位の保存料）</td>
</tr>
<tr>
<td>キャッシュ読み出し費用</td>
<td>0.1x 入力単価</td>
<td>約0.1x 入力単価</td>
<td>0.1x 入力単価</td>
</tr>
<tr>
<td>TTL</td>
<td><a href="https://github.com/anthropics/claude-code/issues/46829" target="_blank" rel="noopener noreferrer">5分または1時間（利用者が選択）</a></td>
<td>非アクティブ状態 5～10分が既定、最大1時間（拡張時24時間）</td>
<td>利用者が指定（時間単位の保存料）</td>
</tr>
<tr>
<td>追加費用</td>
<td>なし</td>
<td>なし</td>
<td>保存料 Flash $1/M・時間、Pro $4.50/M・時間</td>
</tr>
</tbody>
</table>
<p>三社の設計思想がそのまま表れている。<strong>Anthropic</strong>はキャッシュ範囲を利用者が明示する代わりに、初回に少し上乗せ料金（1.25倍）を取り、その後は大幅に割り引く。プレフィックス位置を制御しやすく、キャッシュ読み出し率を予測できる。<strong>OpenAI</strong>は正反対で、すべてを自動化している。1,024トークン以上なら自動でキャッシュされ追加費用もないが、利用者が動作に介入できる余地は少ない。<strong>Google</strong>は両方式を提供し、明示的に制御する場合は別途保存料を課す。短く頻繁な利用なら暗黙的キャッシュ、1時間以上保管する大きなコンテキストなら明示的キャッシュと保存料のモデルが向く。</p>
<h3 id="ツール定義とトークナイザー"><a class="anchor" href="#ツール定義とトークナイザー">ツール定義とトークナイザー</a></h3>
<p>さらに、意外に大きく効く変数が二つある。</p>
<p>一つ目はツール定義だ。複数のMCPサーバーを接続した環境では、毎回の呼び出しにツール名とパラメータスキーマがすべて入力として含まれる（どれほど膨らむかは後で実測する）。同じツールを使っても、モデルを替えるだけで費用が変わる点にも注意したい。Anthropicの公式料金文書を見ると、ツール用システムプロンプトの長さ自体がモデルによって違う。Sonnet 4.6とHaiku 4.5は<code>tool_choice: auto</code>で約497トークン、Opus 4.7は675トークン、Opus 4.8は290トークンだ。model routingの前に「現在のモデルがツール定義をどれだけ長く展開するか」を確認すると、桁の大きな差に気づくことがある。</p>
<p>二つ目はトークナイザー（人が書いたテキストをモデルが処理できるトークン単位に細分化する道具）自体の効率だ。「トークンの仕組み」で触れたように、同じテキストでもトークナイザーによってトークン数は異なる。OpenAIのo200k_baseはcl100k_baseより非英語圏のテキストを大幅に節約できる。一方AnthropicはOpus 4.7から新しいトークナイザーへ移行し、同じテキストで最大35%多くトークンを使う可能性があると明記した。単価だけでモデルを選ぶと、トークナイザー効率の差から費用が漏れる。正しい比較は、単価×予想トークン数の積で行うべきだ。</p>
<p>実際に比べると、韓国語では特に差が大きい。OpenAIの二つのトークナイザーで同じ文をトークン化したところ、英語の技術文は旧版と新版で同じ数だったが、韓国語は旧版（cl100k_base）が新版（o200k_base）より31～43%多くのトークンを使った。とりわけ注目すべきは韓国語の段落だ。167文字の一段落が旧版では169トークンとなり、<strong>文字数よりトークン数の方が多い</strong>結果になった。韓国語一文字が平均で一トークン以上を消費したことになる。</p>
<p><picture><source type="image/webp" srcset="/content/260611/3.720w.webp 720w, /content/260611/3.1440w.webp 1440w, /content/260611/3.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/3.png" alt="トークナイザー別の韓国語・英語トークン数比較：cl100k対o200k" style="max-width:min(720px, 100%);height:auto;" width="1680" height="697" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>旧版は韓国語をバイト単位で細かく分解する一方、新版は「개발」「입니다」のような頻出のまとまりを一つのトークンに束ねるからだ。単価が同じでも、韓国語中心の作業ならトークナイザー効率だけで請求額が1.5倍以上変わり得る。</p>
<p>ここまで来ると、自然に一つの疑問が生まれる。「では私たちは、いったいどこで無駄を生み出しているのだろうか」。</p>
<h2 id="トークンを浪費する典型的なパターン"><a class="anchor" href="#トークンを浪費する典型的なパターン">トークンを浪費する典型的なパターン</a></h2>
<p>AIを使いこなしているつもりでも、トークンが非効率に流出する場面は意外に多い。筆者自身と周囲の開発者の行動を集めると、いくつかの型に整理できる。</p>
<h3 id="多すぎるmcpサーバーとツール定義"><a class="anchor" href="#多すぎるmcpサーバーとツール定義">多すぎるMCPサーバーとツール定義</a></h3>
<p>前節でも触れたが、改めて強調したい。MCPサーバー（Linear、GitHub、Notion、Figma、Slack、Sentry）は一度接続すると外されにくい。使わないツールスキーマが毎回入力トークンを膨らませる。Claude Codeで既定有効のMCP Tool Searchはこの問題に対処するため、セッション開始時にはツール名とサーバー説明だけを載せ、全スキーマはそのツールを実際に呼び出した時点で初めて読み込む。</p>
<p>差がどれほど大きいか、直接測ってみた。作業中のClaude Code セッションにある27個のMCPツール（serena 10、claude.ai OAuth 4セット8、figma 2、agentmemory 7）を使い、<strong>同じユーザーメッセージを二つの設定で呼び出した</strong>。一方はMCPを一つも導入していない状態、もう一方は27個すべてが接続済みだがモデルからは呼び出せない状態とし、ツール呼び出し回数は両方とも0回にそろえた。</p>
<p><picture><source type="image/webp" srcset="/content/260611/6.720w.webp 720w, /content/260611/6.1440w.webp 1440w, /content/260611/6.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/6.png" alt="同じ質問を MCP なしと MCP 27 個を付けた状態で呼び出した結果。入力トークンが 41 から 10,335 に、1 回の費用が約 11.7 倍に増えた" width="1680" height="638" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>質問もモデルも回答の意味も同じなのに、入力トークンだけが<strong>41 → 10,335</strong>（+10,294）へ増えた（Opus 4.7）。<strong>1回当たりの費用は$0.0048 → $0.0563、約12倍</strong>である。入力が250倍に膨らみプリフィル負荷も増え、応答時間には<strong>783ms</strong>が加わった。金額そのもの以上に実感したのは、<strong>利用者がそのターンでMCPツールを一度も使わなくても、毎回払う費用</strong>だという点だった。前段のTool Searchが防ぐのは、この費用である。（この測定以降、筆者は普段使わないMCPサーバーを継続的に整理している。）</p>
<h3 id="コンテキストの蓄積とlost-in-the-middle"><a class="anchor" href="#コンテキストの蓄積とlost-in-the-middle">コンテキストの蓄積とLost in the Middle</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/4.720w.webp 720w, /content/260611/4.840w.webp 840w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/4.jpg" alt="「Lost in the Middle」論文の 1 ページ目。正解を含む文書が文脈の中央にあるとき精度が最も低くなる U 字カーブが載っている" width="840" height="727" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>長い会話をそのまま引きずると、呼び出しごとの入力トークンが増えるだけでなく、モデルの正答率まで下がる。StanfordのLiu研究チームによる「Lost in the Middle」論文は、重要な情報がコンテキストの冒頭か末尾にあると最も見つけやすく、中央に埋もれると性能が目に見えて落ちるU字型曲線を定量的に示した。多くのトークンを使いながら答えは悪くなる、最悪の組み合わせだ。Transformerのself-アテンションはトークン数の二乗に比例して計算量が増えるため、コンテキストが長いほど各トークンが受けるアテンションの絶対量が薄まる。さらに学習分布では重要情報が冒頭と末尾に集まりやすかったため、中央から先に弱くなる。</p>
<p>もう少し内部に入ると、モデルがトークンの「位置」を扱う方法そのものに、中央を埋もれさせる二つの性質がある。難しく聞こえるが、比喩にすれば単純だ。</p>
<p>第一に、モデルは<strong>近くのトークンへより強く耳を傾ける。</strong> 現代の公開モデルの多くが採用する位置表現RoPE（Rotary Position Embedding、回転位置埋め込み）は、二つのトークンが離れるほど結び付きを弱めるよう設計されている（距離とともに信号が薄れるdecay effect）。そのため、遠いトークンほど自然に注目されにくい。</p>
<p>第二に、モデルは<strong>先頭トークンへ無駄に多くのアテンションを流す。</strong> モデルには、自分のアテンションを常に100%使い切るよう配分しなければならない制約（softmaxがアテンション総和を1に固定する）がある。いま特に集中すべき場所がなくても、余ったアテンションをどこかへ捨てなければならず、その捨て場所は多くの場合、文頭の最初のトークンになる。このように最初のトークンが余剰アテンションを吸い込む現象をアテンションシンク（アテンションの排水口）と呼ぶ。（MITのXiao研究チームがStreamingLLM論文で初めて定量分析した。先頭に重要情報があるからではなく、余ったアテンションを流す排水口として使われる副産物に近い。）</p>
<p>この二つが重なるとアテンションは両端（近い直近トークン＋先頭トークン）へ偏り、中央の情報が最も弱く扱われる。重要なのは、これが特定モデルのバグではないことだ。LLaMA、Mistral、Qwenなど公開モデルの大半がRoPE系を使い、ClaudeやGPTなど非公開モデルも似た系統だと考えられているため、lost-in-the-middleは現代Transformerに共通する偏りに近い。</p>
<p><picture><source type="image/webp" srcset="/content/260611/5.720w.webp 720w, /content/260611/5.1200w.webp 1200w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/5.png" alt="context rot のグラフ。入力が長くなるほど Claude、Qwen、OpenAI、Gemini の 4 モデルすべてで精度が下がる" width="1200" height="622" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>近年、この現象は<strong>context rot</strong>と呼ばれる。<a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma研究チーム</a>がGPT-4.1、Claude 4、Gemini 2.5、Qwen3を含む18のフロンティアモデルに同じNIAH（needle in a haystack、干し草の山から針を探す）課題を与えた分析では、入力が10kから100k以上に増えると、モデルによって正答率が20～50%まで低下した。18モデルすべてが長くなるほど性能を落とし、低下が最も緩やかだったのはClaude系だった。Anthropicも、Transformerのn²アテンションに由来する「アテンション予算」が各トークンで消費される問題だと説明する。コンテキストを軽く保つことは、コスト削減であると同時に正答率を守ることでもある。</p>
<h3 id="無分別なsubagent呼び出し"><a class="anchor" href="#無分別なsubagent呼び出し">無分別なsubagent呼び出し</a></h3>
<p>subagentが便利だからと、すべての作業を委任するのも落とし穴だ。subagentは親と別のコンテキストで始まるため、システムプロンプトとツール定義を最初から積み直す固定費がかかる。短いシェルコマンドや単純なgit確認のような軽い作業を委任すると、本文を整理する効果より開始費用の方が大きくなる。Anthropicのmulti-agent research system公開報告によると、一般的なチャットと比べて単一エージェントは約4倍、multi-agent systemは約15倍のトークンを使う。4～15倍の追加費用に見合う正答率向上があるときだけ、委任に意味がある。</p>
<p>同じ罠はMCPツールの接続にも当てはまる。ツールを一つ有効にするたびスキーマが毎ターンのシステムプロンプトに載り、モデルが使わないターンでも同じ費用が請求される。「使うかもしれないから全部有効にしよう」は直感的には安全に見えるが、実測はそうではない。</p>
<p>筆者はfacebook/react v19のreconcilerソースについて、同じ質問群を次の三つに分け、正答率と費用を測った。</p>
<ul>
<li>ツールなし</li>
<li>単一ツール（CodeGraph、Serena、ripgrep、bare grep）</li>
<li>四つのツールを同時に接続</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260611/9.720w.webp 720w, /content/260611/9.1440w.webp 1440w, /content/260611/9.1680w.webp 1680w" sizes="(max-width: 532px) calc(100vw - 32px), 500px"><img src="/content/260611/9.png" alt="codesearch-benchのツールシナリオ別正答率とトークン比較" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1927" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>結果には三つの特徴がそのまま表れた。（ここで再現率は正解を漏らさずどれだけ見つけたかを意味する。）</p>
<ul>
<li><strong>ツールなし</strong>は平均再現率 0.31にとどまり、ツール自体の価値は明確だった。</li>
<li><strong>Serena（LSP）のみ</strong>は再現率 1.00、費用$0.38で、単独戦略の中でもっとも効率的だった。</li>
<li><strong>四ツールをすべて接続</strong>すると、再現率は逆に0.89へ下がり、費用は$0.47へ増えた。とりわけマルチホップ質問では、全接続のスコアがCodeGraph単独（0.78 / 0.88）と小数第2位まで同じだった。モデルが四つのうち一つに偏り、その弱点をそのまま受けた結果だ。</li>
</ul>
<p>subagentとツール接続に共通する原則は一つ。追加費用が正答率向上で正当化されるときにだけ意味がある。<strong>作業ドメインに合うツールを一つ正確に選ぶ方が、使うかもしれないと全部を有効にするより安く、正確だ。</strong></p>
<p>では、実証済みの節約法とは何だろう。本当に節約する方法はあるのだろうか。</p>
<h2 id="実証済みのトークン節約法"><a class="anchor" href="#実証済みのトークン節約法">実証済みのトークン節約法</a></h2>
<p>節約パターンはそれぞれ異なるコスト軸を攻める。入力を減らすもの、同じ入力の単価を下げるもの、同じ作業を安いモデルへ任せるものがある。一つずつ見ていこう。</p>
<h3 id="prompt-caching"><a class="anchor" href="#prompt-caching">Prompt caching</a></h3>
<p>もっとも即効性が高い方法だ。先に見たとおり、キャッシュ読み出しは通常の入力単価の0.1倍である。初回に少し書き込み上乗せ料金（1.25倍）を払えば、2回目から同じ静的部分を10分の1の価格で再利用できる。</p>
<p>節約の観点から改めて強調したいのは一つだ。どの方式でも、<strong>呼び出しごとにほぼ同じ部分</strong>（ツール定義、コードスニペット、RAGコンテキスト）がキャッシュの対象であり、その静的ブロックを動的内容（今回の質問、直前のツール結果）と混ぜなければ効果は保たれる。APIを直接扱うなら、静的ブロックを前にまとめ、動的部分を後ろへ移すだけで節約の大半を実現できる。Claude Codeやopencodeのような完成品なら、ツールが配置を処理してくれる。</p>
<h3 id="batch-apiで非同期作業をまとめる"><a class="anchor" href="#batch-apiで非同期作業をまとめる">Batch APIで非同期作業をまとめる</a></h3>
<p>呼び出しをすぐ完了させる必要がなければ、Batch APIは単価そのものを下げる最も単純な手段だ。Anthropic、OpenAI、Googleはいずれも入力・出力トークンを50%割り引き、その代わり結果は最大24時間以内に受け取る。Anthropicでは実測上ほとんどのバッチが1時間以内に完了するが、SLAは24時間という意味だ。同じ呼び出しのまま単価だけ半分になるので、導入コストもほぼない。</p>
<p>この50%が興味深いのは、ほかの節約策と乗算で重なる点だ。Anthropicの公式文書は、キャッシュとバッチの割引が互いに掛け合わされる（重ね掛け）と明記している。静的入力の単価は標準×0.5（バッチ）×0.1（キャッシュ読み出し）＝0.05倍、つまり標準の5%まで下げられる。二つの割引は加算ではなく乗算だ。</p>
<p>どの程度違うか直接計算した。静的コンテキスト 1万トークン＋動的入力500トークン＋出力1,000トークンの作業を100件処理すると仮定し、Opus 4.8の単価で四つの戦略を比較した。</p>
<p><picture><source type="image/webp" srcset="/content/260611/12.720w.webp 720w, /content/260611/12.1440w.webp 1440w, /content/260611/12.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/12.png" alt="Batch APIとPrompt cachingによるトークン費用削減の比較：二つの割引は乗算で重なる" style="max-width:min(720px, 100%);height:auto;" width="1680" height="670" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>キャッシュだけなら57%、バッチだけなら50%、両方なら79%まで下がる。二つの割引が単純に足されたのではなく、静的入力区間で掛け合わされたからだ。（出力トークンはキャッシュ対象ではなくバッチの50%割引だけを受けるため、出力比率が高い作業ほど全体の削減幅は79%より緩やかになる。静的入力の比率が高いほど乗算効果は大きい。）夜間のコードベースの索引作成、公開前の記事評価、データ抽出、定期レポートなど、人が画面の前で待つ必要のない領域は意外に広い。</p>
<p>ただし、すべての作業を非同期に回せるわけではない。利用者が回答を見て次の行動を決めるコーディングエージェントのメインセッションや対話型チャットUIなど、応答時間そのものに価値がある領域にはBatchは向かない。作業を「結果をすぐ見る必要がある」と「次の出勤までに受け取ればよい」の二つに分けるだけでも、請求額を半分にできる。</p>
<h3 id="subagentで大量出力を伴う作業を隔離する"><a class="anchor" href="#subagentで大量出力を伴う作業を隔離する">Subagentで大量出力を伴う作業を隔離する</a></h3>
<p><img src="/content/260611/7.webp" alt="Subagentのコンテキスト隔離構造" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1680" loading="lazy" fetchpriority="low" decoding="async"></p>
<p><a href="https://www.anthropic.com/engineering/multi-agent-research-system" target="_blank" rel="noopener noreferrer">Claude Code公式文書の表現をそのまま借りると、</a>subagentは「独立したコンテキストウィンドウで動作し、中間のツール呼び出しと結果はsubagent内に残り、最終メッセージだけが親へ返る」。つまり大量出力を伴う作業を丸ごと委任すれば、親コンテキストに入るのは整った要約だけになる。<a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropicのcontext engineering記事</a>によれば、subagentが探索に数万トークンを使っても、親へ返すのは通常1,000～2,000トークンに圧縮した要約だけだ。親コンテキストを軽く保つ効果は明確である。</p>
<blockquote>
<p>大量出力を伴う作業とは、一行の答えを得るまでに大量のトークンを吐き出す作業を指す。テスト実行、文書検索、ログ分析などがこれに当たる。</p>
</blockquote>
<p>ただし、subagentがそのまま総費用の削減を意味するわけではない。Anthropic自身の報告では、エージェントは通常のチャットの約4倍、multi-agent systemは約15倍のトークンを使う。subagentは高価な長期コンテキストから大量の出力を切り離し、正答率と親側の費用を守る装置であって、総トークン支出を無条件に減らす魔法ではない。だからこそ「本文整理で浮く費用が開始費用を上回る場合だけ」委任するという原則が必要だ。短い作業は親が直接処理した方が安い。</p>
<p>適用ドメインについても補足したい。Anthropicのmulti-agent research system報告は、内部評価でOpus 4単独エージェントに対し、Opus 4（リーダー）＋Sonnet 4（subagents）の構成が90.2%性能を向上させたとしている。ただし、全ドメインで同じではない。同じ記事は「エージェント同士が同じコンテキストを共有する必要がある、またはエージェント間の依存が多いドメインはmulti-agentに向かない」と明記し、コーディングをまさにその例とした。リサーチは独立した方向へ並列探索できるが、コードは一つの関数変更が別の関数へ影響する依存グラフ上で動くからだ。（コーディングにmulti-agent構成を使っているなら、そのワークフローが本当に並列探索に近いか考え直す価値がある。単一エージェント＋隔離した探索subagent一つ程度が、コーディング分野ではより安全な既定値かもしれない。）</p>
<h3 id="compactとprogressive-disclosure"><a class="anchor" href="#compactとprogressive-disclosure">compactとprogressive disclosure</a></h3>
<p>Claude Codeの<code>/compact</code>は、これまでの会話全体を要約へ圧縮し、新しいコンテキストで再開するコマンドだ。Anthropicの公式説明によれば、圧縮は単純な切り捨てではなく意味単位の要約で、進行中の作業コンテキストと最近の変更は残し、繰り返しのツール出力など再参照の必要が少ない部分は捨てる。<code>/clear</code>がすべてを消すのに対し、<code>/compact</code>は要約を残す点が違う。コンテキストウィンドウが95%近く埋まるとauto-compactが自動的に同じ処理を行う。長くなったセッションを一定地点で整理すれば、入力トークンの累積を断ち切れる。</p>
<p>内部をもう少し見ると、<code>/compact</code>は利用者が明示的に呼ぶ最終段階にすぎず、その前には自動で動く四段階のコンテキスト圧縮パイプラインがある。<a href="https://github.com/VILA-Lab/Dive-into-Claude-Code" target="_blank" rel="noopener noreferrer">Claude Codeの内部動作を分析した外部研究（「Dive into Claude Code」）</a>によれば、すべての呼び出し直前に<code>query.ts</code>が次の五段階を順に確認する。</p>
<p><picture><source type="image/webp" srcset="/content/260611/8.720w.webp 720w, /content/260611/8.1440w.webp 1440w, /content/260611/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/8.png" alt="エージェントループの図。ユーザープロンプトから始まりツール要求と権限確認を繰り返し、コンテキストが逼迫すると compact を経て次の反復に進む" width="1680" height="1591" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<ul>
<li><strong>Budget Reduction</strong>は個々のツール出力がサイズ上限を超えた部分を切り落とす。</li>
<li><strong>Snip</strong>は時間軸上で古い履歴を切る。</li>
<li><strong>Microcompact</strong>はキャッシュ認識を保ったまま微細な圧縮を行う。</li>
<li><strong>Context Collapse</strong>は非常に長い履歴を読み込み時に再投影し、次元を縮める。</li>
<li><strong>Auto-Compact</strong>は意味単位の圧縮を95%時点の最終手段として発動する。</li>
</ul>
<p>上ほど軽く安く、下ほど重いが効果は大きい。一つの圧縮戦略ではすべてのコンテキスト圧力を解消できないという認識が、この五段構造の根拠だ。利用者が<code>/compact</code>を明示的に呼ぶ行為は、自動パイプラインの最後の段を前倒しで発動させることに近い。</p>
<p>同じ考え方はClaude CodeのSkillsにも入っている。<code>/compact</code>がすでに蓄積したコンテキストを事後に減らす装置なら、<code>/skills</code>の三段階読み込みは最初から蓄積を防ぐ装置だ。</p>
<p>Anthropic文書によると、スキルは三段階で読み込まれる。名前と一行の説明（約100トークン）だけがセッション開始時にコンテキストへ入り、本文（<code>SKILL.md</code>、5千トークン未満）はスキルが起動して初めて読み込まれる。バンドルされたスクリプトやリソースはbashで実行された際に出力だけが戻り、コード自体はコンテキストに入らない。そのため数十個のスキルを導入しても、開始時のコンテキストはほとんど増えない。</p>
<h3 id="model-routing同じ答えなら安いモデルへ"><a class="anchor" href="#model-routing同じ答えなら安いモデルへ">model routing：同じ答えなら安いモデルへ</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/13.720w.webp 720w, /content/260611/13.1172w.webp 1172w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/13.png" alt="費用に対するモデル性能の分布図。対数の費用軸で低コスト高性能の位置に理想的なルーターが置かれる" width="1172" height="1039" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Opus 4.8とHaiku 4.5の入力単価には5倍の差がある。単純な検索、探索、短い要約まで最大モデルで処理するのは大きな損失だ。難易度に応じてHaiku → Sonnet → Opusの順に振り分け、本当に重い推論だけOpusを使うパターンが標準になりつつある。<a href="https://lmsys.org/blog/2024-07-01-routellm/" target="_blank" rel="noopener noreferrer">LMSYSのRouteLLM研究</a>はGPT-4の品質を95%維持しながら、強いモデルの呼び出しを14%に抑えるルーターを示した（コーディング特化ではなく一般推論ベンチマークである点には留意が必要だ）。</p>
<p>ツールの勢力図も急速に固まっている。商用ゲートウェイにはOpenRouter、Martian、NotDiamondがあり、オープンソースのセルフホスト陣営にはLMSYSのRouteLLMとLiteLLM、Bifrostなどのコスト可視化レイヤーが定着した。AnthropicのエコシステムではAgent SDKのモデル選択引数、Claude Code subagentの<code>model</code>フィールド（Exploreは既定でHaiku）、<code>/model</code> スラッシュコマンドがいずれもmodel routingのための装置だ。ただし、APIを直接呼ぶ場合、入力難易度を見てモデルが自動選択されることはない。Anthropic、OpenAI、GoogleのAPIはいずれも利用者が指定したモデル名のまま動くのが基本で、自動ルーティングは製品（CursorのAuto、ChatGPTの自動モード、OpenRouterの<code>openrouter/auto</code>）だけで有効にできるオプションだ。model routingで費用を下げるなら、ゲートウェイか分類器を自分で構築する必要がある。</p>
<p>だからといって外部の自動ルーターを無条件に付ければよいわけでもない。OpenRouterのAuto Routerのように呼び出しごとにモデルを変えるルーターは、prompt cachingと正面から衝突する。Anthropicの一時キャッシュは同じモデル＋同じプレフィックスでしかヒットしないため、呼び出しごとにモデルキーが変わるとキャッシュキーが毎回ずれる。キャッシュ読み出しで単価を90%下げていた効果を失い、model routingで半分近く節約したのにキャッシュミスで吐き出す例も多い。OpenRouterが<code>session_id</code>ベースのスティッキーセッションのオプションを勧めるのも、このためだ。</p>
<p>その結果、実務がたどり着いた形は意外に単純だ。呼び出しごとに動的分類せず、作業種類ごとに静的分岐する。先に見たsubagentの<code>model</code>フィールドがこのパターンだ。Explore subagentには常にHaikuで動くコード探索レーンを、コードレビュー subagentには常にOpusで動くレビューレーンを持たせる。各レーンでは同じモデル、システムプロンプト、ツール定義が繰り返されるため、モデルごとに独自のキャッシュが蓄積してヒットする。つまり「model routingが標準になりつつある」とは、毎回分類器を走らせる動的model routingではなく、「調整役＋実行役」型の作業種類別静的分岐が標準になったという意味に近い。model routingとcachingが同じ設計で矛盾なく共存できる理由もここにある。</p>
<h3 id="cursor-composer-25"><a class="anchor" href="#cursor-composer-25">Cursor Composer 2.5</a></h3>
<p><img src="/content/260611/10.webp" alt="Cursor が公開した CursorBench 3.1 スコアとタスクあたり平均費用のグラフ。Composer 2.5 が最も安い領域で上位スコアに近づく" width="1680" height="1120" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>少し性質の違う節約法だが、Cursorのようなエージェントも利用できる。<a href="https://cursor.com/blog/composer-2-5" target="_blank" rel="noopener noreferrer">Cursorが2026年5月18日に公開した独自モデルComposer 2.5</a>は、Moonshot AIのオープンソースのチェックポイント Kimi K2.5を基に、コーディング向けにファインチューニングしたモデルだ。Cursorチームは独自ベンチマークで、Claude Opus 4.7に並ぶコーディング性能を約10分の1の価格で実現したと述べる。公開価格は入力$0.50、出力$2.50で、Opus 4.8の入力$5.00、出力$25.00とはちょうど一桁違う。</p>
<p>特化モデルのベンチマークはCursor自身の測定なので、絶対値をそのまま受け取るより、「コーディング専用に学習した小型モデルが、汎用フロンティアモデルと同じ作業で遜色ない性能を出しながら費用を一桁下げる」という流れとして読むのが安全だ。これは2026年にもっとも明確になった節約潮流の一つである。</p>
<p>本当に興味深いのは単価だけでなく、<strong>IDEと一体で設計すると入力トークン自体も減る点</strong>だ。ComposerやCascadeのようなモデルは、IDEが送るコードベースのコンテキスト（現在のファイル、隣接ファイル、索引済みのシンボル）を効率よく使うよう学習されている。同じ作業でも、汎用モデルが「関連ファイルをもっと見せてほしい」とgrepやreadを繰り返して膨らませる入力トークンを抑えられる。単価が一桁下がったうえトークン数も少し減るので、実際の削減幅は単価差以上になる。</p>
<h3 id="コンテキストをコンテキストの外へ"><a class="anchor" href="#コンテキストをコンテキストの外へ">コンテキストをコンテキストの外へ</a></h3>
<p>2026年のもっとも興味深い流れは、「必要なものだけを、必要なときに」コンテキストへ載せる方向だ。Anthropicはこれを<strong>just in time（JIT）コンテキスト</strong>と呼ぶ。すべての資料を事前にコンテキストへ入れるのではなく、ファイルパスやクエリのような軽い参照だけを持ち、実際に必要になったときツールで取り込む。Claude Codeがコードベース全体の索引を作って丸ごと載せず、globやgrepでその都度ファイルを読むのがこのパターンだ。</p>
<p>AnthropicがSonnet 4.5とともに公開したmemory toolとcontext editingも、同じ思想から生まれた。ここで注意したいのは、memory toolの「memory」は一般的な意味と異なることだ。セッション内に積み上がる会話履歴（コンテキストウィンドウ内にあり、セッション終了で消える）でも、<code>CLAUDE.md</code>のように人が事前に書き、開始時に自動で読み込まれる設定ファイルでもない。memory toolは、<strong>モデルが直接ファイルを読み書きするツール</strong>である。</p>
<p>さらに見ると、二つのツールの設計は意外に単純なインターフェースだ。memory toolでは、Claudeが利用者のインフラに用意された専用memory用ディレクトリへファイルを作成、読み取り、更新、削除する。つまりモデルは「どこかのテキストに書いておく」のではなく、通常のファイルシステムのようにmemoryを扱う。保存先を利用者のインフラに置くのが要点で、Claudeはコンテキストウィンドウ外に永続memoryを持てる一方、データの保存・保管方法は利用者が管理する。セッション内の記憶がコンテキスト内で揮発するのに対し、このmemoryは外側に残って次のセッションへ続く。context editingは逆方向から同じことを行う。トークン上限に近づくと古く参照されなくなったツール結果を自動で消し、会話の流れを保ったままエージェントを長く動かせる。</p>
<p>Anthropicの内部評価では、両者の併用でベースライン比39%、context editing単独で29%性能が向上し、100ターンのウェブ検索評価ではトークン消費が84%減った。（ベンダー自身のベンチマークである点は考慮すべきだが、方向性は明確だ。コンテキストは埋めることより、空ける設計が重要になっている。）この流れはClaude Codeの<code>/compact</code>五段パイプラインとも共通する。違いは、<code>/compact</code>がコンテキスト内部を減らすのに対し、memory toolはコンテキスト外に別のストレージを置くことだ。両者は競合せず補完する。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p><img src="/content/260611/14.webp" alt="プロンプトエンジニアリングとコンテキストエンジニアリングの比較図。単発の質問はシステムプロンプトとユーザーメッセージだけだが、エージェントは文書・ツール・メモリを選んで入れ、ツール結果を戻す" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>ここまで整理すると、トークン節約は三つの軸に集約できる。<strong>同じ作業なら送る量を減らす、同じ入力なら安く送る、同じ答えなら安いモデルに任せる</strong>。Prompt caching、Batch API、subagent隔離、<code>/compact</code>、memory toolとcontext editing、model routing、特化モデルといったパターンは、三軸のどこを狙うかが違うだけだ。2026年の流れを一言で表せば、コンテキストは「埋める技術」から「空け、選び取る技術」、すなわちcontext engineeringへ重心を移している。context rotが示すとおり、軽いコンテキストは費用だけでなく正答率にも有利だからだ。</p>
<p>もちろん唯一の正解はない。チームごとに作業パターンは違い、同じ人でも文章を書くときとコードを書くときでは費用構造が異なる。ただ一つ確かなのは、エコシステムが急速に動くほど、「昨日正しかった節約パターンが今日も正しい」という前提は危険になることだ。新しいモデル、ツール、料金表を追い続け、自分の作業上で小さなPOCを試す習慣こそ、目立たなくても長く効く節約法になると思う。読者にも、自分だけのトークン家計簿を一度開いてみてほしい。（節約策がなぜ機能するのか、その根を知りたければ、「トークンの仕組み」でBPEとKV cacheもあわせて読むとよい。）</p>
<p>そして一つ問いが残る。コンテキストを空け、選び取る技術まで来たなら、その次はどこへ向かうのか。公開資料をたどると重心はコンテキストのさらに外へ移り、エージェントというシステム全体をどう動かすか（harness設計）、うまく動いたかをどう測るか（eval）、誤作動したときどう閉じ込めるか（containment）へ広がっている。興味深いことに、本稿が繰り返し強調した「POCで直接測る」という姿勢をシステム単位へ拡大すると、まさにevalになる。この先の方向は、<a href="/260622" target="_blank" rel="noopener noreferrer">コンテキストの次</a>で詳しく扱う予定だ。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/batch-processing" target="_blank" rel="noopener noreferrer">Anthropic Batch Processing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/about-claude/pricing" target="_blank" rel="noopener noreferrer">Anthropic Claude API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/sub-agents" target="_blank" rel="noopener noreferrer">Anthropic Claude Code Subagents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/claude-code/mcp" target="_blank" rel="noopener noreferrer">Anthropic Claude Code MCP Tool Search</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview" target="_blank" rel="noopener noreferrer">Anthropic Agent Skills</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developers.openai.com/api/docs/guides/prompt-caching" target="_blank" rel="noopener noreferrer">OpenAI Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://openai.com/api/pricing/" target="_blank" rel="noopener noreferrer">OpenAI API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/gemini-api/docs/caching" target="_blank" rel="noopener noreferrer">Google Gemini Context Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/pricing" target="_blank" rel="noopener noreferrer">Google Gemini API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/news/context-management" target="_blank" rel="noopener noreferrer">Anthropic, Managing context on the Claude Developer Platform</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2601.07206" target="_blank" rel="noopener noreferrer">LLMRouterBench (Findings of ACL 2026)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2604.14228" target="_blank" rel="noopener noreferrer">VILA-Lab, Dive into Claude Code</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>トークン</category>
        </item>
        <item>
            <title><![CDATA[トークンの仕組み]]></title>
            <link>https://hooninedev.com/ja/260610</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260610</guid>
            <pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[この記事では、AIのトークンとはそもそも何なのか、どのような仕組みで動くのかを掘り下げる。 これまで筆者は、AIツールをうまく使う方法や、どのツールがなぜ流行しているのかを主に扱ってきた。ところがトークン節約術をまとめる中で、改めて気づいたことがある。コストの減らし方を語るには、まず「トークンとは何か、どのように課金されるのか」を知る必要があるのに、その土台をきちんと説明したことがなかったのだ。（...]]></description>
            <content:encoded><![CDATA[<p>この記事では、AIのトークンとはそもそも何なのか、どのような仕組みで動くのかを掘り下げる。</p>
<p>これまで筆者は、AIツールをうまく使う方法や、どのツールがなぜ流行しているのかを主に扱ってきた。ところが<a href="/260611" target="_blank" rel="noopener noreferrer">トークン節約術</a>をまとめる中で、改めて気づいたことがある。コストの減らし方を語るには、まず「トークンとは何か、どのように課金されるのか」を知る必要があるのに、その土台をきちんと説明したことがなかったのだ。（節約術を書いているうちに、トークンの仕組みだけで一本の記事になる分量になった。）</p>
<p>そこで本稿では、節約術に入る前の基礎を整理する。トークンが単語でも文字でもないなら正確には何なのか（BPE）、どのような姿でモデルに入るのか（embedding）、なぜ入力より出力の方が高いのか（prefill/decode）、prompt cachingが単価を下げる理由はTransformer（ニューラルネットワークの設計）のどこにあるのか（KV cache）、そしてコンテキストが長くなるほどなぜ高くなるのか（注意機構の二乗コスト）を順に見ていく。実践的な節約方法を知りたい場合は、本稿の次にトークン節約術を読んでほしい。</p>
<h2 id="トークンは単語でも文字でもない"><a class="anchor" href="#トークンは単語でも文字でもない">トークンは単語でも文字でもない</a></h2>
<p>まず最も基本的な事実から確認しよう。<strong>トークンは単語でも文字でもない。</strong> モデルが学習データに頻出する文字列を圧縮して作った、語彙の単位である。</p>
<p>私たちが「この文は何語あるか」と数える直感と、モデルがテキストを分割する方法は異なる。モデルは頻繁に一緒に現れる文字の組み合わせを一つの単位としてまとめ、入力されたテキストをその語彙に沿って細かく分ける。そのため、同じ一単語でも一つのトークンになるものもあれば、複数に分かれるものもある。</p>
<p>この語彙を作るアルゴリズムが、ほぼすべての現代LLMに共通する基盤であるBPEだ。</p>
<h3 id="bpeアルゴリズム"><a class="anchor" href="#bpeアルゴリズム">BPEアルゴリズム</a></h3>
<p>BPE（Byte Pair Encoding、バイト対符号化）は、隣接して頻出する記号のペアを繰り返し新しい一つの記号へ統合し、語彙を増やしていくアルゴリズムである。</p>
<p>興味深いのは、もともと自然言語処理のために生まれたものではない点だ。BPEは1994年、Philip Gageによって<a href="/240706" target="_blank" rel="noopener noreferrer">データ圧縮</a>手法として初めて提案された。データ内で最も頻出するバイト対を未使用の1バイトで置き換え、その置換規則を別の表に保存する圧縮方式だった。</p>
<p>これをニューラル機械翻訳の語彙問題へ持ち込んだのが、エディンバラ大学のSennrichらの研究チームである。2015年に公開され、ACL 2016で発表された「Neural Machine Translation of Rare Words with Subword Units」で、固定語彙では希少語や未知語を扱えないという限界をBPEで解決した。単語全体を覚える代わりに、より小さなサブワードの組み合わせで表せば、語彙にない単語も既知の断片から符号化できるという発想だ。</p>
<p>動作の流れは意外に単純である。</p>
<ol>
<li>最初は一文字ずつを一つのトークンとする。</li>
<li>コーパス内で最も頻繁に隣接するトークンのペアを探す。</li>
<li>そのペアを新しい一つのトークンに統合し、コーパス全体で該当ペアを新トークンに置き換える。</li>
<li>語彙が目標サイズに達するまで2〜3を繰り返す。</li>
</ol>
<p>初めは一文字が一トークンだが、やがて「th」「the」「tion」のような頻出する組み合わせが一つのトークンへまとまっていく。よく使われるパターンほど大きな塊になるわけだ。</p>
<p><img src="/content/260610/2.webp" alt="BPEトークナイザーが頻出する隣接文字ペアを繰り返し統合して語彙を増やす過程" style="max-width:min(500px, 100%);height:auto;" width="1024" height="1536" loading="eager" fetchpriority="high" decoding="async"></p>
<p>GPT系は、さらに一歩進んだ<strong>バイトレベルBPE</strong>を使う。テキストを文字ではなくUTF-8のバイト列へ変換してからBPEを適用する方式だ。これにより、基本語彙を256種類（バイトの種類数）から小さく始めながら、UTF-8で表現できるあらゆるテキストを未知トークンなしで符号化できる。絵文字でも漢字でも特殊記号でも、少なくともバイト単位では必ず表現できることが保証される。</p>
<h3 id="同じテキストでもトークン数は違う"><a class="anchor" href="#同じテキストでもトークン数は違う">同じテキストでもトークン数は違う</a></h3>
<p>この仕組みのため、同じテキストでもtokenizer（人が書いたテキストを、モデルが扱えるトークン単位に分割するツール）によってトークン数は大きく変わる。語彙がどのように学習されたかにより、同じ文が細かくも、大きな塊にも分割されるからだ。</p>
<p><picture><source type="image/webp" srcset="/content/260610/3.720w.webp 720w, /content/260610/3.1440w.webp 1440w, /content/260610/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/3.png" alt="同じテキストでもtokenizerによってトークン数が異なり、非英語ではo200k_baseがcl100k_baseより少ないトークンを使う比較" width="1680" height="997" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>OpenAIがGPT-4oとともに公開した資料を見ると、この差がよく分かる。GPT-4o系の新tokenizerであるo200k_baseは、GPT-4系のcl100k_baseより同じテキストを少ないトークンで表現する。英語では約1.1倍効率的という程度だが、英語以外では差が広がる。公開された短い例文では、中国語と日本語で約1.4倍、韓国語で約1.7倍、ヒンディー語では最大約2.9倍トークンが減った。（韓国語話者にとって、tokenizerの世代差が英語話者より大きく感じられるのは興味深い。）</p>
<p>さらに広く見ると、言語そのものによる格差もある。cl100k_baseで複数言語を比較したYennie Junの分析では、同じ意味の文でも英語はほぼ常に最少トークンで、固有の文字体系を持つ言語ほど長くtokenizeされる傾向がある。ヒンディー語やベンガル語は英語の5倍、ビルマ語は10倍を超えることもある。韓国語や中国語も英語より長くtokenizeされるが、それらの極端な言語ほどではない。</p>
<p>ここから実務上重要な示唆が得られる。AnthropicもOpus 4.7から新しいtokenizerを導入し、公式料金資料に「同じテキストでも従来モデルより最大35%多いトークンとして課金される場合がある」と明記した。<strong>単価が同じでも、トークン数が増えれば請求額もそのまま増える。</strong> モデル比較は単価だけでなく、単価×予想トークン数で見る必要がある。</p>
<p>では、分割されたトークンがモデルへ入るとき、文字や単語のまま入るわけではない。トークンはどのような形でモデルに入力されるのだろうか。</p>
<h2 id="トークンがモデルに入るまで"><a class="anchor" href="#トークンがモデルに入るまで">トークンがモデルに入るまで</a></h2>
<p>ニューラルネットワークはテキストを直接扱えない。モデルが計算できるのは最終的に数値、より正確にはベクトル（複数の数値を並べた配列）だけだ。そのため、トークンはモデルに入る前にいくつかの段階を経て数値へ変換される。</p>
<p>順に整理すると次のようになる。</p>
<ol>
<li><strong>テキスト → トークン</strong>：BPE tokenizerがテキストをトークン片に分ける。</li>
<li><strong>トークン → トークンID</strong>：各トークンは語彙内の位置を示す整数IDへ対応付けられる。たとえば<code>" the"</code>が語彙の1,234番目なら、そのトークンのIDは<code>1234</code>となる。</li>
<li><strong>トークンID → embeddingベクトル</strong>：このIDでembedding行列の該当行を取り出す。embedding行列は<code>vocabulary size × model dimension (d_model)</code>という大きさの巨大な表で、各トークンIDが一行、つまり一つのベクトルに対応する。このベクトルをトークンの「意味」を表す座標と考えればよい。</li>
<li><strong>位置情報を加える</strong>：自己注意機構は、それ自体ではトークンの順序を知らない。つまり「私は君が好き」と「君が私は好き」を同じものとして見る。そこで各トークンベクトルへ位置情報を注入し、並び順を教える。</li>
</ol>
<p>言葉だけでは抽象的なので、この4段階を直接試せる操作ツールを用意した。入力文を変えたりトークンをクリックしたりすると、IDからembeddingベクトルとなり、位置情報が加わって最終入力ベクトルになるまでの流れを追える。（表示する次元数は減らしているが、実際のGPT-4のベクトルは12,288次元であることを念頭に置いて見てほしい。）</p>
<p>ここでモデル次元（d_model）とは、一つのトークンを表現するベクトルの長さである。元祖Transformer論文では512だったが、現在のLLMははるかに大きい。（Llama 2の7Bモデルでも4,096次元ある。）モデル次元が大きいほどトークンの微妙な意味の違いを豊かに表せる一方、計算量とメモリも増える。</p>
<p><picture><source type="image/webp" srcset="/content/260610/4.704w.webp 704w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/4.png" alt="トークンIDがembedding行列の一行を参照し、d_model次元のベクトルへ変換される構造" width="704" height="435" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>位置情報を注入する方法も世代によって変わった。元祖Transformerは正弦・余弦関数で作った位置ベクトルをembeddingへそのまま加えたが、現在の公開LLMで事実上の標準となったRoPE（Rotary Position Embedding、回転位置embedding）は、Query/Keyベクトルを回転させ、注意機構の計算時に相対位置を注入する。（方式は違っても「Transformerに順序感覚を持たせる」という目的は同じだ。）</p>
<p>まとめると、<strong>テキスト → トークン → トークンID → embeddingベクトル（+位置情報）→ Transformer入力</strong>という流れである。請求書にある「トークン数」とは、この流れの二番目、つまりテキストがいくつのトークンに分割されたかを数えたものだ。</p>
<p>ここまでで、トークンがどう作られ、どんな姿でモデルに入るかが分かった。ではモデル内部で処理されるとき、入力と出力のコストはなぜこれほど違い、同じ入力を繰り返し送るコストはどう減らせるのだろうか。</p>
<h2 id="入力が安く出力が高い理由"><a class="anchor" href="#入力が安く出力が高い理由">入力が安く、出力が高い理由</a></h2>
<p>トークンを扱った人なら一度は疑問に思うはずだ。料金表を見ると、<strong>出力トークンは入力トークンの数倍も高い。</strong> Anthropicでは、全モデルで出力単価が入力単価のちょうど5倍である。（Opusは入力$5／出力$25、Haikuは入力$1／出力$5。）同じ一トークンなのに、入るときと出るときでなぜ価格が違うのか。</p>
<p>答えは、モデルが入力と出力のトークンをまったく異なる方法で処理するからだ。LLM inferenceは二段階に分かれる。</p>
<ul>
<li><strong>prefill（入力処理）</strong>：プロンプトとして入った全トークンを<strong>まとめて並列に</strong>処理する。1,000トークンでも10,000トークンでも、GPUが一度に走査して各トークンのK/V（Key/Value）を計算する。演算量は多いが、並列処理できるためトークン当たりの効率がよい。</li>
<li><strong>decode（出力生成）</strong>：応答トークンを<strong>一度に一つずつ順番に</strong>作る。一つ生成したら入力へ追加し、次のトークンを生成する。応答が終わるまでこれを繰り返す。</li>
</ul>
<p>この非対称性がコスト差を生む。Databricksの推論性能分析によれば、prefillは演算律速の段階で、decodeはメモリ帯域律速の段階だ。decodeでは一トークンを作るたび、モデルの全重みをGPUメモリから再び読み込む必要があるのに、一回で得られるのは一トークンだけである。GPUの膨大な演算能力の大半が遊んでいることになる。（Databricksの表現を借りれば、GPUを動かす料金を払いながら、使える演算能力を使えていない状態だ。）</p>
<p>つまり、<strong>入力トークンは並列prefillでまとめて効率よく処理される一方、出力トークンは一つずつ非効率に絞り出さなければならない。</strong> 料金には需要や利益率など商業的要因も含まれるだろうが、技術的にはdecode段階の非効率が背景にある。したがって「出力を短くする」という節約原則は、単なる助言ではなく、最も高価な段階を減らすことなのだ。</p>
<p>では、この非効率なdecodeを少しでも改善する方法はないのか。そこで登場するのがKV cacheである。</p>
<h2 id="キャッシュが単価を下げる仕組み"><a class="anchor" href="#キャッシュが単価を下げる仕組み">キャッシュが単価を下げる仕組み</a></h2>
<p>prompt cachingは静的な入力を一度キャッシュへ保存し、次の呼び出しでは大幅に安い料金で読み出す仕組みだ。Anthropic公式資料では、キャッシュ読み出しは基本入力単価の0.1倍、つまりちょうど10%である。キャッシュ書き込みは5分TTL（Time To Live、キャッシュの有効期間）で1.25倍、1時間TTLで2倍になる。最初の呼び出しで少し多く払い、二回目以降は90%安くなる構造だ。</p>
<p>だが、なぜ90%も安くできるのか。そして「接頭部が完全に一致しなければならない」という条件はなぜそれほど厳しいのか。Transformerの内部を見れば、どちらの答えも明確になる。</p>
<h3 id="kv-cache"><a class="anchor" href="#kv-cache">KV cache</a></h3>
<p>モデルは入力トークンを処理するとき、各トークンに対応するQuery/Key/Value（Q/K/V）ベクトルを作る。自己注意機構はQueryとKeyの内積で注意スコアを計算し、そのスコアを重みとしてValueの加重和を作って出力する。一つのトークンがほかのトークンを「どの程度参照するか」を決める計算だと考えればよい。</p>
<p>先ほど見たdecode段階は、まさにここで問題になる。新しいトークンを一つ作るたび、それ以前の全トークンのK/Vが必要だが、毎回最初から計算すると同じK/Vを際限なく重複計算することになる。</p>
<p><img src="/content/260610/1.webp" alt="TransformerのKV cacheが動作する仕組み" width="1536" height="1024" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>KV cacheは、この重複をなくすために導入された。一度計算したK/Vを保存し、新しいトークンを生成するときは先行トークンのK/Vを再計算せず、キャッシュから読み出して再利用する。これにより、一つの系列内でトークンを一つ追加生成するコストは、各段階で全体を再計算するO(n²)から、キャッシュを読むだけのO(n)へ下がる。（厳密には一段階当たりのコストだが、要点は「計算済みのものは再計算しない」という単純な発想である。）decodeがメモリ律速なのも、各段階でこのKV cacheをメモリから読み直す必要があるためだ。</p>
<h3 id="呼び出し間での再利用"><a class="anchor" href="#呼び出し間での再利用">呼び出し間での再利用</a></h3>
<p>KV cacheがもともと一つの応答生成中のdecodeを高速化する仕組みなら、prompt cachingはこのメモリ構造を<strong>一回の呼び出し内だけでなく、呼び出しと呼び出しの間でも再利用する</strong>という発想だ。</p>
<p>静的な接頭部（システムプロンプト、ツール定義、コード断片など、毎回ほぼ同じ冒頭部分）のK/Vを5分または1時間のTTL中GPUメモリに保持する。次の呼び出しが同じ接頭部で始まれば、その部分のK/V計算を丸ごと飛ばし、キャッシュから直接読み出して使う。正確には入力トークンの単価が魔法のように下がるのではなく、<strong>そのトークンを計算するGPUの仕事そのものが消える。</strong> 90%の値下げは、省かれた計算を料金に反映した結果なのだ。</p>
<p>この仕組みを理解すれば、「接頭部が完全一致しなければならない」という条件が厳しい理由も自然に分かる。キャッシュヒットは接頭部を累積的にハッシュ化した値が一致するかで判定されるが、自己注意機構には因果性があるため、前のトークンが一つでも変われば後続する全トークンのK/Vも変わる。プロンプトの先頭に時刻情報を一行置くだけでも、呼び出すたび値が変わって接頭部のハッシュが一致せず、それ以降のキャッシュ全体が無効になる。</p>
<p>Anthropicのキャッシュは<code>tools</code> → <code>system</code> → <code>messages</code>の階層順に読み込まれる。したがって先頭側のツール定義が一つ変わるだけでも、その後のキャッシュ全体が無効になる。結論は単純だ。<strong>静的なものを前に、毎回変わる動的なものを後ろに置けばキャッシュが生きる。</strong></p>
<p>キャッシュにはあまり知られていない制約もある。キャッシュ可能な最小トークン数はモデルごと、同じ系列内でもバージョンごとに異なる。それより短いプロンプトはキャッシュを有効にしても、通知なく保存されない。（この最小トークン数と提供元ごとのキャッシュ方針はコスト設計に直結するため、トークン節約術の記事で料金表とともに詳しく扱った。）</p>
<h2 id="コンテキストウィンドウがトークン上限を生む理由"><a class="anchor" href="#コンテキストウィンドウがトークン上限を生む理由">コンテキストウィンドウがトークン上限を生む理由</a></h2>
<p>最後に、トークンを語るうえで欠かせない概念がコンテキストウィンドウである。モデルが一度に受け取れるトークン数の上限を指す。なぜこの上限があるのかを理解すれば、「コンテキストが長いほど高くなる」というよくある警告の正体も明らかになる。</p>
<p>鍵は自己注意機構の計算構造にある。注意機構では、すべてのトークンがほかのすべてのトークンを一度ずつ参照する。トークンがn個ならトークンの組はn × n、つまり<strong>自己注意機構の計算量とメモリは系列長nの二乗に比例して増える。</strong> トークンが2倍になれば注意機構のコストは4倍になる。（モデル全体がO(n²)なのではなく、注意機構の段階がそうである点は明記しておきたい。）</p>
<p><picture><source type="image/webp" srcset="/content/260610/5.330w.webp 330w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/5.png" alt="すべてのトークンがほかのすべてのトークンを参照し、n個のトークンに対して組がn×nに増える自己注意行列" width="330" height="304" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>この二乗増加がどれほど急かは、<a href="https://huggingface.co/docs/transformers/en/llm_tutorial_optimization" target="_blank" rel="noopener noreferrer">Hugging Faceの推計</a>がよく示している。最適化なしの標準的な注意機構では、注意スコア行列を保存するだけでも、入力1,000トークンなら約50MBで済むが、16,000トークンでは約19GB、100,000トークンではほぼ1TB必要になる。</p>
<p>さらに、先ほどのKV cacheもトークン数に比例してメモリを消費する。コンテキストが長くなるほど保持すべきK/Vが線形に積み上がる。つまりコンテキストウィンドウの上限とは、「これ以上長くなるとメモリと計算が耐えられない」という物理的な限界にモデルが引いた線なのだ。</p>
<p>だから長い会話をそのまま引き継ぐことは、単に入力トークン数を増やすだけの問題ではない。注意機構のコストは二乗で増え、トークン数が上限に近づくほどモデルが情報を正確に扱う能力も落ちる。（この精度低下は、節約術の記事で扱ったコンテキスト劣化と直結する。コンテキストを軽く保つことがコストと正答率の両方に効く理由がここにある。）</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>ここまでを整理すると、トークンの仕組みはいくつかの単純な事実に集約できる。<strong>トークンはBPEが頻出パターンをまとめて作った語彙の単位</strong>であり、トークンIDを経てembeddingベクトルへ変換され、モデルに入る。<strong>入力は並列prefillでまとめて処理できるが、出力は一トークンずつ絞り出すため高く</strong>、その非効率を和らげるのがK/Vを再利用するKV cacheだ。<strong>キャッシュが単価を下げるのは魔法ではなく、Transformerが計算済みのK/Vを再計算しないからであり</strong>、接頭部が完全一致しなければならない厳しい条件もこの構造から直接導かれる。そしてコンテキストが長いほど高くなるのは、注意機構のコストがトークン数の二乗で増えるためだ。</p>
<p>この基礎を知れば、コストの話はずっと明快になる。出力がなぜ高いのか、キャッシュを壊さないためプロンプトをどう配置すべきか、なぜコンテキストを軽く保つべきか、同じ答えならなぜ安いモデルへ移るべきかといった節約戦略は、すべてこの原理の上に立っているからだ。仕組みを知りたくてここまで読んだ方には、続けてトークン節約術を読み、これらの原理が実際のコストをどう削減するか確かめてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://en.wikipedia.org/wiki/Byte-pair_encoding" target="_blank" rel="noopener noreferrer">Philip Gage, A New Algorithm for Data Compression (1994)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/1508.07909" target="_blank" rel="noopener noreferrer">Sennrich, Haddow, Birch, Neural Machine Translation of Rare Words with Subword Units (ACL 2016)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://jalammar.github.io/illustrated-transformer/" target="_blank" rel="noopener noreferrer">Jay Alammar, The Illustrated Transformer</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/learn/nlp-course/en/chapter6/5" target="_blank" rel="noopener noreferrer">HuggingFace, Byte-Pair Encoding tokenization</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/docs/transformers/en/kv_cache" target="_blank" rel="noopener noreferrer">HuggingFace, Transformers KV Cache strategies</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/openai/tiktoken" target="_blank" rel="noopener noreferrer">OpenAI, tiktoken</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://openai.com/index/hello-gpt-4o/" target="_blank" rel="noopener noreferrer">OpenAI, Hello GPT-4o (Language tokenization)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.artfish.ai/p/all-languages-are-not-created-tokenized" target="_blank" rel="noopener noreferrer">Yennie Jun, All languages are NOT created (tokenized) equal</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices" target="_blank" rel="noopener noreferrer">Databricks, LLM Inference Performance Engineering Best Practices</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.baseten.co/blog/llm-transformer-inference-guide/" target="_blank" rel="noopener noreferrer">Baseten, A guide to LLM inference and performance</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic, Prompt Caching</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>トークン</category>
        </item>
        <item>
            <title><![CDATA[コンテキストファイル]]></title>
            <link>https://hooninedev.com/ja/260529</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260529</guid>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、AIコーディングエージェントが読み込むコンテキストファイルについて話してみたい。 一つのプロジェクトにCLAUDE.md、AGENTS.md、SKILL.md、.cursor/rulesが積み重なっていて、それぞれがいつ読まれ、何を書くべきなのかがわからなくなっている開発者に向けた記事だ。最後まで読めば、ファイルごとの違いと、エージェントがそのファイルを読み込む仕組み、そしてETH...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>AIコーディングエージェントが読み込むコンテキストファイル</strong>について話してみたい。</p>
<p>一つのプロジェクトに<code>CLAUDE.md</code>、<code>AGENTS.md</code>、<code>SKILL.md</code>、<code>.cursor/rules</code>が積み重なっていて、それぞれがいつ読まれ、何を書くべきなのかがわからなくなっている開発者に向けた記事だ。最後まで読めば、ファイルごとの違いと、エージェントがそのファイルを読み込む仕組み、そしてETH Zurichの研究にもとづく「推論できない情報だけを書く」という判断基準が得られる。</p>
<p>筆者はフロントエンド開発者として働きながら、日常的にClaudeを活用している。そうしているうちに、いつの間にかプロジェクトルートに<code>CLAUDE.md</code>ができ、その隣には誰かが作った<code>AGENTS.md</code>があり、<code>.cursorrules</code>も片隅に残り、どこかで読んだ記事に倣って<code>.claude/skills/</code>フォルダまで作るようになった。（気がつけば、似たような内容を書いたファイルが5つほどできていた。）</p>
<h2 id="永続メモリを持たないエージェント"><a class="anchor" href="#永続メモリを持たないエージェント">永続メモリを持たないエージェント</a></h2>
<p>AIコーディングエージェントには、<strong>永続的な記憶がない</strong>という根本的な制約がある。すべてのセッションは空の状態から始まり、昨日合意した規約や1時間前に伝えたフォルダ構成を、次の会話では覚えていない。コンテキストファイルは、この問題を解決する最も単純な仕組みだ。セッションの開始時に毎回自動で読み込まれるファイルをプロジェクトに置けば、同じ説明を何度も繰り返さずに済む。</p>
<p>問題は、同じ発想から生まれたファイルがツールごとに別々に作られたことだ。Claude Codeは<code>CLAUDE.md</code>を、Cursorは<code>.cursorrules</code>（現在はdeprecatedとなり、<code>.cursor/rules</code>の使用が推奨されている）を、GitHub Copilotは<code>.github/copilot-instructions.md</code>を、OpenAI Codexは<code>AGENTS.md</code>を読む。チームで複数のツールを使えば、同じ内容を4か所にコピーしなければならない状況になる。</p>
<h3 id="claudemd"><a class="anchor" href="#claudemd">CLAUDE.md</a></h3>
<p><code>CLAUDE.md</code>は、Claude Codeがセッション開始時に自動で読み込むファイルだ。Anthropicの公式ドキュメント（<code>code.claude.com/docs/en/memory</code>）によると、Claude Codeは次の3階層から<code>CLAUDE.md</code>を探す。</p>
<ul>
<li><strong>ユーザーメモリ</strong>（<code>~/.claude/CLAUDE.md</code>）：マシン上のすべてのプロジェクトに適用されるグローバルなデフォルト</li>
<li><strong>プロジェクトメモリ</strong>（プロジェクトルートの<code>CLAUDE.md</code>）：Gitにコミットされ、チーム全体で共有</li>
<li><strong>ローカルメモリ</strong>（サブディレクトリの<code>CLAUDE.md</code>）：そのディレクトリで作業するときだけ追加で読み込まれる</li>
</ul>
<p>3階層がすべて存在する場合、Claudeは<strong>すべてを読み込んで連結</strong>（concatenate）する。優先順位によって一つだけを選ぶのではなく、CSSのcascadeのように、より具体的なものが追加で重なる構造だ。（オーバーライドではなくマージである。）したがって、同じテーマのルールを複数の階層に分散させると競合する可能性がある。（Anthropicの公式ドキュメントも、競合時の動作は保証されないと明記している。）</p>
<p>ここで見落とされがちな点が一つある。<strong>現在の作業ディレクトリからリポジトリルートまで遡り、途中にあるすべての<code>CLAUDE.md</code>を読む</strong>という点だ。そのため、モノレポの<code>packages/ui/</code>に入って作業すると、ルートの<code>CLAUDE.md</code>と<code>packages/ui/CLAUDE.md</code>が両方読み込まれる。（これは強力だが、同時にコンテキストが気づかないうちに膨らむ可能性も意味する。）</p>
<h3 id="agentsmd"><a class="anchor" href="#agentsmd">AGENTS.md</a></h3>
<p><code>AGENTS.md</code>は、前述したツール別ファイルの乱立を解消するために作られた標準だ。2025年12月、OpenAIはこの標準をLinux Foundation傘下の<strong>Agentic AI Foundation</strong>（AAIF）へ寄贈した。<a href="https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Linux Foundationの発表</a>によると、AAIFはAnthropic・Block・OpenAIが共同で創設し、創設プロジェクトはAnthropicのMCP（エージェントを外部システムにつなぐプロトコル）、Blockのgoose、OpenAIの<code>AGENTS.md</code>の3つだ。公式サイト（<code>agents.md</code>）では、<strong>6万以上のオープンソースリポジトリがこのファイルを採用している</strong>と明記されている。</p>
<p>対応ツールの一覧を見れば、さらに明確だ。OpenAI Codex、Google Jules、VS Code、GitHub Copilot、Cursor、JetBrains Junie、Aider、Devin、Zed、Factory、Warp、goose、opencode、Amp、RooCode、Gemini CLI、Kilo Code、Phoenix、Semgrep、Ona、Windsurf、Augment Codeなど、数多くのツールが対応している。GitHub Copilotは2025年8月から<code>AGENTS.md</code>をネイティブサポートし始めた。興味深いのは、<strong>Claude Codeによる<code>AGENTS.md</code>のネイティブサポートは、いまだactive feature requestの状態</strong>だという点だ。Claude Codeは今も<code>CLAUDE.md</code>を第一のファイルとして扱う。</p>
<p>標準とはいっても、本当に採用が進んでいるのか疑わしく思えるかもしれない。最も強力な証拠は、<strong>dogfooding</strong>（自分たちが作った標準を自ら使うこと）だ。</p>
<ul>
<li><strong>Vercel/Next.js</strong>のcanaryブランチのルートには<code>AGENTS.md</code>がある。実際には<code>CLAUDE.md</code>を指すシンボリックリンクだが、その中にはモノレポ構成、<code>pnpm --filter=next dev</code>による1〜2秒単位の反復、TurbopackとWebpack双方のテストガイド、<code>pr-status</code>スクリプト、環境変数やシークレットの扱いに関するルールまで含まれている。<code>create-next-app</code>が新規プロジェクトに<code>AGENTS.md</code>と<code>CLAUDE.md</code>を一緒に生成するようになったのも、同じ流れだ。</li>
<li><strong>OpenAI/codex</strong>リポジトリ自体が、独自の<code>AGENTS.md</code>を運用している。</li>
</ul>
<p>戦略としては、次の運用が定石として定着しつつある。<strong><code>AGENTS.md</code>を単一の情報源（single source of truth）とし</strong>、<code>CLAUDE.md</code>は最小限に抑え、<code>AGENTS.md</code>を参照する1行とClaude Code固有の指示だけを書く方法だ。これなら重複がなくなり、Claude Codeは両方のファイルを読むため、失うものもない。</p>
<h3 id="skillmd"><a class="anchor" href="#skillmd">SKILL.md</a></h3>
<p><code>SKILL.md</code>は、前の2つとは性質が異なる。<code>CLAUDE.md</code>と<code>AGENTS.md</code>が<strong>常にコンテキストに存在する永続的な指示</strong>であるのに対し、スキル（Skill）は<strong>必要なときだけ呼び出されるオンデマンドの能力</strong>だ。</p>
<p>スキルはフォルダ単位で構成される。フォルダの中には、1つの<code>SKILL.md</code>と、そのスキルが実行するスクリプト、追加のMarkdownドキュメントが入る。Claudeは、現在のタスクがスキルの<code>description</code>と一致するときだけ、そのフォルダを読み込む。これを<strong>progressive disclosure</strong>（段階的開示）と呼ぶ。これは1995年にUX分野でJakob Nielsenが確立した概念で、高度な機能や使用頻度の低い機能を補助画面に移し、ユーザーが一度に一つの作業だけに集中できるようにして、認知負荷とエラーを減らす手法だ。Claude Skillsの文脈では、「必要なときだけ、そのスキルの本文をコンテキストへ取り込む」仕組みを指す。その結果、コンテキストウィンドウのコストを劇的に節約できる。</p>
<p><code>SKILL.md</code>のfrontmatterには、いくつか固有のフィールドがある。</p>
<ul>
<li><strong><code>description</code></strong>：どのような状況でこのスキルが必要かを説明する。モデルが呼び出すかどうかを判断するトリガーになる</li>
<li><strong><code>allowed-tools</code></strong>：スキル内で利用できるツールを制限する（例：<code>"Read, Glob, Grep, Bash(python:*)"</code>）</li>
<li><strong><code>disable-model-invocation: true</code></strong>：モデルからは呼び出せず、ユーザーだけがスラッシュコマンドでトリガーできる。副作用のある作業（デプロイ・コミットなど）に使う</li>
<li><strong><code>user-invocable: false</code></strong>：ユーザーのスラッシュメニューには表示されず、Claudeだけが自律的に呼び出し、背景知識として利用する</li>
</ul>
<p>Claude Skillsは2025年10月16日、Claude.ai、Claude Code、API、Agent SDKで同時にリリースされた。そして2025年12月18日、AnthropicはSkillsの仕様そのものをオープン標準（<code>agentskills.io</code>）として公開した。Simon Willisonは「<strong>Skills are awesome, maybe a bigger deal than MCP</strong>」と評価している。その理由は、形式がMCPよりも劇的に単純でありながら、コンテキストウィンドウのコスト問題をprogressive disclosureで解決している点にあった。</p>
<p>ここでSkillsと比較されたMCP（Model Context Protocol）は、エージェントがSlack、GitHub、DBのような外部システムを呼び出せるようにつなぐ標準プロトコルだ。コンテキストファイルがエージェントに何を知らせるかの問題なら、MCPは何をできるようにするかの問題だ。MCPがfunction callingと何が違うのかは、<a href="/260524" target="_blank" rel="noopener noreferrer">MCPとfunction calling</a>に別途まとめておいた。</p>
<p>エージェントが関連コードを探すコストを減らすツール（Repomix、Aider、CodeGraph、Serena）は、コードをどこまで深く理解するかによって、減らせるコストが異なる。その比較は<a href="/260526" target="_blank" rel="noopener noreferrer">コードインテリジェンスの4つの階層</a>に別途まとめておいた。</p>
<h3 id="ほかのツールのファイル"><a class="anchor" href="#ほかのツールのファイル">ほかのツールのファイル</a></h3>
<p>Cursorの<code>.cursorrules</code>は、<strong>バージョン0.43からdeprecated</strong>となった。現在、公式には<code>.cursor/rules/</code>ディレクトリ内に複数の<code>.mdc</code>ファイルを置く方法が推奨されている。各<code>.mdc</code>ファイルはYAML frontmatterを持つ。</p>
<ul>
<li><strong><code>description</code></strong>：エージェントがこのルールの関連性を判断するときに参照する</li>
<li><strong><code>globs</code></strong>：一致するファイルが会話に含まれたとき、自動で添付（auto-attach）される</li>
<li><strong><code>alwaysApply</code></strong>：<code>true</code>なら、すべての会話に必ず含まれる（この場合、<code>globs</code>は無視される）</li>
</ul>
<p>GitHub Copilotも同じような方向へ進化した。リポジトリ全体への指示は<code>.github/copilot-instructions.md</code>に置き、パスごとのスコープが必要な指示は<code>.github/instructions/*.instructions.md</code>ファイルを作成して、frontmatterの<code>applyTo:</code>キーでglobを指定する。（Copilot code reviewは2025年9月からpath-scoped instructionsを正式にサポートしている。）</p>
<p>Cursor・Copilot以外のツールも、すべて似たパターンへ収束している。表にまとめると次のようになる。</p>
<table>
<thead>
<tr>
<th>ツール</th>
<th>ファイル／ディレクトリ</th>
<th>特徴</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Claude Code</strong></td>
<td><code>CLAUDE.md</code>（3階層）</td>
<td>ディレクトリツリーに沿ってマージ</td>
</tr>
<tr>
<td><strong>Cursor</strong></td>
<td><code>.cursor/rules/*.mdc</code></td>
<td><code>globs</code>でファイルパターンをスコープ化</td>
</tr>
<tr>
<td><strong>GitHub Copilot</strong></td>
<td><code>.github/copilot-instructions.md</code> + <code>.github/instructions/*.instructions.md</code></td>
<td><code>applyTo</code> globをサポート</td>
</tr>
<tr>
<td><strong>Cline</strong></td>
<td><code>.clinerules/</code>ディレクトリ</td>
<td>すべての<code>.md</code>／<code>.txt</code>を統合し、<code>paths</code> globで条件付き有効化</td>
</tr>
<tr>
<td><strong>Continue.dev</strong></td>
<td><code>.continue/rules/*.md</code></td>
<td><code>name</code>／<code>globs</code>／<code>alwaysApply</code> frontmatter</td>
</tr>
<tr>
<td><strong>Aider</strong></td>
<td><code>CONVENTIONS.md</code> + <code>.aider.conf.yml</code></td>
<td>リクエストごとに含まれ、<strong>200行以内を推奨</strong></td>
</tr>
<tr>
<td><strong>Windsurf</strong></td>
<td><code>.windsurfrules</code> + <code>global_rules.md</code></td>
<td>グローバルとプロジェクトの2段階</td>
</tr>
<tr>
<td><strong>標準</strong></td>
<td><code>AGENTS.md</code>（AAIF）</td>
<td>60,000以上のリポジトリが採用</td>
</tr>
</tbody>
</table>
<p>なかでも、<strong>Aiderの<code>CONVENTIONS.md</code>は興味深い。</strong> 公式ドキュメントには、リクエストのたびにこのファイル全体をコンテキストへ含めるため、「<strong>200行以内に保つこと</strong>」と明記されている。（Aiderは、この制約を早くから認識し、ユーザーへ明示的に伝えているわけだ。）</p>
<h3 id="memorymd"><a class="anchor" href="#memorymd">MEMORY.md</a></h3>
<p>これまでのファイルとは別に、最近よく見かけるようになったパターンがもう一つある。<code>MEMORY.md</code>だ。公式標準ではないが、コミュニティから自然発生した慣習であり、<strong>時間の経過に伴う意思決定と失敗を記録する</strong>ために使われる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="markdown" data-theme="github-dark github-light"><code data-language="markdown" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-10</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Pages Router에서 App Router로 이전. 신규 라우트는 App Router 컨벤션 사용.</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-22</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Prisma 쿼리 결과에 optional chaining 쓰지 말 것 — null은 if-check로 명시적 처리.</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(이전에 옵셔널 체이닝으로 null을 흘려보내 프로덕션 이슈 발생.)</span></span></code></pre></figure>
<p><code>CLAUDE.md</code>や<code>AGENTS.md</code>が<strong>現時点のルール</strong>を書く場所なら、<code>MEMORY.md</code>は<strong>そのルールがなぜ作られたのかという歴史</strong>を書く場所だ。（両者は補完関係にあり、代替関係ではない。）</p>
<h3 id="エージェントはこれらのファイルをどう読むのか"><a class="anchor" href="#エージェントはこれらのファイルをどう読むのか">エージェントはこれらのファイルをどう読むのか</a></h3>
<p>ここまで、どのようなファイルがあるのかを整理してきた。しかし、意外に見落とされやすい問いが一つある。<strong>エージェントはこれらのファイルを、正確にはどこへ、どのように読み込むのだろうか。</strong> 実はこの仕組みを理解すると、後で扱うETH Zurichの結果（コンテキストファイルの指示があまり守られないという結果）が、よりすっきりと理解できる。</p>
<p>まず押さえておくべき事実が一つある。<strong><code>CLAUDE.md</code>はsystem promptではなく、user messageとして注入される。</strong> Anthropicの公式ドキュメントには、次のように明記されている。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>CLAUDE.mdの内容はシステムプロンプトの一部ではなく、システムプロンプトの後にユーザーメッセージとして渡される。Claudeはそれを読み、従おうとするが、厳密な遵守が保証されるわけではない。</p></div><div class="quote-original" lang="en"><p>CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance.</p></div></blockquote>
<p>つまり、強制ルールではなく「参考用コンテキスト」にすぎない。特定の動作を確実に強制したいなら、<code>PreToolUse</code>フックのような別の仕組みを使うべきだと、公式ガイドでも推奨されている。</p>
<p>読み込み順は、broad → specificの順で積み重なる。具体的には、managed policy（組織レベルの設定）→ ユーザーグローバル（<code>~/.claude/CLAUDE.md</code>）→ プロジェクト（<code>./CLAUDE.md</code>）→ ローカル（<code>./CLAUDE.local.md</code>）の順だ。同じディレクトリでは、<code>CLAUDE.md</code>の後に<code>CLAUDE.local.md</code>が続く。<strong>最も近い場所の指示が最後に読まれる</strong>ことを利用すれば、LLMのrecency biasによって、より具体的なルールが強く働く効果を期待できる。</p>
<p>ここで興味深いのが<code>@import</code>構文だ。CLAUDE.mdの本文中に<code>@path/to/file</code>と書くと、そのファイルがその位置に展開され、一緒に読み込まれる。<strong>再帰の最大深度は4 hops</strong>まで許可され、相対パスはimport文が書かれたファイルを基準に解決される。そのため、公式には<code>@AGENTS.md</code>で橋渡しする方法が推奨されている。<code>CLAUDE.md</code>をほぼ空にして<code>@AGENTS.md</code>の1行だけを書けば、Claude Codeも自然にAGENTS.mdを読む。（CLAUDE.mdがまだAGENTS.mdをネイティブサポートしていない現状では、最もすっきりした回避策だ。）</p>
<p>トークンの観点も確認しておこう。CLAUDE.md自体には明示的なトークン上限がなく、<strong>存在する内容はすべて読み込まれる</strong>。ただし、公式の推奨は<strong>1ファイル200行以内</strong>だ。200行を超えると「consume more context and may reduce adherence」と明記されている。興味深いことに、Claude 4.xでは<strong>tool useを有効にするだけでspecial system promptが自動的に346トークン増える</strong>（<code>tool_choice: auto</code>の場合）。コンテキストは、知らないうちに少しずつ消費されているのだ。</p>
<p>Cursorは別の方法を採る。<code>.cursor/rules/*.mdc</code>のルールは、次の4タイプで動作する。</p>
<ul>
<li><strong>Always Apply</strong>：すべてのチャットに必ず含まれる。globs／descriptionは無視される</li>
<li><strong>Apply Intelligently</strong>（Agent Requested）：エージェントが<code>description</code>を読み、関連性を判断して利用する</li>
<li><strong>Apply to Specific Files</strong>（Auto Attached）：globパターンに一致するファイルがコンテキストに入ったときに有効化される</li>
<li><strong>Apply Manually</strong>：<code>@rule-name</code>でユーザーが明示的に呼び出す</li>
</ul>
<p>ほかのツールは、さらに異なる。OpenAI CodexはGitリポジトリのルートからcwdへ向かってwalkし、すべての<code>AGENTS.md</code>を収集して<strong>ユーザープロンプトの直前</strong>に注入する。GitHub Copilotは<code>.github/copilot-instructions.md</code>を、「edit contextとexplicit referencesの後、loosely related open filesより前」という、コンテキストウィンドウの中間的な優先位置に挿入する。同じ<code>AGENTS.md</code>ファイルでも、読み込み時点、優先順位、マージルールはツールごとに異なるため、<strong>3つのツールがまったく同じ方法でそのファイルを見る保証はない。</strong></p>
<p>しかし、ここには一つ根本的な問いが残る。<strong>なぜモデルは、コンテキストにある指示の一部しか守らないのだろうか。</strong> 単に「指示が長いから」という説明では不十分だ。この現象の根底には、LLMの構造的な限界がある。</p>
<h3 id="ハルシネーションとコンテキスト忘却"><a class="anchor" href="#ハルシネーションとコンテキスト忘却">ハルシネーションとコンテキスト忘却</a></h3>
<p>AIエージェントが会話の文脈を混同したり、前に明確に伝えた内容を後になって忘れたりする経験があるなら、それはまさに<strong>ハルシネーション（Hallucination）<strong>の一種だ。一般にハルシネーションといえば「存在しない事実を作り出すこと」がまず思い浮かぶが、学術的には3つに分類される。Yue Zhangらの研究チームによる2023年のサーベイ「Siren's Song in the AI Ocean」では、これを</strong>入力競合型</strong>（ユーザーが明示した内容と異なる生成）、<strong>文脈競合型</strong>（以前に自ら生成した内容との矛盾）、<strong>事実競合型</strong>（世界知識との不一致）に分けている。コンテキストファイルの指示を無視する現象は、3番目ではなく<strong>1番目のタイプ</strong>だ。モデルが入力を処理するとき、その中の一部の情報を「存在しなかったかのように」扱うのである。</p>
<p>さらに根本的な問題は、このハルシネーションを<strong>原理的に排除できない</strong>ことだ。シンガポール国立大学の研究チームは、学習理論を用いてこれを数学的に証明した。どのLLMもすべての計算可能関数を学習することはできず、したがって汎用問題解決器として使う限り、どこかで必ずハルシネーションが生じるという。</p>
<p>位置効果も重要だ。Stanfordの研究チームは、関連情報が<strong>コンテキストウィンドウの先頭または末尾にあるとき</strong>、モデルが最もよく参照し、<strong>中央に埋もれたとき</strong>に性能が大きく低下することを実験で示した。これはコンテキストファイルに直接関係する。<code>CLAUDE.md</code>は読み込み順の中ほどに挿入され、会話が長くなるほど、その指示はコンテキストの「中央」へ押し込まれていく。先ほど触れたrecency bias（新しい情報ほどよく従う傾向）の反対側、つまり<strong>primacy-recency効果において中央部分が最も弱い</strong>という事実ともつながっている。</p>
<p>これらの現象をまとめると、一つの図が見えてくる。コンテキストファイルは、LLMの<strong>最初のuserターンより前に、システムの外から差し込まれるテキスト</strong>にすぎない。モデルの決定を強制する仕組みではなく、コンテキストウィンドウに置かれる、もう一つのトークンの塊なのだ。長いほど、そして会話が長引くほど、指示は「中央」へ押し込まれ、参照率が下がる。ETH Zurichの結果は、この構造的な限界を定量的に確認したものだといえる。</p>
<h3 id="eth-zurichの研究"><a class="anchor" href="#eth-zurichの研究">ETH Zurichの研究</a></h3>
<p>多くの人は、「それなら、このファイルにできるだけ多く書いておけばよいのでは」と考えただろう。しかし、その直感に真正面から反論する研究が最近発表された。先ほどから触れてきたETH Zurichの研究である。</p>
<p>ETH Zurichの研究チームが2026年2月に発表した論文「Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents?」だ。138件の実際のPythonソフトウェアエンジニアリングタスクのベンチマーク（AGENTBENCH）とSWE-bench Liteを使い、Claude Code（Sonnet-4.5）、Codex（GPT-5.2／GPT-5.1 mini）、Qwen Codeの4つのエージェントで測定したところ、意外な結果が得られた。</p>
<ul>
<li><strong>LLMが自動生成したコンテキストファイル</strong>は、SWE-bench Liteで約0.5%、AGENTBENCHで約2%、<strong>タスク成功率をむしろ低下させた</strong></li>
<li><strong>人間が直接書いたファイル</strong>でさえ、平均約4%のわずかな改善にとどまった</li>
<li>コンテキストファイルを追加すると、<strong>推論コストがインスタンス当たり20%以上増加</strong>した</li>
<li>より強力なモデル（GPT-5.2）では、コンテキストファイルの効果がさらに小さかった（強力なモデルほどパラメトリック知識が十分で、追加コンテキストがノイズとして働く）</li>
</ul>
<p>ただし、例外が一つあった。<strong>非標準ツールを明記した場合</strong>だ。たとえば、Pythonのパッケージマネージャーである<code>uv</code>をコンテキストに明記すると、エージェントが<code>uv</code>を使う頻度がインスタンス当たり0.01回から1.6回へと、<strong>約160倍に増加した</strong>。</p>
<p>先ほど扱ったAiderの「200行推奨」「毎回コンテキストに入るため短く保つこと」という案内は実用的な指針であり、ETH Zurichの研究は「長いコンテキストファイルが統計的に性能を下げる」ことを定量的に示した。筆者が考える、この研究の実践的な示唆は次のとおりだ。</p>
<ul>
<li><strong>自動生成された巨大なコンテキストファイルは、役立つより害になる可能性がある</strong>。300行の<code>CLAUDE.md</code>にコーディング規約・アーキテクチャ・ワークフローをすべて詰め込むと、エージェントは一部だけに従い、残りを無視する。その不整合は、コンテキストがない場合より悪い結果につながり得る。</li>
<li><strong>必ず書くべきなのは「推論できない情報」だ。</strong> 非標準ツール、プロジェクト固有の規約、過去の失敗事例などが該当する。一般的なコーディングのベストプラクティスは、モデルがすでに知っている。</li>
<li>AGENTS.mdを単一の情報源とし、CLAUDE.mdにはツール固有の短い指示だけを書き、詳細なワークフローはSkillへ分離する。</li>
</ul>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>コンテキストファイルは、ツールごとに名前も、置き場所も、読み込まれるタイミングも異なる。この記事で見たものだけでも、CLAUDE.md、AGENTS.md、SKILL.md、Cursor rules、copilot-instructions.md、MEMORY.mdがあり、どのツールがどのファイルに対応しているかも変わり続けている。ファイルの一覧を覚えておくだけでは、すぐに古くなる。</p>
<p>そこで筆者がこの記事で目指したのは、特定のファイル形式を薦めることではなく、<strong>ファイルがエージェントにどう読まれるのかを見る目</strong>を養うことだった。CLAUDE.mdがなぜuser messageとして注入されるのか、同じAGENTS.mdをツールごとになぜ異なる形で読むのか、指示がなぜコンテキストの中間で弱まるのかを理解すれば、新しいコンテキストファイルの形式が登場したときにも、「これはいつロードされ、どれくらい強く作用するのか」を素早く読み解ける。</p>
<p>最後に残るのは、ETH Zurichの研究が示した一つの直感だ。<strong>モデルはすでに多くのことを知っている。</strong> コンテキストファイルへ何もかも詰め込んだからといって、エージェントがよりよく従うわけではない。モデルが知らない可能性の高いもの、つまりプロジェクト固有の規約、非標準ツール、過去の失敗だけを残し、それ以外を取り除くほうがよい。コンテキストファイルを長く書くことと、うまく書くことは別の問題なのだ。</p>
<p>この記事を読んだ方にも、今すぐCLAUDE.mdを数百行へ増やすのではなく、いま使っているツールがそのファイルをいつ、どこに、どれくらい強く読み込むのか、一度掘り下げてみることを勧めたい。それが、ファイル形式がどう変わっても揺らがない土台になると考えている。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/memory" target="_blank" rel="noopener noreferrer">Claude Code Memory, Anthropic</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">Anthropic Tool Use Overview</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://cursor.com/docs/context/rules" target="_blank" rel="noopener noreferrer">Cursor Rules Documentation</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2602.11988" target="_blank" rel="noopener noreferrer">ETH Zurich, "Evaluating AGENTS.md" (2602.11988)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2309.01219" target="_blank" rel="noopener noreferrer">Yue Zhang et al., "Siren's Song" (2309.01219)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2401.11817" target="_blank" rel="noopener noreferrer">Ziwei Xu et al., "Hallucination is Inevitable" (2401.11817)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2307.03172" target="_blank" rel="noopener noreferrer">Nelson F. Liu et al., "Lost in the Middle" (2307.03172)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://simonwillison.net/2025/Oct/16/claude-skills/" target="_blank" rel="noopener noreferrer">Simon Willison, "Claude Skills are awesome"</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>開発ツール</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[コードインテリジェンスの4つの階層]]></title>
            <link>https://hooninedev.com/ja/260526</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260526</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の投稿では、AIコーディングエージェントが関連コードを探すコストを減らすツール同士が、互いに何が違うのかについて話してみようと思う。 大きなコードベースでエージェントがgrepとファイル読み込みを繰り返してトークンを使うのを見て、Repomix、CodeGraph、Serenaのようなツールのうちどれを組み込むべきか悩んでいる開発者に向けた記事だ。これらのツールはコードをどれだけ深く理解するか...]]></description>
            <content:encoded><![CDATA[<p>今回の投稿では、<strong>AIコーディングエージェントが関連コードを探すコストを減らすツール同士が、互いに何が違うのか</strong>について話してみようと思う。</p>
<p>大きなコードベースでエージェントがgrepとファイル読み込みを繰り返してトークンを使うのを見て、Repomix、CodeGraph、Serenaのようなツールのうちどれを組み込むべきか悩んでいる開発者に向けた記事だ。これらのツールはコードをどれだけ深く理解するかによって、context packing、tree-sitter repo map、ナレッジグラフ、LSPの4つの階層に分かれ、階層ごとに探索コストを減らす箇所が異なる。ナレッジグラフは開発元のベンチマークを、残りの3つの階層はこのブログのリポジトリで測った結果を載せる。</p>
<p><code>codegraph</code>がGitHub Trendingに載ったのを見て真似してインストールしてみて以来、筆者は新しいツールを見るたびに、それがどんな原理でトークンを節約しているのかが気になっていた。この記事に出てくるツールの多くも、筆者が普段weekly単位でTypeScriptとPythonに絞って眺めているGitHub Trendingで初めて知ったものだ。</p>
<h2 id="コードインテリジェンスツール"><a class="anchor" href="#コードインテリジェンスツール">コードインテリジェンスツール</a></h2>
<p>エージェントはコードを直す前に、まず関連コードがどこにあるかを探す。grepで探し、ファイルを読み、絞り込み、またgrepするループだ。後で見るCodeGraphのベンチマークでは、ツールなしで答えた側は質問1つにtool callを最大43回使った。コードインテリジェンスツールは、この探索コストを減らそうとする試みである。</p>
<p>ただし、「コスト」という言葉が指すものは1つではない。モデルが処理したトークン、tool callの回数、そして作業が終わった後もcontext windowに残っているトークンは、それぞれ別々に動く。この記事では、各階層がこの3つのうち何を減らすのかを見る。</p>
<p>筆者はツールがコードをどれだけ深く理解するかを基準に、これらの試みを以下の4つの階層(tier)に分けて捉えている。業界で決まった分類はなく、この区分は筆者が整理したものだ。この記事の実測値はすべてこのリポジトリのコミット <code>36e5cfa</code> で2026-10-08に得たもので、トークンはtiktokenの <code>o200k_base</code> で数えた。Claudeのトークナイザーとは値が異なるので、絶対値よりも比較に使う。</p>
<h3 id="コンテキストパッキング"><a class="anchor" href="#コンテキストパッキング">コンテキストパッキング</a></h3>
<p>最も単純な解決策は、「<strong>全部を1つのcontext windowに入れてしまおう</strong>」という発想だ。グラフも作らず、インデックスも作らない。リポジトリ全体をテキストの塊としてシリアライズし、モデルに丸ごと渡す。</p>
<p>代表的なツールが<strong>Repomix</strong>だ。デフォルトの出力形式はXMLで、READMEはAnthropicのXMLタグに関するドキュメントへのリンクを載せている。CLI、Web、ブラウザ拡張、MCPサーバーまで揃っている。</p>
<p><strong>GitIngest</strong>はインストールなしで使えるのが強みだ。GitHubのURLの <code>github.com</code> を <code>gitingest.com</code> に変えると、リポジトリ全体が1枚のテキストページになる。RustのCLIである<strong>code2prompt</strong>(Mufeed VH作)は、テンプレートで出力形式を変えられる。</p>
<p><strong>rtk</strong>(<code>rtk-ai/rtk</code>)は少し方向が違う。上のツールがリポジトリ全体を一度にパッキングするのに対し、rtkはエージェントが実行したCLIコマンドの出力を圧縮する。Rustで作られた単一バイナリで、Claude Code、Cursor、Copilot、Gemini CLI、Codexなど複数のエージェントのhookに登録され、エージェントが <code>git status</code> を呼び出すと <code>rtk git status</code> に置き換えて実行する。100以上のコマンドにfiltering、grouping、truncation、deduplicationを適用する。rtkのhookはBashのtool callだけを対象にする。Claude Codeの <code>Read</code>、<code>Grep</code>、<code>Glob</code> はそのまま素通りする。</p>
<p>減る量は注意して読む必要がある。<a href="https://github.com/rtk-ai/rtk/blob/8533612180c60efbcb5827c7db4e910aba096705/README.md#L66-L70" target="_blank" rel="noopener noreferrer">rtkのREADME</a>は、Bashの出力を最大90%減らすと書きつつ、それが請求額を90%減らすという意味ではないとすぐに付け加えている。コマンドの出力はモデルから見れば入力トークンの一部で、入力トークンは請求額の一部なので、段階ごとに薄まる。<a href="https://www.rtk-ai.app/" target="_blank" rel="noopener noreferrer">公式サイト</a>が掲げる数字は、rtkが置き換えて実行したコマンド基準で平均56%だ。上のツールが入っていくテキストを減らすのだとすれば、rtkはtool callの結果として戻ってくるテキストを減らす。</p>
<p>この階層の限界は、<strong>大きなリポジトリはトークン上限に引っかかる</strong>ことだ。このブログの <code>src/</code> のファイル109個をパッキングするだけで94,596 tokensになる。この程度ならウィンドウに収まるが、ファイルが数千個あるリポジトリならすぐ上限に達する。Repomixはこの問題に <code>--compress</code> で答える。<a href="https://github.com/yamadashy/repomix/blob/8d6429121e98ed178e4d3a975c2bdbbecc958c4a/README.md#L797-L831" target="_blank" rel="noopener noreferrer">README</a>によれば、Tree-sitterで関数とクラスのシグネチャを残し、実装の本体を捨てる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># repomix 1.18.1, 이 리포 커밋 36e5cfa, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">              # Total Tokens: 94,596 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --compress</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # Total Tokens: 30,320 tokens</span></span></code></pre></figure>
<p>68%減った。コメントはそのまま残るので、JSDocが長い <code>visit-counter.ts</code> は1,642から約1,220 tokensへと26%しか減らなかった。圧縮版にはimport文とシグネチャが残るが、関数本体の中の呼び出しは消える。どのファイルが何を取り込むかはテキストで見えるが、誰が誰を呼ぶかは問えない。そのため、この階層と次の階層の境界は、構文を見るかどうかよりも、関係を問えるかどうかにある。</p>
<h3 id="tree-sitterリポジトリマップ"><a class="anchor" href="#tree-sitterリポジトリマップ">tree-sitterリポジトリマップ</a></h3>
<p>次の階層は、<strong>tree-sitter</strong>を活用してコードの構造を解析しつつ、別途インデックスサーバーは立ち上げない方式だ。</p>
<p>この階層のツールが使うtree-sitterが作るのは、ソースコードの構造を木で表した<strong>CST</strong>(Concrete Syntax Tree)だ。<a href="https://tree-sitter.github.io/tree-sitter/" target="_blank" rel="noopener noreferrer">tree-sitter公式ドキュメント</a>もconcrete syntax treeを作ると書いている。括弧や句読点までノードとして残した木で、以下のツールが扱うのもこの木だ。</p>
<p><strong>tree-sitter</strong>は、オープンソースのパーサジェネレータであり、インクリメンタル(incremental)パースライブラリだ。<a href="https://docs.github.com/en/repositories/working-with-files/using-files/navigating-code-on-github" target="_blank" rel="noopener noreferrer">GitHubのcode navigation</a>がtree-sitterを使っている。編集された部分だけを再パースするので、エディタで1行を直してもファイル全体を再パースせず、変わった部分の木だけを直す。この利点は、編集が絶えず起きるエディタのものだ。以下のAiderは、ファイルの更新時刻でキャッシュを持ち、変わっていないファイルを再パースしない。</p>
<p>ターミナルで使うAIペアプログラミングツール<strong>Aider</strong>が、このアプローチの代表例だ。Aiderはtree-sitterでファイルごとに関数、クラス、メソッドの定義と参照を抽出する。そしてファイルをノードとするグラフを作る。ファイルAがファイルBで定義された識別子を参照すると、AからBへエッジができる。</p>
<p>このグラフで重要なファイルを選ぶのに、AiderはPageRankを使う。PageRankは、リンクを多く、そして重く受けるノードほど高いスコアを与えるアルゴリズムだ。Aiderが使うのはその変種のpersonalized PageRankで、指定したノードの側へスコアを傾ける。こうして順位の高いファイルから、定義とシグネチャをトークン予算の分だけ入れる。</p>
<p>何が予算に入るかは、いまの会話によって変わる。Aiderの<a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/repomap.py#L487-L525" target="_blank" rel="noopener noreferrer"><code>repomap.py</code></a>では、エッジの重みは参照回数の平方根に倍率を掛けた値だ。会話に出た識別子は10倍、8文字以上のcamelCase、snake_case、kebab-caseの識別子も10倍だ。<code>_</code> で始まる識別子と、5つを超えるファイルで定義された識別子はそれぞれ0.1倍で、いまチャットに追加したファイルから出ていくエッジは50倍になる。チャットに追加したファイル、会話で言及したファイル、そしてパスやファイル名が会話中の識別子と一致するファイルは、PageRankのpersonalizationスコアも受け取る。</p>
<p>予算も固定値ではない。<a href="https://aider.chat/docs/repomap.html" target="_blank" rel="noopener noreferrer">Aiderのドキュメント</a>は <code>--map-tokens</code> のデフォルトを1kと書いているが、<a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/models.py#L782-L789" target="_blank" rel="noopener noreferrer">コード</a>はモデルの入力上限の1/8を1,024から4,096の間に切り詰める。そしてチャットにファイルがなければ <code>--map-multiplier-no-files</code>(デフォルト2)倍まで広げる。このリポジトリの <code>src/</code> で確かめた。実行した0.86.1と上でリンクしたコミットは、ランキングのコードが同じだ。出力されたmapは、Aiderが付ける案内文3行を含めてo200k_baseで数えた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># aider-chat 0.86.1, 리포 루트에서 시작한다, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mkdir</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cp</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -R</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">cd</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -q</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -A</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> commit</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -qm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 키는 더미 값이다. --show-repo-map 은 map 만 출력하고 LLM 을 부르지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> OPENAI_API_KEY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dummy</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">uvx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --python</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3.12</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider-chat==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.86.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-check-update</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --analytics-disable</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-gitignore</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --model</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> gpt-4o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">$@</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                                              # Repo-map: using 4096 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                            # Repo-map: using 1024 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src/lib/filter-posts.ts</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    # 이 파일을 채팅에 올린 상태</span></span></code></pre></figure>
<table>
<thead>
<tr>
<th>条件</th>
<th>Aiderが決めた予算</th>
<th>出力されたmap</th>
<th>含まれたファイル</th>
</tr>
</thead>
<tbody>
<tr>
<td>デフォルト、チャットにファイルなし</td>
<td>4,096</td>
<td>7,370 tokens</td>
<td>61個</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>、チャットにファイルなし</td>
<td>1,024</td>
<td>2,019 tokens</td>
<td>27個</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>、<code>filter-posts.ts</code> をチャットに追加</td>
<td>1,024</td>
<td>983 tokens</td>
<td>12個</td>
</tr>
</tbody>
</table>
<p>gpt-4oの入力上限は128kなので1/8が4,096に切り詰められ、チャットが空なのでその2倍以内でmapが出た。ファイルを1つチャットに追加すると、mapは予算内に縮み、前の27個のうち10個だけが残った場所に <code>PostList.tsx</code> と <code>SearchModal.tsx</code> が新たに入った。repo mapはリポジトリの固定された要約ではなく、その瞬間の会話に合わせた抜粋だ。</p>
<p>同じ階層でもう1つ触れておきたいツールがある。<strong>ast-grep</strong>(<code>ast-grep/ast-grep</code>)だ。tree-sitterベースの構造検索とrewriteを行うCLIで、テキストではなく構文木のノードをマッチングする。たとえば <code>console.log($A)</code> というパターンは、改行や空白がどうなっていても、引数1つで <code>console.log</code> を呼ぶ呼び出しをすべて捉える。捉えるのは同じ構文構造だ。<code>$A</code> の型や、<code>console</code> がどこから来た名前なのかは知らない。<code>ast-grep-mcp</code> サーバーもあるので、エージェントにテキストgrepの代わりに構造検索を使わせることができる。</p>
<h3 id="ナレッジグラフ"><a class="anchor" href="#ナレッジグラフ">ナレッジグラフ</a></h3>
<p>3つ目の階層はさらに一歩進む。<strong>事前にコードベース全体をパースしてナレッジグラフを作り、ディスクに保存</strong>しておき、エージェントは保存しておいたグラフにクエリを投げる方式だ。最も話題になっている例が<strong>CodeGraph</strong>というツールである。</p>
<p><a href="https://github.com/colbymchenry/codegraph/blob/b635dd467f0578926a9c01a37b9d28d2b26689f1/README.md" target="_blank" rel="noopener noreferrer">CodeGraphのREADME</a>が説明する構造は単純だ。tree-sitterでコードをパースしてシンボル、エッジ、ファイル情報を抽出し、それをローカルのSQLiteデータベースに保存する。名前検索はSQLiteのFTS5インデックスで行う。エージェントはMCPを通じてこのグラフに問い合わせる。READMEが説明する手順にLLMは登場しない。そのため筆者は、この抽出は決定論的(deterministic)、つまり同じコードからは常に同じ結果が出ると見ている。</p>
<p>LLMがコードを要約してグラフを作ると、同じコードでも結果が変わりうるし、ハルシネーションが混ざる危険がある。一方、構文木を直接パースすれば言語の文法が定めた規則どおりにだけシンボルの関係を抽出するので、そうした解釈が入り込む余地がない。</p>
<p>ここで登場する<strong>FTS5</strong>(SQLite Full-Text Search 5)は、SQLiteの仮想テーブルの形で提供される全文検索拡張だ。<a href="https://www.sqlite.org/fts5.html" target="_blank" rel="noopener noreferrer">SQLiteのドキュメント</a>によれば、3.9.0(2015-10-14)からamalgamationに含まれており、<code>CREATE VIRTUAL TABLE ... USING fts5(...)</code> でテーブルを作り、<code>MATCH</code> 演算子で問い合わせる。Elasticsearchのような別の検索エンジンを立てなくても、SQLiteファイル1つで全文検索インデックスを持てる。</p>
<p>ただし、決定論的であることと漏れがないことは別だ。同じREADMEは、慣習やリフレクションに頼るフレームワークのルート認識率をDjangoの74.1%からASP.NETの83.9%までと記し、これを静的解析の限界(honest static-analysis ceiling)と呼んでいる。同じコードからは同じ結果が出るが、その結果から抜け落ちたエッジがありうる。</p>
<p>ベンチマークはCodeGraph自身が測ったものだ。同じREADMEの2026-08-05の再測定では、Claude Opus 4.8をheadlessで動かし、7つのオープンソースリポジトリにアーキテクチャの質問を1つずつ投げた。CodeGraph MCPを有効にした側が、平均コストを44%、処理トークンを62%、tool callを88%減らした。この再測定では、両側ともBashで <code>codegraph</code> CLIを呼べないようにブロックした。ブロックしない測定環境では、ツールなしの側が28回中26回CLIを見つけて使っており、READMEは以前に発表した数値がこのブロックなしで出たものだと明かしている。</p>
<p>削減幅はリポジトリの大きさに従わなかった。ツールなしの側がtool callを28〜43回使った質問ではコストが57〜78%減り、7回で終わったGinではほぼ同じだった。ファイル約11k個のVS Codeが71%、約640個のExcalidrawが78%だった。READMEはこれを、質問が必要とした探索量で説明している。リポジトリごとに質問が1つしかないので、筆者は方向だけを読み取る。</p>
<p>READMEは逆方向の数字も記している。処理したトークンは減るが、複数ターンのセッションが終わった時点でcontext windowに残っているretrieval結果は、7つのリポジトリを通じてCodeGraph側が約80%多い。リポジトリごとの差は大きく、VS Code 1つでは67k対18k tokensで約3.7倍だった。一度に原文を密に返し、それがウィンドウにそのまま残るからだ。CodeGraphはtool callと処理トークンを減らす代わりに、ウィンドウに残る量を増やした。</p>
<p><strong>Cursor</strong>は別の道を進み、その後方向を変えた。<a href="https://cursor.com/blog/secure-codebase-indexing" target="_blank" rel="noopener noreferrer">2026年1月のCursorブログ</a>が説明したインデックスは、構文グラフではなく<strong>ベクトル埋め込みベースのセマンティック検索</strong>だった。ローカルでファイルをchunkに分け、Merkle treeのハッシュでサーバーと同期し、chunkを埋め込みに変えてセマンティック検索に使っていた。2026年7月、CursorのCommunity Support Engineerは<a href="https://forum.cursor.com/t/what-do-you-think-about-cursor-removing-the-codebase-indexing-settings/165899" target="_blank" rel="noopener noreferrer">フォーラム</a>で「Semantic/embeddings indexing is being turned down in favor of grep-based retrieval」と答えた。同じスレッドで別のスタッフは、モデルがgrepをうまく使えるようになるにつれ、以前のセマンティック検索の経路はもう意味のある形で役立たなくなったと書いた。現在の<a href="https://cursor.com/docs/context/codebase-indexing" target="_blank" rel="noopener noreferrer">Cursorのドキュメント</a>は、Instant Grepがインデックスをローカルで作って問い合わせ、検索用にコードベースの埋め込みを保存しないと記している。</p>
<h3 id="lsp"><a class="anchor" href="#lsp">LSP</a></h3>
<p>最後の階層は、<strong>言語サーバーに直接依存</strong>する方式だ。tree-sitterが「シンボルが存在すること」を知っているとすれば、LSPは「そのシンボルが何であるか」を知っている。</p>
<p><strong><a href="https://microsoft.github.io/language-server-protocol/" target="_blank" rel="noopener noreferrer">LSP</a></strong>(Language Server Protocol)は、エディタと言語解析ツール(コード補完、定義へ移動、参照の検索、リファクタリングなど)の間の通信を標準化した、JSON-RPCベースのオープンプロトコルだ。2016年に<a href="https://www.redhat.com/en/about/press-releases/red-hat-codenvy-and-microsoft-collaborate-language-server-protocol" target="_blank" rel="noopener noreferrer">Microsoft、Red Hat、Codenvyが協力を発表した</a>。核となる発想は、「エディタごとに言語解析器を作り直すのではなく、言語ごとにサーバーを1つ置き、すべてのエディタがそのサーバーに問い合わせよう」というものだ。rust-analyzerやPythonのpyrightがLSPサーバーで、TypeScriptは独自プロトコルを使う <code>tsserver</code> をLSPで包んだtypescript-language-serverを使う。</p>
<p><strong>Serena</strong>(<code>oraios/serena</code>)がこの階層に属するMCPサーバーだ。2026-10-08時点で30,093 starsで、リポジトリは2025年3月に作られた。Serenaの核となる発想は一行にまとめられる。<strong>エージェントにコードをシンボル単位で見せよう。</strong> 主なツールは <code>find_symbol</code>、<code>find_referencing_symbols</code>、<code>get_symbols_overview</code> などだ。バックエンドは2つのうち1つを選べる。デフォルトはLSPを実装した言語サーバー(無料/オープンソース)、もう1つはJetBrains IDEのコード解析を活用する有料プラグイン(無料トライアルあり)だ。</p>
<p>このリポジトリで測ってみると、差がどこから生まれるのかが見える。非公開記事を除外する <code>isHiddenPost</code>(<code>src/lib/filter-posts.ts:12</code>)の使用箇所を、2つの方法で探した。テキスト側はgrepだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 커밋 36e5cfa, 2026-10-08. 이 글도 같은 이름을 담고 있어 content/ 는 뺐다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 16줄, 파일 9개</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -C1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 같은 16곳을 앞뒤 1줄과 함께</span></span></code></pre></figure>
<p>LSP側ではSerenaの<a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/tools/symbol_tools.py#L169-L172" target="_blank" rel="noopener noreferrer"><code>find_referencing_symbols</code></a>を呼んだ。LSPの参照検索は名前ではなく位置(ファイルと行、列)で問い合わせるので、このツールもシンボル名である <code>name_path</code> と、定義のあるファイルである <code>relative_path</code> を一緒に受け取る。SerenaをMCPサーバーとして起動せず、同じコミットでPythonからツールを直接呼び出した。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># serena 3b99f8b, 커밋 36e5cfa, 2026-10-08. 리포 루트에서 실행한다</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># uvx --python 3.12 --from git+https://github.com/oraios/serena@3b99f8b024dafd58c962ea6e74f37c8a730ef532 python refs.py</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.config.serena_config </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaConfig</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.tools.symbol_tools </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FindReferencingSymbolsTool</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">project</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">serena_config</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SerenaConfig().with_headless_mode_overrides())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">None</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 언어 서버가 뜰 때까지 기다린다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">tool </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> agent.get_tool(FindReferencingSymbolsTool)</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">print</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: tool.apply(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"isHiddenPost"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">relative_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"src/lib/filter-posts.ts"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)))</span></span></code></pre></figure>
<p>出力はJSON 1行だ。最初の項目だけを抜き出すとこうなる。行番号は0から数える。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>{"src/lib/filter-posts.ts": {"Function": [{"name_path": "filterPublishedPosts", "body_location": {"start_line": 22, "end_line": 24}, "content_around_reference": "...  22:export function filterPublishedPosts(posts: Post[]): Post[] {\n  >  23:  return posts.filter(post => !isHiddenPost(post))\n...  24:}"}]}, ...</span></span></code></pre></figure>
<p>Serenaは参照を10か所返した。定義のある <code>filter-posts.ts:12</code> は除き、3つのファイルのimport行はそのファイル自体をシンボル(<code>File</code>)として返す。grepが拾ったコードの位置11か所は、この10か所に定義の1行を足したものと同じだ。この名前はリポジトリに1つしかないので、grepもコードの位置を取りこぼしたり余計に拾ったりしなかった。grepが余分に拾った5か所は、この関数を説明するドキュメント内の文だった。ルートの <code>CLAUDE.md</code>、<code>.claude/commands/</code> の <code>audit.md</code> と <code>write-post.md</code>、そして <code>docs/research/snapshot-20260816/</code> に残る <code>CLAUDE.md</code> と <code>write-post.md</code> のコピーだ。</p>
<p>Serenaは参照ごとに<a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/repl/api/lsp_api.py#L367-L369" target="_blank" rel="noopener noreferrer">前後1行</a>を付けて返す。そこでgrepは、行番号だけを返す <code>git grep -n</code> と、前後1行も一緒に返す <code>git grep -n -C1</code> の2通りで実行し、3つの出力全体を <code>o200k_base</code> で数えた。</p>
<table>
<thead>
<tr>
<th>出力</th>
<th>位置</th>
<th>tokens</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>git grep -n</code></td>
<td>16か所 (コード11、ドキュメント5)</td>
<td>703</td>
</tr>
<tr>
<td><code>git grep -n -C1</code></td>
<td>16か所 (コード11、ドキュメント5)</td>
<td>1,575 (コード733、ドキュメント841)</td>
</tr>
<tr>
<td>Serena <code>find_referencing_symbols</code></td>
<td>10か所</td>
<td>892</td>
</tr>
</tbody>
</table>
<p>前後1行を一緒に返すgrepと比べると、Serenaのほうが683 tokens少ない。差はドキュメント5か所を返さないことから生まれる。ただしSerenaは参照ごとにシンボル名と位置情報をJSONで付けるので、コード側だけを見るとむしろ159 tokens多い。行番号だけを返す <code>git grep -n</code> の703 tokensよりは、Serenaの892 tokensのほうが大きい。このリポジトリでLSPの階層が節約するのは検索結果ではなく、grepの結果を見て周辺の行やファイルをさらに読む次のステップだ。</p>
<p>その次のステップで、エージェントがgrepに引っかかったファイル9個を丸ごと読むなら、読む量は47,034 tokensで、コード4個が3,716、ドキュメント5個が43,318だ。これは上限だ。<code>CLAUDE.md</code> は実際のセッションではすでにcontextに載っていて、改めて読まない可能性が高い。<code>isLocale</code> のようなありふれた名前なら、コードの位置でも結果が分かれうる。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、4つの階層はコードをどれだけ深く理解するかで分かれ、減らすコストも異なる。context packingはコードをテキストとして渡し、<code>--compress</code> を付けても構文までしか残さず、入っていくトークンを減らす。tree-sitter repo mapはシンボルが存在することまでを知り、入っていくトークンに予算で上限を設け、その中を何で埋めるかはその瞬間の会話が決める。ナレッジグラフは関係をあらかじめ保存してtool callと処理トークンを減らすが、ベンダーの測定ではウィンドウに残る量がむしろ増えた。LSPはシンボルが何であるかまで知り、このリポジトリではドキュメント内の同じ名前を返さない代わりに、結果ごとにメタデータを付けた。節約する地点は検索結果ではなく、その後の読み込みだ。</p>
<p>だから筆者はツールを選ぶとき、階層の深さよりも、いまどのコストが問題なのかを先に見る。ウィンドウが小さくセッションが長いなら残る量を、往復が遅いならtool callの回数を、ドキュメントとコードが同じ名前を共有するリポジトリなら、grepの結果を見てさらに読むことになるファイルの量を見る。Cursorが埋め込みによるセマンティック検索を外してローカルのテキストインデックス(Instant Grep)に移ったことも、高価なインデックスがモデルの探索能力より常に優れているわけではないというサインとして読める。</p>
<p>これらのツールがエージェントのコード探索コストを減らすものだとすれば、エージェントが最初から知っておくべきプロジェクトのルールをどのファイルにどれだけ書くかは、また別の問題だ。その話は<a href="/260529" target="_blank" rel="noopener noreferrer">コンテキストファイル</a>で扱う。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep-mcp" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep-mcp</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>開発ツール</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[MCPとfunction calling]]></title>
            <link>https://hooninedev.com/ja/260524</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260524</guid>
            <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、MCP（Model Context Protocol）がfunction callingと何が違うのかについて話してみたい。 Claude CodeやCursorにMCPサーバーをつないで使ってはいるものの、それがLLM APIのfunction callingとどこで分かれるのかを説明しにくかった開発者に向けた記事だ。先に答えを書くと、MCPはfunction callingを置...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>MCP（Model Context Protocol）がfunction callingと何が違うのか</strong>について話してみたい。</p>
<p>Claude CodeやCursorにMCPサーバーをつないで使ってはいるものの、それがLLM APIのfunction callingとどこで分かれるのかを説明しにくかった開発者に向けた記事だ。先に答えを書くと、MCPはfunction callingを置き換えない。ホストアプリケーションがMCPサーバーから受け取ったツール一覧をfunction callingの<code>tools</code>パラメータに変換して渡し、モデルが選んだ呼び出しを再びMCPサーバーへ渡す。最後まで読めば、その上にMCPが加える4つの違いがプロトコルのどの部分から来ているのか、2026-07-28改訂版の後も残る違いは何か、そしてその構造がどのような攻撃面を開くのかがわかる。</p>
<p>筆者はフロントエンド開発者として日常的にClaudeを活用しているが、MCPサーバーを一つ追加するたびに、これらのツールがどのような仕組みでモデルの視野に入るのかが毎回あやふやだった。</p>
<h2 id="mcpmodel-context-protocol"><a class="anchor" href="#mcpmodel-context-protocol">MCP（Model Context Protocol）</a></h2>
<p>MCP（Model Context Protocol）は「<strong>エージェントに何ができるようにするか</strong>」という問題を解く。</p>
<p>少し具体的に説明しよう。AIエージェントがSlackへメッセージを送るには、Slack APIを呼び出せなければならない。GitHub Issueを作るには、GitHub APIを呼び出せなければならない。Postgresへクエリするには、DB接続を扱えなければならない。こうした外部システムとの統合を、<strong>一つの標準プロトコルにまとめたもの</strong>がMCPだ。（クライアントとサーバーが同じ規格で接続されるという意味だ。）</p>
<p>MCPはAnthropicが<strong>2024年11月25日</strong>に初めて公開したオープン標準だ。そして<strong>2025年12月9日</strong>、AnthropicはMCPをLinux Foundation傘下の<a href="https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Agentic AI Foundation（AAIF）</a>へ寄贈した。AAIFはAnthropic・Block・OpenAIが共同で創設した。</p>
<p>MCPはJSON-RPC上に構築されたプロトコルだ。<a href="https://www.jsonrpc.org/specification" target="_blank" rel="noopener noreferrer">JSON-RPC 2.0</a>は、JSONをワイヤーフォーマットとして使うstatelessで軽量なRPC（Remote Procedure Call）プロトコルである。トランスポート層に依存せず、HTTP・TCP・標準入出力のいずれでも動作する。この記事は2025-11-25改訂版を基準に説明する。この版のMCPは、接続ごとにセッションを張るstatefulなプロトコルだ。その後の改訂版で何が変わったのかは、呼び出しの流れを見てから扱う。</p>
<h3 id="6つのプリミティブ"><a class="anchor" href="#6つのプリミティブ">6つのプリミティブ</a></h3>
<p>2025-11-25仕様の概要は、サーバーが提供する機能3つと、クライアントが提供する機能3つを挙げている。この記事ではこの6つをプリミティブ（primitive）と呼ぶ。ここでいうプリミティブは、JavaScriptのプリミティブ型（stringやnumberなど）とは関係がなく、プロトコルが定めた基本的なやり取りの種類を指す。</p>
<h4 id="サーバー側プリミティブ"><a class="anchor" href="#サーバー側プリミティブ">サーバー側プリミティブ</a></h4>
<ul>
<li><strong>Tool</strong>（model-controlled）：モデルが呼び出すかどうかを自ら判断して実行する操作。この操作は副作用（side effect）を持つことがある</li>
<li><strong>Resource</strong>（application-controlled）：URIで識別されるデータ。仕様には内容を読み出す<code>resources/read</code>だけがあり、書き込むメソッドはない。そのリソースをコンテキストにどう入れるかはホストアプリケーションが決める</li>
<li><strong>Prompt</strong>（user-controlled）：ユーザーがスラッシュコマンドなどで明示的にトリガーする、再利用可能なテンプレート</li>
</ul>
<h4 id="クライアント側プリミティブ"><a class="anchor" href="#クライアント側プリミティブ">クライアント側プリミティブ</a></h4>
<ul>
<li><strong>Sampling</strong>：サーバーから逆にクライアントのLLMへcompletionを要求できる仕組みで、クライアントとサーバーを双方向の構造にする。ツールの実行中に文章の生成が必要になったサーバーが、自前のAPIキーなしでクライアントの使うモデルを借りるための仕組みだ。2026-07-28改訂版では削除予定のdeprecatedになった</li>
<li><strong>Roots</strong>：クライアントがサーバーへ「ここまでが作業可能な範囲」と伝えるワークスペース境界の情報</li>
<li><strong>Elicitation</strong>：サーバーがツールの実行中に、構造化された形式でユーザーへ追加入力を求められる機能</li>
</ul>
<p>この区別が重要なのは、<strong>誰が呼び出しや提供を決めるのか</strong>が異なるからだ。Toolはモデルの判断で実行されるため誤った呼び出しのリスクがあり、Promptはユーザーが明示的に選ぶ。Resourceはアプリが選ぶのが基本だが、仕様はヒューリスティクスやモデルの選択による自動的な取り込みを行う実装も認めている。クライアント側の3つは向きが逆だ。サーバーが要求し、応じるかどうかはクライアントが決める。</p>
<h3 id="2つの転送方式"><a class="anchor" href="#2つの転送方式">2つの転送方式</a></h3>
<p><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" target="_blank" rel="noopener noreferrer">標準の転送方式</a>は2つで、仕様はそれ以外のカスタム転送も認めている（MAY）。一つは<strong>stdio</strong>で、MCPサーバーをローカルのサブプロセスとして実行し、標準入出力で通信する方式だ。ファイルシステムやGitなど、ローカルで動くツールに適している。もう一つは<strong>Streamable HTTP</strong>で、HTTP POSTとGETにSSE（Server-Sent Events）ストリーミングを重ね、双方向に近い通信を実現する方式だ。SSEは、HTTP接続の上でサーバーからクライアントへ一方向にデータを送り出す方式だ。リモートサーバー、OAuth認証、複数クライアント接続、クラウドデプロイなど、ネットワーク越しのシナリオに適している。</p>
<h3 id="llmがmcpツールを呼び出す流れ"><a class="anchor" href="#llmがmcpツールを呼び出す流れ">LLMがMCPツールを呼び出す流れ</a></h3>
<p>プリミティブと転送方式を確認したので、次は<strong>実際にLLMがMCPツールをどのように発見し、呼び出すのか</strong>を追ってみよう。</p>
<p>2025-11-25改訂版では、接続が始まると次の順序でハンドシェイクが行われる。2026-10-08時点で、TypeScript SDKの1.32.1とv2系の2.3.1も、既定の設定ではこの順序で動く。</p>
<ul>
<li><strong>クライアント → サーバー</strong>：<code>initialize</code>リクエスト（対応するプロトコルバージョンとクライアントcapabilitiesを渡す）</li>
<li><strong>サーバー → クライアント</strong>：<code>initialize</code>レスポンス（サーバーcapabilitiesと、任意の<code>instructions</code>フィールド）</li>
<li><strong>クライアント → サーバー</strong>：<code>notifications/initialized</code>通知</li>
<li><strong>クライアント → サーバー</strong>：<code>tools/list</code>リクエスト → 利用可能なツール一覧を受信</li>
<li>（以後）LLMがツールを呼び出すと判断 → クライアントが<code>tools/call</code>を送信 → 結果を受信</li>
</ul>
<p><code>initialize</code>レスポンスの<code>instructions</code>フィールドは、サーバーがツールの使い方をテキストで書いて送る場所だ。下のデモ出力のinstructions行がこの値である。</p>
<p>では、tool定義そのものはどのようにLLMの視野に入るのか。MCPのtool定義は、次のようなJSON Schemaの形をしている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "name"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "description"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "inputSchema"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"object"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "properties"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"string"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "required"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ホストは<code>tools/list</code>で受け取ったこの一覧を、<strong>Anthropic Messages APIの<code>tools</code>パラメータ</strong>または<strong>OpenAI function callingの<code>tools</code>パラメータ</strong>へ変換し、LLM APIの呼び出し時に一緒に渡す。Anthropicの場合、toolパラメータが渡されると<a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">special system promptが自動的に追加</a>され、モデルがtoolの呼び出し方を理解できるようになる。その長さはモデルによって異なる。2026-10-08に見たドキュメントの表では、<code>tool_choice: auto</code>の場合、現行モデルで286〜675トークンだった。</p>
<p>LLMがツールを呼び出す必要があると判断すると、レスポンス内に<code>tool_use</code>ブロック（<code>{"type": "tool_use", "name": ..., "input": ...}</code>）が挿入され、レスポンスの<code>stop_reason</code>は<code>tool_use</code>で終わる。ホストはこれを受け取り、実際のMCPサーバーへ<code>tools/call</code>を送信する。結果を受け取ると、次のuserメッセージの<code>tool_result</code>ブロックに入れて、再びLLMへ送る。<strong><code>stop_reason</code>が<code>tool_use</code>以外の値（<code>end_turn</code>、<code>max_tokens</code>など）に変わるまで、このループが繰り返される。</strong> 私たちが一般に「エージェントが働く」と呼ぶ動作は、実際にはこの呼び出し・結果・呼び出しのループが連続することに近い。</p>
<p>図にすると、モデルとMCPサーバーの間には線がない。モデルは<code>tools</code>で受け取った定義だけを見て、MCPサーバーはモデルではなくホストからリクエストを受ける。2つのプロトコルをつなぐのは真ん中のホストだ。</p>
<p><picture><source type="image/webp" srcset="/content/260524/1.720w.webp 720w, /content/260524/1.1440w.webp 1440w, /content/260524/1.2000w.webp 2000w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260524/1.png" alt="左のLLM APIと中央のホストの間をtools、tool_use、tool_resultの矢印が行き来し、ホストと右のMCPサーバーの間をtools/list、tools/callの矢印が行き来する。LLM APIとMCPサーバーの間には線がない" style="max-width:min(720px, 100%);height:auto;" width="2000" height="640" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>この変換が実際にどれほど短いのかを動かして確かめた。2026-10-08にNode v24.16.0、<code>@modelcontextprotocol/sdk</code> 1.32.1、<code>zod</code> 4.6.5で実行した。転送はstdioではなく、同じプロセス内でサーバーとクライアントをつなぐ<code>InMemoryTransport</code>で、LLMは呼び出していない。<code>tool_use</code>ブロックはAnthropicのドキュメントの形どおりに手で作った。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { McpServer } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/server/mcp.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Client } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/client/index.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { InMemoryTransport } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/inMemory.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { z } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "zod"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// MCP 서버: 도구 하나와 instructions 를 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> server</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> McpServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { instructions: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Use get_weather for current conditions only."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">registerTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, inputSchema: { location: z.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() } },</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">location</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ content: [{ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"text"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, text: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">location</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}: 15C, partly cloudy`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트: 같은 프로세스 안에서 서버와 잇고, 클라이언트가 보내는 메서드 이름을 찍는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">clientT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">serverT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InMemoryTransport.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">createLinkedPair</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> clientT.send.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">bind</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">clientT.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"C->S"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, m.method, m.params?.protocolVersion </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> send</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(serverT);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> client</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Client</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"demo-host"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"instructions:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInstructions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. tools/list 결과를 LLM API 의 tools 파라미터 모양으로 바꾼다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">listTools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> anthropicTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ name: t.name, description: t.description, input_schema: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> openaiTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: t.name, description: t.description, parameters: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"anthropic:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(anthropicTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"openai:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(openaiTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. 모델이 이런 tool_use 블록을 돌려줬다고 가정한다. input 이 그대로 tools/call 의 arguments 가 된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toolUse</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_use"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toolu_demo"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, input: { location: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Seoul"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">callTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: toolUse.name, arguments: toolUse.input });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, tool_use_id: toolUse.id, content: result.content }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>node post-demo.mjs</code>の出力は次のとおりだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>C->S initialize 2025-11-25</span></span>
<span data-line=""><span>C->S notifications/initialized </span></span>
<span data-line=""><span>instructions: Use get_weather for current conditions only.</span></span>
<span data-line=""><span>C->S tools/list </span></span>
<span data-line=""><span>anthropic: {"name":"get_weather","description":"Get current weather information for a location","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>openai: {"type":"function","name":"get_weather","description":"Get current weather information for a location","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>C->S tools/call </span></span>
<span data-line=""><span>tool_result: {"type":"tool_result","tool_use_id":"toolu_demo","content":[{"type":"text","text":"Seoul: 15C, partly cloudy"}]}</span></span></code></pre></figure>
<p>ツール定義の変換はフィールド名を変えるだけだ。MCPの<code>inputSchema</code>がAnthropicでは<code>input_schema</code>、OpenAIでは<code>parameters</code>になる。SDKがスキーマに<code>$schema</code>を付け足すことも出力からわかる。逆方向も短い。Anthropicの<code>tool_use.input</code>はオブジェクトなので、そのまま<code>tools/call</code>の<code>arguments</code>になる。結果の側も、textブロックは形が同じなのでそのまま渡せるが、画像やエラーの結果は形が異なるため、Anthropic SDKのMCP helperが別途変換する。上のOpenAI側の形はResponses APIの形式だ。OpenAIが返す呼び出しの<code>arguments</code>は<a href="https://developers.openai.com/api/docs/guides/function-calling" target="_blank" rel="noopener noreferrer">JSON文字列</a>なので、渡す前に<code>JSON.parse</code>を一度通す必要がある。上のコードはAnthropic形式の<code>tool_use</code>ブロックしか作っていないので、このparseの段階は出力に現れない。</p>
<h3 id="mcpが加える4つのもの"><a class="anchor" href="#mcpが加える4つのもの">MCPが加える4つのもの</a></h3>
<p>では、MCPはfunction callingに何を加えるのか。2025-11-25改訂版を基準にすると4つある。</p>
<ul>
<li><strong>動的な発見</strong>：ビルド時にはツール一覧を知らず、実行時に<code>tools/list</code>で取得する。サーバーは<code>notifications/tools/list_changed</code>で、接続中に一覧が変わったことを知らせられる</li>
<li><strong>Stateful session</strong>：<code>initialize</code>で接続を確立し、その中でリクエストがやり取りされる。終了用のJSON-RPCメッセージはなく、転送を閉じることで終える</li>
<li><strong>Tool以外のプリミティブ</strong>：Resource・Prompt・Sampling・Roots・Elicitationをcapability negotiationで公開する。capability negotiationとは、<code>initialize</code>で双方が対応する機能を互いに知らせる段階だ</li>
<li><strong>双方向性</strong>：サーバーがSamplingを使って、クライアントのLLMへ逆にcompletionを要求できる（2026-07-28改訂版でdeprecated）</li>
</ul>
<h3 id="2026-07-28改訂版以降"><a class="anchor" href="#2026-07-28改訂版以降">2026-07-28改訂版以降</a></h3>
<p>2026-10-08時点で、公式サイトでlatestとして開くのは<a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" target="_blank" rel="noopener noreferrer">2026-07-28改訂版</a>で、ここでこのリストの半分が変わった。まず、<code>initialize</code>と<code>notifications/initialized</code>からなるハンドシェイクとプロトコルレベルのセッションがなくなった。代わりに、すべてのリクエストが<code>_meta</code>にプロトコルバージョンとクライアントcapabilitiesを載せる。このフィールドは、メッセージ本来の引数とは別にメタデータを付けるためにMCPが予約している場所だ。サーバーは<code>server/discover</code>を必ず実装しなければならない（MUST）。サーバーが対応するプロトコルバージョン、capabilities、サーバー情報を返すRPCで、クライアントはほかのリクエストより先にこれを呼び出し、対応バージョンとcapabilitiesを事前に確認できる。</p>
<p>サーバーが先に送っていたリクエストは、Multi Round-Trip Requests（MRTR）というパターンに置き換えられた。サーバーは別途リクエストを送る代わりに、追加の入力が必要だという中間結果（<code>input_required</code>）を返し、クライアントがその入力を埋めて元のリクエストを送り直す方式だ。</p>
<p>SamplingとRootsはLoggingとともにdeprecatedになった。仕様に残っていて動作もするが新しい実装は採用すべきでないという意味で、仕様はSamplingの代わりにLLMプロバイダーのAPIへ直接つなぐよう勧めている。</p>
<p>ただし、SDKの既定の動作はまだ古い方式だ。TypeScript SDK 1.32.1は最新バージョン定数が<code>2025-11-25</code>なので2026-07-28改訂版を知らず、2.3.1はこの改訂版に対応しているが、バージョン交渉の既定値が<code>legacy</code>だ。上の出力の1行目<code>initialize 2025-11-25</code>がその結果である。</p>
<p>すると、4つのうち残るのは動的な発見とTool以外のプリミティブ（Resource、Prompt、Elicitation）だ。動的な発見も形が少し変わり、一覧の変更通知は<code>subscriptions/listen</code>ストリームにopt-inしたクライアントだけが受け取る。結局、MCPがfunction callingと分かれるところは、セッションや双方向性よりも、<strong>ツール一覧とコンテキストを実行時にやり取りする契約</strong>にある。</p>
<h3 id="apiがmcpクライアントになるとき"><a class="anchor" href="#apiがmcpクライアントになるとき">APIがMCPクライアントになるとき</a></h3>
<p>この契約のうちどこまでがモデルに届くのかは、Anthropicの<a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector" target="_blank" rel="noopener noreferrer">MCP connector</a>（beta）が示している。Messages APIがリモートのMCPサーバーに直接つなぐ機能だが、ドキュメントのLimitationsは、MCP仕様の機能のうち"only tool calls are currently supported"と書き、"Local STDIO servers cannot be connected directly"と書いている。同じドキュメントは、ローカルサーバーやMCPのprompt、resourceが必要なら、MCP SDKで接続を自分で管理しながらAnthropic SDKの変換helperを使うよう案内している。</p>
<p>つまり、function callingのレイヤーでMCPを消費するとToolだけが残る。ResourceとPromptは、それを画面やコンテキストへ運ぶホストがあって初めて意味を持つ。OpenAIもfunction callingガイドで、MCPサーバーの機能をbuilt-in toolとして使う方法を紹介している。OpenAIの<a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp" target="_blank" rel="noopener noreferrer">MCP serversガイド</a>はツール一覧の取得と呼び出しの方法だけを説明し、ResourceやPromptに対応しているかどうかは書いていない。</p>
<h3 id="動的な発見が開く攻撃面"><a class="anchor" href="#動的な発見が開く攻撃面">動的な発見が開く攻撃面</a></h3>
<p>ツールのdescriptionとツール呼び出しの結果は、ホストを経てモデルのcontextに入る。だからサーバーがそこに何を書いても、モデルはそれを読む。代表的な2つの攻撃は、どちらもここから生まれる。</p>
<ul>
<li>
<p><strong>Tool Poisoning Attack（TPA）</strong>：<a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks" target="_blank" rel="noopener noreferrer">Invariant Labs</a>が2025年4月に命名し、PoCを公開した攻撃だ。MCPサーバーのツール説明（description）に悪意ある指示を隠すと、モデルはユーザーには見えないそのテキストを読み、ユーザーの知らないうちに従うことがある。</p>
</li>
<li>
<p><strong>Rug Pull</strong>：Invariant Labsが同じ記事で説明した攻撃で、ユーザーが承認した後にサーバーがツール定義を変える。<a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/" target="_blank" rel="noopener noreferrer">Simon Willisonが引用した</a>Elena Crossの例のように、1日目に安全そうに見えるツールを承認したのに、7日目にはそのツールがAPIキーを攻撃者へ送るように変わっている、という具合で、ツール定義をインストール時点ではなく実行時にサーバーから受け取る構造から生まれる。<a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" target="_blank" rel="noopener noreferrer">tool仕様</a>は、どのツールがモデルに公開されているかを示すUIを推奨する（SHOULD）だけで、変わった定義の再承認までは求めていないので、再承認はホストの役目になる。</p>
</li>
</ul>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、MCPはfunction callingを置き換えるものではなく、その上に載った標準だ。モデルがツールを呼び出す方法は依然として<code>tools</code>パラメータと<code>tool_use</code>ループであり、2つのプロトコルの間はホストが翻訳する。2025-11-25改訂版でMCPが加えたのは、動的な発見、stateful session、Tool以外のプリミティブ、そしてサーバーからクライアントへ向かう呼び出しだった。2026-07-28改訂版でセッションがなくなりSamplingがdeprecatedになると、残るのはツール一覧とコンテキストを実行時にやり取りする契約だ。その契約があるからこそ、ツール定義を汚染したり、こっそり書き換えたりする攻撃も同じところから生まれる。MCPサーバーをもう一つつなぐときは、そのサーバーが何をできるのかとあわせて、定義が変わったときにホストが知らせてくれるかどうかも確かめてみてほしい。</p>
<p>MCPがエージェントに何をできるようにするかの問題なら、何を知らせるかは<code>CLAUDE.md</code>や<code>AGENTS.md</code>のようなコンテキストファイルの問題だ。それらのファイルがエージェントにどのように読まれ、どこまで守られるのかは<a href="/260529" target="_blank" rel="noopener noreferrer">コンテキストファイル</a>で扱う。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle" target="_blank" rel="noopener noreferrer">MCP Specification 2025-11-25, Lifecycle</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" target="_blank" rel="noopener noreferrer">MCP Specification 2026-07-28, Versioning</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>開発ツール</category>
            <category>Claude</category>
            <category>MCP</category>
        </item>
        <item>
            <title><![CDATA[状態管理]]></title>
            <link>https://hooninedev.com/ja/260518</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260518</guid>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、状態管理（State Management）について考えてみたい。ライブラリの比較記事ではない。どのツールが優れているかを決めるよりも、状態というものをどう捉え、どこに境界を引くべきかという感覚を整理するための記事だ。 今ではAIツール（Claude、ChatGPT、Cursor、Gemini、Copilot）が、私たちの仕事に深く入り込んでいる。開発速度は飛躍的に上がった一方で、...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>状態管理（State Management）<strong>について考えてみたい。ライブラリの比較記事ではない。どのツールが優れているかを決めるよりも、状態というものを</strong>どう捉え</strong>、どこに<strong>境界を引くべきか</strong>という感覚を整理するための記事だ。</p>
<p>今ではAIツール（Claude、ChatGPT、Cursor、Gemini、Copilot）が、私たちの仕事に深く入り込んでいる。開発速度は飛躍的に上がった一方で、率直に言えば、サービスの完成度はそれほど向上していないように感じる。機能が増えた分だけバグも増え、「なぜこうなったのか分かりません」という言葉を耳にする機会も多くなった。</p>
<p>開発が速くなるほど、コードを一行ずつ細かく確認することは減っていく。だからこそ筆者は、<strong>AIを正しい方向へ導ける基礎力</strong>がいっそう必要になると考えている。AIが書いたコードの問題に気づき、望む方向へ改めて導けなければ、成果物の品質は保てない。その基礎力には、ドメイン視点での開発、抽象化、TDD（Test-Driven Development、テスト駆動開発）、ライブラリの活用、パフォーマンス上の優位性の確保など、さまざまなものがあるだろう。</p>
<p>ところが、フロントエンドの同僚や他のIT職種の同僚に「フロントエンド開発で最も難しい作業は何ですか？」と尋ねるたび、最も多く返ってきた答えはいつも同じだった。<strong>「状態の流れを管理することです」</strong></p>
<p>この記事では、なぜ状態の流れを管理することがそれほど難しいのか、そして適切に扱うためにどのような設計眼と感覚を養うべきかを整理していく。</p>
<h2 id="状態stateとは何か"><a class="anchor" href="#状態stateとは何か">状態（State）とは何か</a></h2>
<p>本題に入る前に、最も基本的な問いから確認しよう。私たちが言う「状態」とは、正確には何だろうか。</p>
<p>フロントエンド開発を学ぶ中で、<a href="https://blog.hoseung.me/2021-12-05-state-management" target="_blank" rel="noopener noreferrer">hoseung.me</a>氏の記事をよく読んだ。そこでは状態を、「<strong>UIに影響を与え得るすべてのデータ</strong>」と説明している。いいね数、カートの中身、モーダルが開いているかどうか、入力値、ログイン中のユーザー情報、現在選択されているタブ、検索結果、ローディング中かどうか。これらはすべて状態である。</p>
<p>React公式ドキュメントは、もう少し形式的に定義している。ページのタイトルはそのまま<a href="https://react.dev/learn/state-a-components-memory" target="_blank" rel="noopener noreferrer">「State: A Component's Memory」</a>であり、かみ砕けば、<strong>「コンポーネントがレンダー間でデータを保持（retain）し、そのデータが更新されたときにReactへ再レンダーを促す仕組み」<strong>といったところだ。つまり、時間が経っても消えず、何らかのイベントによって更新され、更新時にUIを描き直すデータである。もう一つ押さえておくと、状態は</strong>コンポーネントのインスタンスごとに分離される。</strong> 同じコンポーネントがページ上に10個あっても、それぞれが独立した状態を持つ。この事実は、後で扱う「状態をどこに置くか」という議論に直結する。</p>
<p>どちらの定義も、同じものを指している。「<strong>レンダーに影響し、時間とともに変化する値</strong>」が状態である。変化しない定数（constant）は状態ではない。ビルド時に固定されるプリミティブなデザイントークンは状態ではないが、ユーザーが切り替えるダークモードは状態だ。（厳密には、値そのものがダーク／ライトというテーマの状態に応じてresolveされるため、「テーマの選択」が状態であり、トークンはその状態を映す鏡だと捉えるのが正確である）</p>
<p>ここで一つ確認しておきたい。<strong>すべての状態がコンポーネント内に存在するわけではない。</strong> Cookieに存在する状態もあれば、localStorage・sessionStorage・IndexedDBに存在する状態も、URLに存在する状態もある。サーバーにあるデータをクライアントへ持ってきてキャッシュすれば、それも一種の状態になる。ブラウザ自身が保持するスクロール位置や履歴スタックも、アプリの振る舞いを決めるという点で、状態として扱うべき場合がある。</p>
<h2 id="なぜそれほど難しいのか"><a class="anchor" href="#なぜそれほど難しいのか">なぜそれほど難しいのか</a></h2>
<p>状態を扱うことがなぜ難しいのか、まずは単純に考えてみよう。必要な状態を作り、必要な場所まで渡し、更新と初期化さえ適切に処理すればよいのではないだろうか。</p>
<p>この問いを頭に置いたまま、いま携わっているサービスのページを一つ開いてみよう。</p>
<p>そのページには、いくつのコンポーネントがあるだろうか。単純なページでも少なければ数十、多ければ数百のコンポーネントがツリーを構成しているはずだ。各コンポーネントは独自の状態を持つこともあれば、兄弟コンポーネントと状態を共有することも、親から状態を受け取ることもある。ページ間で状態が遷移することもあり、再読み込み後も残るべき状態も、タブを閉じたら消えるべき状態もある。</p>
<p>状態の管理が難しい本当の理由は、ここにある。<strong>無数の状態がどこで宣言され、どのように更新され、いつ消滅するのかを、私たちは一目では把握できない。</strong> 似た役割を持つコンポーネントが増えるほど、状態の命名も、状態を変更するコードの追跡も難しくなる。</p>
<p>こうして、目に見えない蜘蛛の巣ができあがる。AコンポーネントでのクリックがBのデータを無効化し、Bの無効化によってCのUIが閉じ、Cが閉じるとフォームの入力が失われる。この連鎖がコードのどこにも明示されていなければ、バグをデバッグするたびに、私たちは頭の中で蜘蛛の巣を描き直さなければならない。</p>
<p>では、この蜘蛛の巣をどう整理すればよいのだろうか。筆者が最初の一歩だと考えるのは、「<strong>状態には種類がある</strong>」と認識することである。</p>
<h2 id="すべての状態が同じではない"><a class="anchor" href="#すべての状態が同じではない">すべての状態が同じではない</a></h2>
<p><a href="https://kentcdodds.com/blog/application-state-management-with-react" target="_blank" rel="noopener noreferrer">Kent C. Dodds</a>は、状態を<strong>Server Cache</strong>（サーバーに存在する情報を、クライアントが素早くアクセスするために保持するもの）と<strong>UI State</strong>（インターフェースの動作を制御するため、UIにのみ存在するもの）に分けている。私たちは、この二つを一緒に扱うときにしばしば間違いを犯す。</p>
<p><a href="https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state" target="_blank" rel="noopener noreferrer">TanStack Query公式ドキュメント</a>では、TanStack Queryをサーバーとクライアント間の非同期処理を管理するServer Stateライブラリ、Redux、MobX、ZustandなどをClient Stateライブラリと位置づけている。（非同期データを保存することはできるが、それは非効率である）</p>
<p>要点は明確だ。<strong>Server StateとClient Stateは異なる問題である。</strong> Server Stateは非同期で、他のユーザーによって変更される可能性があり、時間が経つとstaleになる。Client Stateは同期的で、私たちが制御でき、再読み込みすれば消える。（正確には、ページがunloadされると<strong>JavaScriptランタイムが再起動し、heapメモリ上にあったコンポーネントツリーとその内部の状態がまとめて回収される。</strong> そのため、再びマウントされると<code>useState</code>の初期値からやり直す）この二つを同じツールで扱おうとすると、キャッシュの無効化、バックグラウンド更新、楽観的更新といったパターンをすべて自分で実装しなければならない。</p>
<p>筆者はさらに一歩進めて、フロントエンドの状態を<strong>7つの分類</strong>に分けて捉えている。あらかじめ断っておくと、この7つは一つの軸できれいに分割できるものではない。保存場所・出所・ライフサイクル・役割が混在しているため、一つの状態が複数の分類に同時に属することもある。完全な分類表ではなく、<strong>状態をどう管理するか決める際に投げかける問い</strong>だと考えてほしい。</p>
<ul>
<li><strong>ローカル状態（Local State）</strong>：一つのコンポーネント、または狭いツリー内だけで使う状態</li>
<li><strong>グローバル状態（Global State）</strong>：アプリ全体で共有する必要がある状態</li>
<li><strong>サーバー状態（Server State）</strong>：サーバーがSingle Source of Truthで、クライアント側はキャッシュである状態</li>
<li><strong>フォーム状態（Form State）</strong>：ユーザーの入力中だけ一時的に存在する状態</li>
<li><strong>URL状態（URL State）</strong>：アドレスバーにあり、共有でき、再読み込み後も残る状態</li>
<li><strong>外部状態（External State）</strong>：Cookie、localStorage、sessionStorage、IndexedDBなど、Reactの外部にある状態</li>
<li><strong>状態ガード（State Guard）</strong>：状態そのものではなく、状態の組み合わせに応じてアクセスや実行を制御・検証するロジック</li>
</ul>
<p>この分類以外にも、状態マシンで精緻化すべきワークフロー状態や、WebSocket・CRDTを基盤とするリアルタイム共同編集状態がある。</p>
<p>それぞれに異なるツールが必要な理由と、どのような視点で向き合うべきかを、一つずつ見ていこう。</p>
<h2 id="ローカル状態local-state"><a class="anchor" href="#ローカル状態local-state">ローカル状態（Local State）</a></h2>
<p>最も単純な状態である。一つのコンポーネント内でのみ使われ、外部が知る必要も、知る権利もない状態だ。モーダルの開閉、トグルボタンのon/off、ホバー状態、入力途中の検索語などが該当する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SearchBox</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{query} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e.target.value)} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>この程度なら馴染みがあるだろう。しかし、ローカル状態で本当に難しいのは、「<strong>この状態をどこに置くべきか</strong>」という配置の判断である。</p>
<p><a href="https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster" target="_blank" rel="noopener noreferrer">Kent C. DoddsのState Colocation</a>の記事では、<strong>人々は状態を「引き上げる（lift up）」ことには慣れているが、コードが変わったときに状態を再び「近くに置く（colocate）」ことはあまりしない</strong>。と述べている。</p>
<p>状態を引き上げるのは、兄弟コンポーネントが同じ状態を共有する必要があるときに、私たちが自然に取る行動だ。二つの兄弟が同じデータを見る必要があるため、共通の親へ状態を移し、propsで渡す。</p>
<p>問題は、その状態が兄弟に必要なくなったときである。私たちは状態を再び子の側へ<strong>引き下げる</strong>ことを、あまりしない。その結果、親コンポーネントには本来関係のない状態が大量に蓄積し、親が再レンダーされるたびに、子のツリー全体まで再レンダーされることになる。</p>
<p>したがって、ローカル状態に関する第一の設計眼は、<strong>高速かつシンプルにするため、状態はそれを使うコードのできるだけ近くに置く</strong>ことである。あるコンポーネントの子の一つでしか使わない状態なら、親が持つ理由はない。その子の内部へ移そう。その分、親は軽くなる。</p>
<h2 id="グローバル状態global-state"><a class="anchor" href="#グローバル状態global-state">グローバル状態（Global State）</a></h2>
<p>グローバル状態とは、アプリのどこからでもアクセスできる必要がある状態である。ログイン情報、テーマ、言語、通知（トースト）などが候補になる。</p>
<p>ローカル状態とグローバル状態の違いは、単に「どこに存在するか」ではない。<strong>参照に関する契約</strong>が異なる。ローカル状態は「<strong>このコンポーネントの内部でのみ意味を持つ</strong>」という契約を、グローバル状態は「<strong>アプリのどこからでも、この名前でこの値を参照できる</strong>」という契約をコード全体に公開する。その契約のコストが高いことこそ、グローバル状態の本質である。</p>
<p>グローバル状態を一つ作ることは、実のところ、<strong>アプリ全体にまたがる暗黙的な依存関係</strong>を一つ追加することだ。</p>
<h2 id="サーバー状態server-state"><a class="anchor" href="#サーバー状態server-state">サーバー状態（Server State）</a></h2>
<p>APIから受け取ったデータをClient Stateに入れ、ローディングとエラーをbooleanで直接管理し続けた末に、「<strong>なぜ毎回、同じボイラープレートを書いているのだろう</strong>？」という疑問へ行き着く。</p>
<p>TanStackのメインメンテナーであるTanner Linsleyは、<strong>「Client Stateは同期的で予測可能だ。Server Stateは非同期で、複数のコンポーネントにまたがって共有され、キャッシュ・バックグラウンド更新・エラー状態を慎重に扱う必要がある」<strong>と述べている。つまり、Server StateはClient Stateとは</strong>本質的に別種のもの</strong>である。同じツールで扱うべきではない。</p>
<p>Server Stateが難しいのは、ツールの問題ではなく、<strong>データの本質</strong>によるものだ。</p>
<p>クライアントが見ているデータは、サーバーのものだ。クライアントが持つデータは、<strong>ある時点のスナップショット</strong>にすぎない。このデータは時間の経過とともにstaleになる。また、非同期で失敗する可能性があり、pending、error、successなどの状態を持つ。</p>
<p>最も重要な本質は、<strong>レスポンスがリクエストを送った順番どおりに返る保証はない</strong>という点だ。検索欄へ「react」と素早く入力するとしよう。r → re → rea → reac → reactの順にリクエストは送られるが、「react」のレスポンスが先に到着し、その後で「rea」のレスポンスが届けば、画面には「rea」の結果が表示されてしまう。この問題を防ぐには、AbortControllerやリクエストIDの追跡を毎回手作業で実装する必要があり、<strong>並行処理に伴う危険</strong>（race conditions）を考慮しなければならない。</p>
<h2 id="フォーム状態form-state"><a class="anchor" href="#フォーム状態form-state">フォーム状態（Form State）</a></h2>
<p>フォームは少し特殊な状態である。ユーザーが入力している間は激しく変化するが、送信が終われば通常は消える。他の場所と共有されることも、保存先があることも（ほとんどの場合）ない。</p>
<p>問題は、この「激しい変化」のコストが高いことだ。キー入力のたびにReactの再レンダーが起きれば、大きなフォームでは入力遅延が目立つようになる。そしてフォームは、単に「値を保持する」だけのものではない。<strong>バリデーション、dirty check、送信状態、エラーメッセージ、複数ステップのフロー</strong>まで、一つのフォーム内で複数種類の状態が同時に動き続ける。</p>
<p>3段階の決済フローのような複数ステップのフォームには、<strong>「途中で再読み込みしても進行状態が残ること」<strong>が期待される。このときフォームの値をuseStateだけで保持していれば、再読み込みですべて失われる。<strong>sessionStorage</strong>（タブ単位の一時保存）や</strong>URL</strong>（共有可能なステップ）に保存するのが自然だ。つまりフォーム状態は、ライフサイクル上の要件に応じて、<strong>External State</strong>や<strong>URL State</strong>と組み合わせることになる。</p>
<h2 id="url状態url-state"><a class="anchor" href="#url状態url-state">URL状態（URL State）</a></h2>
<p>検索ページで、カテゴリー・並び順・ページ番号を使って絞り込んでいるとしよう。これらの状態をuseStateで保持すると、三つの問題が同時に生じる。</p>
<ul>
<li>再読み込みすると、すべてのフィルターが初期化される</li>
<li>URLを友人に共有しても、相手にはフィルターが適用されていないページが表示される</li>
<li>戻るボタンを押しても、以前のフィルターへ戻らない</li>
</ul>
<p>こうした問題を解決するには、<strong>状態をURLに入れるのが自然だ。</strong> URLはそれ自体が、再読み込み・共有・履歴に対応した、無料で使える永続ストレージである。</p>
<pre><code>/products?category=shoes&#x26;sort=price-desc&#x26;page=2
</code></pre>
<p>この一行のURLには、「<strong>靴カテゴリーを価格の降順で並べた2ページ目</strong>」という完全な状態が含まれている。useStateで別途保持する必要はない。</p>
<p>では、どのような状態をURLで扱うのが適切だろうか。<strong>URLは公開インターフェースである。</strong> パスワード、認証トークン、ユーザーが他人に見せたくない一時的なメモなどをURLへ入れてはいけない。また、頻繁に変わる値（入力のたびに更新される検索語）をそのままURLへ反映すると、履歴スタックがゴミで埋まってしまう。この場合はdebounceしてから反映するか、<code>push</code>ではなく<code>replace</code>を使い、履歴を汚さないようにする必要がある。</p>
<p>URLの値は<strong>常に文字列</strong>である。数値、boolean、配列、オブジェクトには、シリアライズとデシリアライズの工程が必要だ。さらにURLは、<a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams" target="_blank" rel="noopener noreferrer">パーセントエンコーディング（percent-encoding）</a>の規則に従う必要があるため、<code>&#x26;</code>、<code>=</code>、韓国語、スペースなどは特別に処理される。これを毎回手作業で実装すれば、やがてバグの温床になる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> URLSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(location.search);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> page</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "1"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(page </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">navigate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`?${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">page</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQueryState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, parseAsInteger.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">withDefault</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p><a href="https://nuqs.dev/" target="_blank" rel="noopener noreferrer">nuqs</a>のようなライブラリは、<strong>パーサー（parser</strong>）という概念で、この二つの問題を解決する。<code>parseAsInteger</code>、<code>parseAsBoolean</code>、<code>parseAsJson</code>といったパーサーが、シリアライズ・デシリアライズ・型をまとめて担う。Next.js（App/Pages Routerの両方）、React Router v6/v7、TanStack Router、Remixなど、ほとんどの環境に対応している。</p>
<p>では、URLへ状態をいくらでも詰め込んでよいのだろうか。シリアライズや型の問題とは別に、最後に考慮すべき制約が一つ残っている。<a href="https://datatracker.ietf.org/doc/html/rfc7230" target="_blank" rel="noopener noreferrer">RFC 7230</a>は正確な上限を定めてはいないものの、「サーバーは最低でも8,000オクテット（ネットワークやデータ通信において、8 Bitからなる1 Byteを明確に指す際に使う単位）をサポートすべき」と推奨している。ブラウザごとの上限もさまざまで、モダンブラウザではおおむね8KBから数万文字まで許容されるが、<strong>検索エンジンやソーシャルメディアのOG／共有処理、一部のゲートウェイでは2KB前後で切り捨てられることもある。</strong> したがって、URLへ無制限に詰め込むのは避けよう。<strong>共有可能な主要フィルター</strong>だけを置き、残りはsessionStorageやサーバー側の保存に任せるのが安全だ。</p>
<h2 id="外部状態external-state"><a class="anchor" href="#外部状態external-state">外部状態（External State）</a></h2>
<p>Reactが認識できるのは、自身の内部にある状態だけだ。しかし、私たちのアプリはReactの外の世界とも絶えずやり取りしている。そこに存在する状態はReactのライフサイクルと無関係に残り、変化する。ここでいうExternal Stateには、<strong>Cookie、localStorage、sessionStorage、IndexedDB</strong>がある。</p>
<p>ストレージはどのように選ぶのが適切だろうか。筆者は通常、<strong>寿命・容量・同期性・セキュリティ</strong>という四つの観点から考える。</p>
<p><strong>認証トークン</strong>に関する<a href="https://owasp.org/www-community/HttpOnly" target="_blank" rel="noopener noreferrer">OWASPの推奨</a>では、<strong>HttpOnly + Secure Cookie</strong>が第一候補となる。localStorageはJavaScriptからアクセスできるため、<strong>XSSにさらされた瞬間、トークンをそのまま盗まれる。</strong> セキュリティガイドの中には、<strong>アクセストークンはメモリに、リフレッシュトークンはHttpOnly Cookieに</strong>置くハイブリッドパターンを推奨するものもある。永続的で、機密性がなく、頻繁に変わらないデータにはlocalStorageを、タブとともに消えるデータにはsessionStorageを活用する。オフラインキャッシュ・大容量データ・ファイルを扱う際には、一般にIndexedDBを利用する。</p>
<p>CookieとWeb Storage（local/session）は、<strong>文字列しか保存できない。</strong> そのため、オブジェクトを入れるには<code>JSON.stringify</code>/<code>JSON.parse</code>を経由する必要がある。しかし、JSONには限界がある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ when: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → { "when": "2026-05-19T..." } — Date becomes a string</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ map: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"a"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]]) });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → { "map": {} } — Map is lost entirely</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ value: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → "{}" — the undefined field is omitted</span></span></code></pre></figure>
<p><code>Date</code>はJSONとの往復で文字列になり、<code>Map</code>、<code>Set</code>、<code>undefined</code>ではデータが失われることがある。<code>BigInt</code>はデフォルトでは<code>JSON.stringify</code>が<code>TypeError</code>をスローし、シリアライズ自体が失敗する。外部ストレージへオブジェクトを入れるときは、<strong>どの型が失われたり変化したり、シリアライズを失敗させたりするか</strong>を常に意識し、必要に応じてシリアライズ用のアダプターを用意しなければならない。</p>
<p>External Stateの本当の難しさは、<strong>Reactがその変化を自動では検知できない</strong>点にある。localStorageへ値を書き込んでも、Reactコンポーネントは再レンダーされない。これを解決するパターンは、主に三つある。</p>
<ul>
<li><strong>カスタムフック（useLocalStorage）で一段ラップし、External StateをReact stateと同期する。</strong> 軽量だが、自分で実装するなら、複数タブ・SSR・tearingといったエッジケースをすべて扱わなければならない。</li>
<li>React 18で導入された<code>useSyncExternalStore</code>フックを使い、<strong>「React外部の状態と同期できる」。</strong> これにより、<strong>並行レンダリングでtearingが発生しないことを保証できる。</strong> localStorage・ブラウザAPI・外部ストアをつなぐ標準的なツールである。</li>
<li>Zustandの<code>persist</code>ミドルウェアやJotaiの<code>atomWithStorage</code>のように、状態管理ライブラリは外部ストレージとの連携を第一級の機能として提供しているため、既存のライブラリを活用できる。</li>
</ul>
<p>ここでもう一つ、設計上の視点を加えよう。<strong>External StateをReactへ取り込んだ瞬間、同期の責任は私たちに移る。</strong> 別のタブで更新されたら？ サーバーがCookieを変更したら？ ユーザーがブラウザの開発者ツールからlocalStorageを直接編集したら？ こうした事象が、最も大きなバグの温床になることも多い。</p>
<h2 id="状態ガードstate-guard"><a class="anchor" href="#状態ガードstate-guard">状態ガード（State Guard）</a></h2>
<p>最後の分類は、少し性質が異なる。状態そのものではなく、<strong>状態の組み合わせによって、何らかのフローを止めたり許可したりするロジック</strong>である。</p>
<p>最も一般的な例は、<strong>認証ガード（Auth Guard</strong>）だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ProtectedRoute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isAuthenticated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isAuthenticated) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ここでは、<code>isAuthenticated</code>という状態がルーティングのフローを制御している。これがガードロジックである。認証ガード（認証済みか）、権限ガード（特定のロールや権限を持つか）、フローガード（進入先の分岐）、検証ガード（ステップを有効化できるか）など、さまざまな種類のガードがある。</p>
<p>ガードロジックは、一か所に集中しやすい。一つのコンポーネントに、「<strong>未ログインならログインページ、権限がなければ403、カートが空なら商品ページ、ユーザーが停止中なら停止案内</strong>」がすべて入ってしまうことも珍しくない。ガードが肥大化するほど、どの条件によって、どこで止められたのかをデバッグしにくくなる。</p>
<p>優れたガードは、<strong>一つのことだけを検査する。</strong> 組み合わせにはCompositionを使う。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"admin"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> require</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cartHasItems"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CheckoutPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>各ガードは一つの判断だけを行い、合成はツリー構造が担う。新しいガードを追加するときに、既存のガードへ手を加える必要はない。</p>
<p>ガードを扱う際、止めること以上に考えるべきなのは、<strong>どこへ遷移させるか／その後どう扱うかを決めること</strong>だ。止めるだけでフォールバックのないガードは、真っ白な画面や無限スピナーに行き着く。</p>
<p>最もよくあるバグは、「<strong>ガードの非同期チェックが終わる前に、保護対象のコンテンツが一瞬表示される</strong>」ことだ。認証トークンの検証や権限の取得は、ほとんどが非同期であり、その間、<code>isAuthenticated</code>が一時的に<code>undefined</code>や<code>false</code>になる瞬間がある。<strong>ローディング状態を明示的に扱わなければ、その隙に保護対象の画面が露出したり、誤ってログインページへリダイレクトされたりする。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Ignores loading and handles only missing data => incorrect</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Treat loading as a first-class state (early return) => correct</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span></code></pre></figure>
<p>権限ガードを実装するときは、二つのモデルがよく使われる。</p>
<ul>
<li><strong>RBAC（Role-Based Access Control）</strong>：ロール単位で権限を付与する。「adminはすべてのユーザー情報を閲覧できる」といった形だ。シンプルで高速だが、ロールを細分化するほど、その数が爆発的に増える</li>
<li><strong>ABAC（Attribute-Based Access Control）</strong>：属性の組み合わせで権限を決める。「ユーザーがその投稿の作成者である、同じチームに所属している、またはadminである場合」といった形だ。表現力は高いが、実装とデバッグが難しい</li>
</ul>
<p><a href="https://tanstack.com/router/v1/docs/framework/react/how-to/setup-rbac" target="_blank" rel="noopener noreferrer">TanStack RouterのRBACガイド</a>のように、ルーターレベルで<code>beforeLoad</code>にガードを組み込むパターンが推奨される。重要なのは、<strong>権限チェックをコードの各所にばらまかず、データ（ロール／権限の一覧）として表現できるようにすること</strong>だ。そうすれば、権限ポリシーの変更は<strong>データの変更</strong>だけで済む。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>整理しよう。状態管理が難しいのは、ライブラリが難しいからではない。<strong>状態には種類があるという事実を忘れがちだから</strong>であり、種類ごとに異なるツールと考え方が必要だという点を見落としやすいからだ。</p>
<p>ローカル状態はできるだけ近くへ置く。グローバル状態は本当にグローバルであるべきか、もう一度疑う。Server Stateはキャッシュとして扱う。フォームはドメインから分離する。URLはより積極的に活用する。外部ストレージは責任を意識する。ガードは薄く分けて合成する。これが7つの分類を扱うための基本である。</p>
<p>そして、それらすべての土台となる設計眼は、最終的に四つの問いへ集約される。</p>
<ul>
<li>このデータのSingle Source of Truthはどこにあるか？</li>
<li>これは計算で求められる値か、それとも本当に保存すべき値か？</li>
<li>この状態の組み合わせに、不可能な組み合わせはないか？</li>
<li>この状態は、本当にこの場所にあるべきか？</li>
</ul>
<p>新しい画面を実装するとき、PRをレビューするとき、AIが生成したコードを受け取るとき。そのたびに、これらの問いを一度ずつ投げかける。それこそが、設計眼と感覚を養う最も確実な道だと筆者は信じている。</p>
<p>冒頭で述べたとおり、AIはこれからも長く私たちのそばにいるだろう。コードを一行ずつ確認する時間は、ますます減っていく。しかし、だからこそ、「<strong>この状態はどこにあるべきか</strong>？」という小さな問いに答えられる感覚の価値は高まる。AIに「ここへuseStateをもう一つ追加して」と頼むのは簡単だ。だが、その一行がアプリの蜘蛛の巣にどのような糸を加えるのかを理解できるかどうかは、そのコードを読む人の設計眼にかかっている。</p>
<p>正解はない。それでも、「<strong>状態が何かを理解せずに状態を作ること</strong>」と、「<strong>状態の種類と配置を意識して作ること</strong>」の間には、明確な違いがある。この記事を読んだ方にも、次に<code>useState</code>を一行書く前に一度立ち止まり、「これはどの分類の状態だろう？」と問いかけてみてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/choosing-the-state-structure" target="_blank" rel="noopener noreferrer">React, Choosing the State Structure</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://legacy.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html" target="_blank" rel="noopener noreferrer">React, You Probably Don't Need Derived State</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://xstate.js.org/" target="_blank" rel="noopener noreferrer">XState</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.syncfusion.com/blogs/post/react-state-management-libraries" target="_blank" rel="noopener noreferrer">Top 5 React State Management Tools in 2026</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>状態管理</category>
            <category>React</category>
            <category>アーキテクチャ</category>
        </item>
        <item>
            <title><![CDATA[ドメインモデル]]></title>
            <link>https://hooninedev.com/ja/260418</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260418</guid>
            <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、フロントエンドのドメインロジックをどこに置くべきかについて話してみたい。 税金計算や申告ステータスの判定といったビジネスルールがコンポーネントやフック、utilsに散らばっていて、ルールが一つ変わるたびに複数のファイルを探して直しているフロントエンド開発者に向けた記事である。最後まで読めば、そのルールをReactに依存しない純粋関数へ移す方法と、その分離をどこで止めるかを判断する基...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>フロントエンドのドメインロジックをどこに置くべきか</strong>について話してみたい。</p>
<p>税金計算や申告ステータスの判定といったビジネスルールがコンポーネントやフック、utilsに散らばっていて、ルールが一つ変わるたびに複数のファイルを探して直しているフロントエンド開発者に向けた記事である。最後まで読めば、そのルールをReactに依存しない純粋関数へ移す方法と、その分離をどこで止めるかを判断する基準が得られる。</p>
<p>例はすべて総合所得税の計算で説明する。筆者が関心を持っているドメインである。</p>
<h2 id="フロントエンドのドメインロジックはどこに置くべきか"><a class="anchor" href="#フロントエンドのドメインロジックはどこに置くべきか">フロントエンドのドメインロジックはどこに置くべきか？</a></h2>
<p>答える前に、用語を一つそろえておく。この記事でいう<strong>ドメインモデル</strong>は、ビジネスの概念とその上で働くルールを一緒に持つものである。APIレスポンスの型のように、データがどんな形でやり取りされるかだけを定義する<strong>データモデル</strong>とは異なる。コンポーネントがレスポンスの構造に直接依存すると、バックエンドのスキーマが変わるたびにフロントエンド全体が揺らぐのも、この違いのためである。ドメイン、ドメインモデル、ドメインオブジェクト、ドメインオブジェクトモデルという4つの用語の違いは、<a href="/260413" target="_blank" rel="noopener noreferrer">ドメイン用語</a>に別途まとめておいた。</p>
<p>ソフトウェア設計に深い関心を持つ<a href="https://khalilstemmler.com/about/" target="_blank" rel="noopener noreferrer">Khalil Stemmler</a>は、当初「ビジネスロジックはフロントエンドに属さない」と主張していたが、後に「バックエンドでアーキテクチャ上行っているほぼすべてのことを、フロントエンドでも実践できるし、実践すべきだ」と立場を改めた。</p>
<p>筆者もこの立場に同意する。もちろん、フロントエンドがビジネスロジックの <strong>信頼できる唯一の情報源（Single Source of Truth）</strong> になってはならない。それはバックエンドの役割である。しかし、フロントエンドにも <strong>フロントエンド独自のドメインロジック</strong> が確かに存在する。</p>
<p>「ユーザーが入力した情報に応じて、予想還付額をリアルタイムに表示しなければならない」ケースを考えてみよう。この計算ロジックがバックエンドにしかなければ、ユーザーが所得金額を一文字直すたびにAPIを呼び出す必要がある。ネットワークの往復時間だけUIが止まり、入力の速いユーザーなら不要なリクエストが爆発的に増えてしまう。debounceを適用しても、数百ミリ秒の遅延は「リアルタイムプレビュー」という体験を損なうのに十分である。<strong>結局、即時フィードバックが必要な計算はフロントエンド自身が実行せざるを得ず、フロントエンドでしか実行できないロジックが存在することになる。</strong></p>
<h3 id="ドメインロジックがコンポーネントに混在している場合"><a class="anchor" href="#ドメインロジックがコンポーネントに混在している場合">ドメインロジックがコンポーネントに混在している場合</a></h3>
<p>総合所得税のプレビュー画面を例にしよう。ユーザーが所得情報を入力すると、予想税額をリアルタイムに表示する機能である。次は、ドメインロジックとUIロジックが入り交じった、よく見かけるコードだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 종합소득금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 경비율;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 소득공제합계</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 인적공제대상인원 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 과세표준</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, 종합소득금액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 소득공제합계);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 50_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.15</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_260_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 88_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.24</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5_760_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 150_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.35</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 15_440_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.38</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 19_940_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 기납부세액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> refundOrPayment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 기납부세액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>...&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>このコードの問題が見えるだろうか？「人的控除は1人当たり150万ウォン」「8段階の累進税率」「3.3%の源泉徴収」という <strong>税法で定められたビジネスルール</strong> が、Reactコンポーネント内へ直接埋め込まれている。税法は毎年改正されるため、このようなルールがコンポーネントに散らばっていれば、改正時に修正箇所を探し回ることになる。開発者だけでなく、QAチームが管理するE2Eシナリオがあるなら、テストコストも無視できないだろう。</p>
<p>やがて、これがビューロジックなのかビジネスロジックなのか区別しづらくなり、大量の条件分岐とカスタムフックが複雑に絡み合ってしまう。</p>
<h3 id="ドメインロジックを分離してみよう"><a class="anchor" href="#ドメインロジックを分離してみよう">ドメインロジックを分離してみよう</a></h3>
<p>Alex BespoyasovのClean Architectureのアプローチから、中心的な原則を借りよう。ドメインロジックを、<strong>フレームワークに依存しない純粋関数</strong>として分離するのである。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>ドメインは、あるアプリケーションを別のアプリケーションと区別する中核である。ReactからAngularへ移行しても変わらないもの、と考えることができる。</p></div><div class="quote-original" lang="en"><p>The domain is the core that distinguishes one application from another. You can think of the domain as something that won't change if we move from React to Angular.</p></div></blockquote>
<p>先ほどの税額計算の例をリファクタリングしてみよう。</p>
<p>まず、ドメインの型とルールを定義し、関連する情報をまとめる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  grossAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  expenseRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  personalCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  pensionPaid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  additionalDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> PERSONAL_DEDUCTION_PER_PERSON</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> WITHHOLDING_RATE</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { limit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, rate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, progressiveDeduction: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /** ...구간들... **/</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>次に、ドメインロジックを純粋関数として分離する。</p>
<p>先ほどの所得計算、控除、課税標準、税額、還付額などの計算ロジックを<code>computeFullTax</code>関数へ分離する。各段階は、さらに小さな純粋関数へ分割する。結果の型を<code>ReturnType&#x3C;typeof computeFullTax></code>で推論させれば、別途インターフェースを宣言する必要はない。</p>
<p>その後、コンポーネントはドメインロジックを「使う」だけにする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { computeFullTax } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    grossAmount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    expenseRate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDeductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    personalCount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    pensionPaid: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    additionalDeductions: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> computeFullTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(income, deductions);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">IncomeForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{income} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIncome} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DeductionForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{deductions} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setDeductions} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TaxResultSummary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{result} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>何が変わったのだろうか？</p>
<ul>
<li><strong>8段階の累進税率表</strong>（<code>TAX_BRACKETS</code>）が一か所にまとまり、税法改正時は<code>domain/tax.ts</code>だけを修正すればよい</li>
<li><strong>計算パイプライン</strong>が<code>computeFullTax</code>という一つの関数へ集約され、全体の流れをひと目で把握できる。（例を単純にするため一つにまとめたが、実際のプロジェクトでは所得計算・控除計算・税額算出など、目的別にさらに細分化するのが適切である。）</li>
<li><strong>コンポーネントは「どう見せるか」だけに集中</strong>する。税率が変わってもコンポーネントを修正する必要はない</li>
<li>Reactから別のフレームワークへ移行しても、<code>domain/tax.ts</code>は <strong>変わらない</strong></li>
</ul>
<p>ドメインロジックを分離すると、テストは驚くほど単純になる。税金ドメインでは、<strong>計算の正確さがそのままユーザーのお金に直結する</strong>ため、この点は特に重要である。</p>
<p>税金に関する計算ロジックを持つ純粋関数には、React Testing Libraryも、<code>render</code>も、<code>screen.getByText</code>も必要ない。入力を与えて出力を確認するだけでよい。「1,400万ウォン以下は税率6%」「課税標準が0ウォンなら税額も0ウォン」「フリーランサーの所得3,000万ウォンに対する還付額」といったケースを、一行の<code>it</code>で表現できる。ドメイン単体テストはコンポーネントを分離する基準を自然に示し、テストコードはドキュメントとしての役割まで果たす。</p>
<h2 id="貧血ドメインモデルanemic-domain-model"><a class="anchor" href="#貧血ドメインモデルanemic-domain-model">貧血ドメインモデル（Anemic Domain Model）</a></h2>
<p>前節では、<strong>計算ロジック</strong>を分離した。しかし、ドメインロジックには計算以外にも、<strong>状態遷移ルール</strong>と <strong>権限の判定</strong> がある。「この申告書は今編集できるか？」「提出できるか？」「請求方式を切り替えられるか？」といった問いがそれに当たる。このようなルールを分離する際、陥りやすい罠が一つある。Martin Fowlerが名付けた <strong>貧血ドメインモデル（Anemic Domain Model）</strong> である。</p>
<p>貧血ドメインモデルとは、<strong>型はドメインの言葉で適切に定義されているのに、その上で動作するルールがドメインの外へ散らばっている</strong>状態を指す。税務申告（Filing）ドメインを例にしよう。型はすっきりしている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// types/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ところが、この型に関する判定・遷移ルールは、別の場所に埋め込まれている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/filingHelpers.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmendFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// components/FilingDetail.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingDetail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 같은 도메인 규칙을 컴포넌트 안에 다시 작성한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> canEdit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// hooks/useSubmitFiling.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmitFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>同じドメインルールが、utils、コンポーネント、フックの三か所に、それぞれ異なる形で存在している。この状態で「請求可能条件を変更します」という要件が入れば、修正すべき場所を探し回ることになる。そして一か所でも見落とせば、サイトのどこかで誤った判定が行われる。Fowlerはこのようなコードを、<strong>「オブジェクト指向の皮をかぶった手続き的コードと変わらない」</strong> と批判した。</p>
<p>解決策は、前節で計算ロジックに適用したものと同じである。<strong>ルールを型の隣に置く。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 규칙은 도메인 옆에 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>これで、申告に関するルールは<code>domain/filing.ts</code>の一か所で管理される。どのコンポーネントでも<code>canAmend(filing)</code>を呼び出せばよく、ルールが変わればこのファイルだけを修正すればよい。重要なのは、<strong>型と、その上で働くルールを一つのまとまりとして捉えることだ。</strong> 型だけをドメインフォルダに置き、ルールをutilsへ切り出す部分的な分離は、見た目がすっきりしていても、依然として貧血状態である。</p>
<h2 id="apiレスポンスとドメインモデルの間の変換層"><a class="anchor" href="#apiレスポンスとドメインモデルの間の変換層">APIレスポンスとドメインモデルの間の変換層</a></h2>
<p>実務では、もう一つ考慮すべきことがある。バックエンドのAPIレスポンス構造とフロントエンドのドメインモデルが、常に一致するとは限らない点だ。国の機関と連携する税金サービスなら、なおさらである。韓国国税庁のHometaxから連携されるデータは略語やコード値にあふれており、フロントエンドのドメインモデルと同じ形で返ってくる可能性は低い。</p>
<p>そこで必要になるのが <strong>変換層（Mapper）</strong> である。APIレスポンスの型をそのままコンポーネントまで流すのではなく、一度ドメインの型へ整えてから使う。純粋関数が一つあれば十分だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Income } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> HometaxIncomeResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  총수입금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  경비율</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  소득유형코드</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 나머지 약어 필드들</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> toIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> HometaxIncomeResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    총수입_금액: response.총수입금액,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    경비_비율: response.경비율,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>こうすれば、APIレスポンスの<code>총수입금액</code>、<code>경비율</code>のような略語やコード値による分類を、フロントエンドのドメインに合わせて <strong>一か所で</strong> 変換できる。所得区分コードのようにenumへ展開する必要がある値には、mapper内に小さなlookup tableを置けばよい。Hometax APIのフィールド名が変わっても、mapper一つを修正するだけで済む。</p>
<h2 id="ユーティリティ関数とドメインロジック"><a class="anchor" href="#ユーティリティ関数とドメインロジック">ユーティリティ関数とドメインロジック</a></h2>
<p>ドメインロジックを分離していると、必ずぶつかる問いがある。<strong>「これはユーティリティ関数ではないのか？」</strong></p>
<p>たとえば、次の二つの関数を見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatCurrency</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">amount</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toLocaleString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}원`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">taxableBase</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bracket</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">bracket</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> taxableBase </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.limit);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(taxableBase </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.rate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.progressiveDeduction);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>formatCurrency</code>は、数値を文字列へ変換する純粋な <strong>プレゼンテーション（Presentation）ロジック</strong> である。「ウォン」という単位を付け、3桁ごとにカンマを入れることはビジネスルールではなく、ユーザーにどう見せるかに関するものだ。一方、<code>calculateTax</code>には「8段階の累進税率を適用する」という、<strong>税法に基づくビジネスルール</strong> が含まれている。これはUIがなくても同じように適用されるべき、ドメインのルールである。</p>
<p>筆者が実務で用いる判断基準は、次のとおりだ。</p>
<blockquote>
<p><strong>このロジックがなくなったとき、ビジネスが壊れるのか、それとも画面だけが壊れるのか？</strong></p>
</blockquote>
<p>ビジネスが壊れるならドメインロジックであり、画面だけが壊れるならプレゼンテーションロジックである。この問い一つで、ほとんどの境界を区別できる。</p>
<table>
<thead>
<tr>
<th>判断基準</th>
<th>ドメインロジック</th>
<th>ユーティリティ／プレゼンテーションロジック</th>
</tr>
</thead>
<tbody>
<tr>
<td>ないと何が壊れるか？</td>
<td>税額計算を誤る</td>
<td>画面（UI）の表示がおかしくなる</td>
</tr>
<tr>
<td>フレームワークが変わると？</td>
<td>そのまま維持される</td>
<td>変わる可能性がある</td>
</tr>
<tr>
<td>企画書に明記されているか？</td>
<td>「課税標準 × 税率 - 累進控除」</td>
<td>「金額はカンマ区切り」</td>
</tr>
<tr>
<td>バックエンドにも同じロジックがあるか？</td>
<td>ある、またはあるべき</td>
<td>ない（フロントエンドだけの関心事）</td>
</tr>
</tbody>
</table>
<p>しかし、現実はそれほど明快ではない。最も厄介なのは、<strong>ドメインロジックのように見えて、実際にはプレゼンテーションロジックである場合</strong>だ。</p>
<p>次のコードを見てみよう。FilingStatusというドメインの概念を引数として扱うため、ドメインロジックに分類している。しかし、本当にこれはドメインロジックなのだろうか？</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// domain/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getStatusBadgeColor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> colors</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    draft: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"gray"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    submitted: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    reviewing: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"yellow"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completed: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"green"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    amended: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"purple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> colors[status];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getStatusDisplayText</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> labels</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    draft: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"작성 중"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    submitted: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"제출 완료"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    reviewing: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"검토 중"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completed: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"신고 완료"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    amended: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"경정청구"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> labels[status];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>getStatusBadgeColor</code>と<code>getStatusDisplayText</code>は、<code>FilingStatus</code>というドメインの概念を使ってはいるが、行っているのは <strong>画面上の表現</strong> である。バッジの色が変わっても、ビジネスには何の影響もない。このような関数を<code>domain/filing.ts</code>へ入れると、ドメインモジュールが次第に肥大化し、本当のドメインロジックとプレゼンテーションロジックが混在することになる。</p>
<h3 id="ドメインモデルとviewmodelの分離"><a class="anchor" href="#ドメインモデルとviewmodelの分離">ドメインモデルとViewModelの分離</a></h3>
<p>この問題を解決する実用的な方法がある。<strong>同じドメインフォルダ内で、ViewModelを別ファイルに分離する</strong>ことだ。<code>.ui.ts</code>ではなく<code>.viewModel.ts</code>という名前を使えば、MVVMパターンのViewModelという概念へ自然につながる。「ドメインデータを画面に合わせて変換する層」という役割が、名前からすぐに伝わるためだ。</p>
<pre><code>domains/
└── filing/
    ├── filing.ts              # 순수 도메인 모델 + 도메인 로직
    ├── filing.viewModel.ts    # ViewModel (표현 변환 계층)
    ├── filing.test.ts         # 도메인 로직 테스트
    └── filingMapper.ts        # API ↔ 도메인 변환
</code></pre>
<p>先ほど見た<code>getStatusBadgeColor</code>、<code>getStatusDisplayText</code>を、そのまま<code>filing.viewModel.ts</code>へ移す。さらに、<code>getFilingTypeLabel(type: FilingType): string</code>のように、申告の種類を韓国語のラベルへ展開する変換もここへ集める。<code>filing.ts</code>はビジネスルールだけ、<code>filing.viewModel.ts</code>は画面上の表現だけを担う。</p>
<p>重要なのは <strong>依存関係の向き</strong> である。<code>filing.viewModel.ts</code>は<code>filing.ts</code>をimportするが、<code>filing.ts</code>は<code>filing.viewModel.ts</code>を決してimportしない。ドメインはプレゼンテーションを知らず、プレゼンテーションがドメインを知る構造だ。これはRobert C. Martinが述べた依存性のルール（Dependency Rule）の縮図といえる。</p>
<p>筆者は、一緒に変更されるファイルは同じディレクトリに置くべきだと考え、同じフォルダへ配置した。<code>FilingStatus</code>型に新しい値（たとえば<code>'rejected'</code>）が加われば、<code>filing.ts</code>と<code>filing.viewModel.ts</code>の両方を修正しなければならない。同じフォルダにあれば、修正範囲をひと目で把握できる。</p>
<h2 id="境界と凝集"><a class="anchor" href="#境界と凝集">境界と凝集</a></h2>
<p>ドメインロジックを分離することと同じくらい重要なのが、<strong>どこに境界を引くか</strong> である。筆者が実務でよく直面する境界判断の問題を、いくつか整理してみよう。</p>
<p>フロントエンドで扱うデータは、おおむね四つの出所に分けられる。</p>
<ul>
<li><strong>サーバーデータ</strong>：APIレスポンスとして受け取ったもの</li>
<li><strong>派生データ</strong>：サーバーデータから計算されたもの</li>
<li><strong>UI状態</strong>：画面制御のためのもの、ユーザーの操作</li>
<li><strong>ユーザー入力</strong>：フォームへの入力途中のもの</li>
</ul>
<p>この四つを一つの型に混ぜると、ドメインモデルが汚染される。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안티패턴: 모든 것이 섞인 타입</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 서버 데이터 (도메인)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 파생 데이터 (도메인)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  refundAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  canAmend</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // UI 상태 (표현)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  isExpanded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  activeStep</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 임시 상태</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  editingDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deduction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>この型には、ドメインの概念、UI状態、一時データが一つのかごに入っている。<code>activeStep</code>が変わるたびに、申告ドメインが更新されることになる。（フォームのステップが変わることは、ビジネスイベントではない。）</p>
<p>改善するには、境界に従って型を分離する。<strong>ドメインモデル</strong>には<code>id</code>、<code>status</code>、<code>determinedTax</code>のようなビジネスの概念だけを、<strong>UI状態</strong>（<code>FilingFormViewState</code>）には<code>isExpanded</code>、<code>activeStep</code>のような画面制御だけを、<strong>フォーム状態</strong>（<code>DeductionEditForm</code>）には入力途中の一時データだけを含める。</p>
<p>こうすれば、それぞれの型が <strong>一つの変更理由</strong> だけを持つ。ドメインの型は税法が変わるときだけ、UI状態は画面設計が変わるときだけ、フォーム状態は入力UXが変わるときだけ修正される。</p>
<h3 id="一緒に変わるものは一緒に置こう"><a class="anchor" href="#一緒に変わるものは一緒に置こう">一緒に変わるものは一緒に置こう</a></h3>
<p>Eric EvansのDDDには、<strong>Aggregate（集約）</strong> という概念がある。「関連するオブジェクトのまとまりを一つの単位として扱うこと」である。フロントエンドでこの概念をそのまま適用する必要はないが、<strong>一緒に変わるデータとルールは一緒に置く。</strong> という中心的な原則は参考にできる。</p>
<p>税金サービスを例にすると、<code>Income</code>（所得）と<code>ExpenseRate</code>（必要経費率）は常に一緒に変わる。所得区分が変われば適用される必要経費率も変わり、総合所得金額の計算にも影響する。したがって、これらは一つのファイル<code>domain/tax.ts</code>へまとめる。</p>
<p>一方、<code>TaxFiling</code>（申告書）は税額計算とは別に変わり得る。申告書の状態遷移ルールが変わっても、税率の計算ロジックには影響しない。そのため、<code>domain/filing.ts</code>へ分離するのが適切である。</p>
<pre><code>이렇게 묻자: "A가 변할 때 B도 반드시 변해야 하는가?"
  → Yes: 같은 모듈에 둔다 (Income + ExpenseRate + TaxBracket)
  → No: 분리한다 (Tax 계산 ↔ Filing 상태관리)
</code></pre>
<h2 id="classと関数型"><a class="anchor" href="#classと関数型">Classと関数型</a></h2>
<p>ここまで読むと、一つの根本的な疑問が浮かぶかもしれない。これまでの例はすべて<code>interface</code>と純粋関数の組み合わせだったが、Classでドメインを表現すれば、凝集がより自然になるのではないだろうか？</p>
<p>そのとおりである。Classベースでドメインを表現すると、データと振る舞いが一つのオブジェクトにまとまるため、凝集がコード構造にそのまま現れる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  ) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  547200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>Classベースで書けば、振る舞いがデータへ帰属する。そして、利用側で主語が明確になる。<code>filing.canAmend()</code>は、まるで自然言語を読むように直感的だ。主語（filing）と動詞（canAmend）がはっきり結び付いている。<code>jihoon.eat('감자탕')</code>と書けば、「ジフンがカムジャタンを食べる」とすぐに読み取れるのと同じだ。</p>
<p>一方、関数型スタイルでは次のようになる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">eat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"감자탕"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>関数型では、データが外部に存在する。上の二つのコードは、<code>filing</code>というデータを引数として受け取り、何らかの動作を実行する。<code>eat</code>関数は、<code>jihoon</code>と<code>감자탕</code>というデータを引数として受け取って実行する。</p>
<p>結果として、主語と動詞の結び付きが弱くなる。<code>canAmend</code>という関数が<code>TaxFiling</code>に関係することは、ファイルを開くか型シグネチャを確認しなければ分からない。さらに、同じファイルに<code>canAmend(filing)</code>、<code>canEdit(filing)</code>、<code>calculateTax(taxableBase)</code>のような関数が混在すれば、どの関数がどのドメインに属するのかをひと目で把握しにくくなることがある。</p>
<h3 id="ではclassを使うべきか"><a class="anchor" href="#ではclassを使うべきか">では、Classを使うべきか？</a></h3>
<p>正直に言えば、答えは <strong>「状況による」</strong>。しかし、筆者の経験上、React + TypeScript環境でClassが万能ではない現実的な理由がある。</p>
<h4 id="reactの状態管理との摩擦"><a class="anchor" href="#reactの状態管理との摩擦">Reactの状態管理との摩擦</a></h4>
<p>Reactの状態管理は、基本的に <strong>Plain Object</strong> と最も自然に組み合わせられる。<code>useState</code>や<code>useReducer</code>は技術的にはどんな値でも保持でき、Redux DevTools自体がClassインスタンスのプロトタイプを取り除くわけでもない。ただし、Redux/Zustandの永続化ミドルウェアが状態をJSONとして保存・復元すると、Classインスタンスは<code>JSON.stringify</code> → <code>JSON.parse</code>のサイクルでメソッドとプロトタイプを失い、plain objectになる。一方、React Server ComponentからClient Componentへpropsを渡す境界は、対応するシリアライズ可能な（serializable）値だけを受け付けるため、任意のClassインスタンスはそもそも渡せない。</p>
<p>次のコードを見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>Reactの状態を更新するだけなら、<code>filing</code>が<code>TaxFilingModel</code>のインスタンスでなくなることはない。ただし、Redux/Zustandの永続化でJSONとして保存・復元されると、値がメソッドのないplain objectになる可能性があり、何気なく呼び出した<code>filing.canAmend()</code>がランタイムエラーを起こし得る。React Server ComponentからClient Componentへ渡す場合は、Classインスタンスが対応するシリアライズ形式ではないため、受け渡しの時点で失敗する。</p>
<h4 id="イミュータビリティを保証する難しさ"><a class="anchor" href="#イミュータビリティを保証する難しさ">イミュータビリティを保証する難しさ</a></h4>
<p>Reactは、状態の変更を <strong>参照同一性（referential equality）</strong> に基づいて検出する。Classインスタンスのメソッドが<code>this.items.push(...)</code>のように内部を変更しても参照は同じままなので、Reactは再レンダリングをトリガーしない。そのため、結局は<code>addDeduction(item)</code>が<code>return new DeductionList([...this.items, item])</code>のように毎回新しいインスタンスを返すよう実装しなければならない。そうなると、Classの利点である「カプセル化された状態変更」の意味が薄れ、関数型の更新とさほど変わらないコードになる。</p>
<h3 id="関数型で凝集を確保する方法"><a class="anchor" href="#関数型で凝集を確保する方法">関数型で凝集を確保する方法</a></h3>
<p>では、関数型スタイルで<code>eat('jihoon', '감자탕')</code>のような、凝集の弱さに関する問題をどう改善できるだろうか？筆者が効果的だと感じた三つの方法を紹介する。</p>
<h4 id="モジュールの名前空間で凝集させる"><a class="anchor" href="#モジュールの名前空間で凝集させる">モジュールの名前空間で凝集させる</a></h4>
<p>最も直感的な方法である。ファイル（モジュール）自体をドメイン単位にし、import時に名前空間を利用する。先ほど定義した<code>domain/filing.ts</code>をそのまま使えばよい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> *</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FilingModel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/filing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span></code></pre></figure>
<p><code>FilingModel.canAmend(filing)</code>は<code>filing.canAmend()</code>ほどではないが、少なくともこの関数がFilingドメインに属することがコードからすぐに分かる。関数が複数のドメインにまたがって混在するリスクもなくなる。</p>
<h4 id="最初の引数はドメインの主体に"><a class="anchor" href="#最初の引数はドメインの主体に">最初の引数はドメインの主体に</a></h4>
<p>関数型で凝集を表す、もう一つの規約がある。<strong>最初の引数を常に「振る舞いの主体」にする。</strong> <code>canAmend(filing)</code>、<code>calculateTotalIncome(income)</code>のようにシグネチャを統一すると、<code>canAmend(filing)</code>は「filingについてcanAmendかどうかを問う」と読める。Unixのパイプラインという考え方（<code>data |> transform</code>）にも通じる。実際、Goのメソッドレシーバーはまさにこのパターンであり、Rustの<code>impl</code>ブロックで<code>self</code>を最初の引数として受け取るのも同じ発想である。</p>
<h4 id="factory-関数で振る舞いをまとめる"><a class="anchor" href="#factory-関数で振る舞いをまとめる">Factory 関数で振る舞いをまとめる</a></h4>
<p>Classの凝集性が欲しいときに使えるパターンである。ファクトリ関数がドメインオブジェクトとその振る舞いをまとめて返す。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">data,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(rawFiling);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>このパターンなら、Classの表現力（<code>filing.canAmend()</code>）と、オブジェクトリテラルで振る舞いを構成する関数型の実用性を同時に得られる。ただし、返されるオブジェクトは関数プロパティを持つため、それ自体はJSONシリアライズ可能なデータではない。毎回関数オブジェクトを新しく作るコストもあるが、フロントエンドで扱うデータ量なら、パフォーマンス上の問題になることはほとんどない。</p>
<h2 id="どこまで分離するべきか"><a class="anchor" href="#どこまで分離するべきか">どこまで分離するべきか？</a></h2>
<p>Clean Architectureを読むと、3〜4層に分けてPort/Adapterを定義する理想的な構造が示されている。しかし、実際にすべてのプロジェクトへこの構造を適用するのは、過剰設計（over-engineering）になり得る。</p>
<p>筆者が考える実用的な基準は、次のとおりだ。</p>
<ul>
<li><strong>ドメインの型をAPIレスポンスの型から分離</strong>する。<code>interface</code>でも<code>type</code>でも、フロントエンドで使用するドメインの概念を別ファイルに定義する。</li>
<li><strong>ビジネスルールを含むロジックは、コンポーネントの外へ出す。</strong> <code>domain/</code>フォルダでなくてもよい。重要なのは、Reactに依存しない純粋関数にすることである。</li>
<li><strong>APIレスポンス → ドメインモデルへの変換を一か所で行う。</strong> Mapper関数でもZodスキーマでも、その一か所だけを修正すれば変更が波及しない構造にする。</li>
</ul>
<p>プロジェクトが複雑になったら、次のような対応も検討できる。</p>
<ul>
<li><strong>Bounded Context単位でフォルダを分ける。</strong> <a href="https://frontend-fundamentals.com/" target="_blank" rel="noopener noreferrer">Tossフロントエンドチャプター</a>でも、「一緒に変更されるファイルを同じディレクトリに配置する」という原則を強調している。ドメイン単位でフォルダを分ければ、importパスがドメインの境界を自然に表す。</li>
<li><strong>Use Case層を導入する。</strong> ドメインロジックの組み合わせが複雑になったら、「所得情報の取得 → 必要経費率の適用 → 控除項目の計算 → 税額の算出 → 還付額の確定」というシナリオを一つの関数にまとめるApplication層が必要になる。</li>
</ul>
<pre><code>src/
├── domains/
│   ├── tax/
│   │   ├── tax.ts                  # 세액 계산 도메인 (세율, 공제, 계산 파이프라인)
│   │   ├── tax.viewModel.ts        # 세액 표현 (금액 포맷, 구간 라벨)
│   │   ├── tax.test.ts             # 세액 계산 테스트
│   │   └── incomeMapper.ts         # 홈택스 API ↔ 도메인 변환
│   ├── filing/
│   │   ├── filing.ts               # 신고 상태 도메인 (상태 전이, 권한)
│   │   ├── filing.viewModel.ts     # 신고 표현 (상태 배지, 라벨)
│   │   ├── filing.test.ts
│   │   └── filingMapper.ts
│   └── deduction/
│       ├── deduction.ts            # 공제 항목 도메인 (자격 조건, 한도)
│       └── deduction.viewModel.ts
├── hooks/                           # React 의존 로직
├── components/                      # UI 컴포넌트
└── api/                             # API 호출
</code></pre>
<p>「税金」という一つのドメインの中でも、<strong>税額計算（tax）</strong>、<strong>申告管理（filing）</strong>、<strong>控除項目（deduction）</strong> は、それぞれ独立したサブドメインに分けられる。税率が変わっても申告状態の遷移ロジックには影響せず、控除項目が追加されても申告書の提出フローはそのままである。これがBounded Contextの実践的な適用である。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、フロントエンドにもフロントエンドならではのドメインロジックがあり、そのルールはコンポーネントではなく型のそばの純粋関数に置く。APIレスポンスは1つのmapperでドメインの型に変換し、画面表現はViewModelに切り離し、一緒に変わるものは一緒に置く。</p>
<p>これをフロントエンドで実践するとは、単にフォルダを分けることではなく、<strong>何層もの境界を意識的に判断すること</strong>である。「これはビジネスルールか、プレゼンテーションロジックか？」「このデータはドメインの状態か、UIの状態か？」「この関数は十分に凝集しているか？」。こうした問いを習慣的に投げかけるだけでも、コードの構造は自然と改善される。</p>
<p>もちろん、すべてのプロジェクトでClean Architectureの層をすべてそろえる必要はない。単純なCRUDアプリを4層に分け、すべてのドメインにFactoryパターンを適用するのは、本末転倒である。Classの優れた凝集性と関数型の実用的な柔軟性のどちらを選ぶかは、プロジェクトの複雑さとチームのコンテキストによって決まる。</p>
<p>唯一の正解はない。しかし少なくとも、<strong>「ドメインが何かを知らずにコードを書くこと」</strong> と <strong>「ドメインを認識し、境界を判断し、意識的に分離すること」</strong> の間には明確な違いがある。この記事を読んだ方にも、自分のプロジェクトで「ここでのドメインは何だろう。そして、このコードはどこに置くべきだろう？」と一度問いかけてみてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design (Book)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html" target="_blank" rel="noopener noreferrer">Robert C. Martin, Clean Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://khalilstemmler.com/articles/typescript-domain-driven-design/ddd-frontend/" target="_blank" rel="noopener noreferrer">Khalil Stemmler, Does DDD Belong on the Frontend?</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://bespoyasov.me/blog/clean-architecture-on-frontend/" target="_blank" rel="noopener noreferrer">Alex Bespoyasov, Clean Architecture on Frontend</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://toss.tech/article/income-qa-e2e-automation" target="_blank" rel="noopener noreferrer">토스, E2E 자동화 여정</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>アーキテクチャ</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[ドメイン用語]]></title>
            <link>https://hooninedev.com/ja/260413</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260413</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、ドメイン、ドメインモデル、ドメインオブジェクト、ドメインオブジェクトモデルがそれぞれどう違うのかについて話してみたい。 DDDの記事を読みながら、これらの言葉が同じものを指しているのか迷ったことのあるフロントエンド開発者に向けた記事である。最後まで読めば、4つの用語を抽象から具体へと下る一本の線の上に並べられ、APIレスポンスの型がなぜドメインモデルではないのかを説明できるようにな...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>ドメイン、ドメインモデル、ドメインオブジェクト、ドメインオブジェクトモデルがそれぞれどう違うのか</strong>について話してみたい。</p>
<p>DDDの記事を読みながら、これらの言葉が同じものを指しているのか迷ったことのあるフロントエンド開発者に向けた記事である。最後まで読めば、4つの用語を抽象から具体へと下る一本の線の上に並べられ、APIレスポンスの型がなぜドメインモデルではないのかを説明できるようになる。</p>
<p>筆者も開発をしながらこれらの言葉にかなり頻繁に触れてきたが、いざ「ドメインとは正確に何なのか？」と聞かれると、明快に答えるのは簡単ではなかった。（正直に言うと、開発を始めたばかりの頃はドメインといえばwwwのことだと思っていた。）例はすべて総合所得税の計算で説明する。</p>
<h2 id="ドメインdomain"><a class="anchor" href="#ドメインdomain">ドメイン（Domain）</a></h2>
<p>最も基本的な問いから始めよう。<strong>ドメイン</strong>とは何だろうか？</p>
<p>Eric Evansは著書 <strong>Domain-Driven Design: Tackling Complexity in the Heart of Software（2003）</strong> で、ドメインを次のように定義している。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>知識、影響力、または活動の領域。</p></div><div class="quote-original" lang="en"><p>"A sphere of knowledge, influence, or activity."</p></div></blockquote>
<p>簡単に言えば、<strong>プログラミングによって解決しようとする問題領域</strong>そのものがドメインである。税務申告サービスを作るなら「税務申告」がドメインであり、保険請求プラットフォームを作るなら「保険請求」がドメインとなる。ドメインはコードではない。ソフトウェアより先に存在する、現実世界の問題領域である。</p>
<p>これはフロントエンド開発者にとって、どのような意味を持つのだろうか？私たちが作るUIは、最終的にこのドメインをユーザーへ見せ、操作できるようにする <strong>窓（window）</strong> である。税金を主なドメインとするToss Incomeや3o3のような税金還付サービスを開発するなら、所得区分、必要経費率、所得控除、税額控除、還付額といったドメインの概念をUIで表現することになる。そのため、フロントエンド開発者も自分が扱うドメインを深く理解しなければならない。UIコンポーネントをうまく描画することと同じくらい、<strong>「このサービスが解決する問題は何か」</strong> を知ることが重要だという意味である。</p>
<p>とはいえ、「税金」という一つのドメインだけを見ても、その内部には数多くのサブドメインが存在する。筆者が表面的に把握している総合所得税の計算パイプラインだけでも、次のとおりだ。</p>
<p><picture><source type="image/webp" srcset="/content/260413/1.720w.webp 720w, /content/260413/1.1090w.webp 1090w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260413/1.png" alt="総合所得税の計算パイプライン。総収入金額から納付・還付までの各段階が所得・控除・税額・結果の 4 つのサブドメインに色分けされている" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>このパイプラインの各段階は、それぞれ固有のルールとデータを持つサブドメインである。「税金」という一つの大きなドメインの中で、所得（Income）、控除（Deduction）、税額（Tax）、申告結果（Filing）という細かなドメインが絡み合っている。これらをコード上でどう分けるかが、まさにドメインモデリングの中心的な問いである。</p>
<h2 id="ドメインモデルdomain-model"><a class="anchor" href="#ドメインモデルdomain-model">ドメインモデル（Domain Model）</a></h2>
<p>では、ドメインモデルとは何だろうか？ドメインと「ドメインモデル」はどう違うのだろう？</p>
<p><a href="https://martinfowler.com/eaaCatalog/domainModel.html" target="_blank" rel="noopener noreferrer">Martin Fowler</a>は、ドメインモデルを振る舞いとデータの両方を含むドメインのオブジェクトモデルだと定義している。Eric Evansの定義はそこからもう一歩踏み込む。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>ドメインの選択された側面を記述する抽象化の体系であり、そのドメインに関する問題を解決するために使用できるもの。（Eric Evans）</p></div><div class="quote-original" lang="en"><p>A system of abstractions that describes selected aspects of a domain and can be used to solve problems related to that domain.</p></div></blockquote>
<p>重要なのは、<strong>「選択的な抽象化」</strong> だという点である。ドメインモデルは、現実世界のすべてを含むわけではない。映画監督が現実のすべての場面を収めず、物語に必要な場面だけを選ぶように、ドメインモデルも <strong>解決したい問題に必要な側面だけを選び、構造化したもの</strong> である。</p>
<p>ここで大切な点が一つある。ドメインモデルは、必ずしもコードである必要はない。ホワイトボードに描かれた図かもしれないし、チーム内で共有されているメンタルモデル（Mental Model）かもしれない。つまり、ドメインモデルという用語自体は、ソフトウェアとは独立した概念であり得る。</p>
<p>ここには、フロントエンド開発者が特に混同しやすい点がある。APIレスポンスの構造を見て、「これがドメインモデルだ」と考えることだ。しかし、それは <strong>データモデル（Data Model）</strong> であって、ドメインモデルではない。</p>
<p>データモデルとドメインモデルを区別すると、次のようになる。</p>
<table>
<thead>
<tr>
<th>区分</th>
<th>ドメインモデル</th>
<th>データモデル</th>
</tr>
</thead>
<tbody>
<tr>
<td>目的</td>
<td>ビジネス上の概念とルールを表現する</td>
<td>保存・転送の構造を定義する</td>
</tr>
<tr>
<td>言語</td>
<td>ビジネス用語（課税標準、税額控除、還付額）</td>
<td>技術用語（string、number、array）</td>
</tr>
<tr>
<td>構成要素</td>
<td>データ + 振る舞い（ルール）</td>
<td>データ構造のみ</td>
</tr>
<tr>
<td>例</td>
<td>「課税標準1,400万ウォン以下の区分は税率6%」</td>
<td><code>{ taxableBase: number, taxRate: number }</code></td>
</tr>
</tbody>
</table>
<p>データモデルは「どのような形でデータがやり取りされるか」を定義し、<strong>ドメインモデルは「このデータがビジネス上何を意味し、どのようなルールに従うか」を定義する。</strong> この二つを区別できなければ、コンポーネントがAPIレスポンスの構造へ直接依存し、バックエンドのスキーマが変わるたびにフロントエンド全体が揺さぶられることになる。</p>
<h2 id="ドメインオブジェクトdomain-object"><a class="anchor" href="#ドメインオブジェクトdomain-object">ドメインオブジェクト（Domain Object）</a></h2>
<p>ドメインモデルが概念の体系であるなら、<strong>ドメインオブジェクト</strong>は、その概念をコードとして実装した実体である。</p>
<p>Code with Jasonを運営する<a href="https://www.codewithjason.com/difference-domains-domain-models-object-models-domain-objects/" target="_blank" rel="noopener noreferrer">Jason Swettの記事</a>では、ドメインオブジェクトを次のように定義している。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>私のオブジェクトモデルにあるオブジェクトのうち、ドメインモデルにも一つの概念として存在するものを、ドメインオブジェクトと呼ぶ。</p></div><div class="quote-original" lang="en"><p>Any object in my object model that also exist as a concept in my domain model I would call a domain object.</p></div></blockquote>
<p>つまり、ドメインモデルに「総合所得」という概念があり、コード上に<code>Income</code>という型があるなら、この<code>Income</code>がドメインオブジェクトである。ただし、コード上のすべてのオブジェクトがドメインオブジェクトなのではない。<code>HttpClient</code>、<code>LocalStorageAdapter</code>、<code>useDebounce</code>などは技術的な道具であり、ドメインの概念ではない。</p>
<h3 id="entityとvalue-object"><a class="anchor" href="#entityとvalue-object">EntityとValue Object</a></h3>
<p>Evansは、ドメインオブジェクトを <strong>Entity</strong>、<strong>Value Object</strong>、<strong>Service</strong> の三つに分類している。（Martin Fowlerはこの分類を「Evans Classification」と呼ぶ。）Serviceは、「特定のオブジェクトへ自然に帰属しないドメインの操作」を表す別の概念だが、この節の中心はデータをどのように識別するかという問題なので、EntityとValue Objectの二つに焦点を当てる。</p>
<p><strong>Entity（エンティティ）</strong> は、時間やさまざまな表現を超えて維持される、一意のアイデンティティを持つオブジェクトである。税務申告書（TaxFiling）、納税者（Taxpayer）、所得記録（IncomeRecord）のように一意なIDで識別され、属性が変わっても同じIDなら同じEntityである。申告書の控除項目が修正されても、申告書IDが変わらない限り、それは同じ申告書であり続ける。</p>
<p><strong>Value Object（値オブジェクト）</strong> は、属性の組み合わせだけで意味を持つオブジェクトであり、すべての属性値が同じなら同一とみなす。金額（Money）、税率（TaxRate）、税率区分（TaxBracket）のように、値そのものに意味があるオブジェクトだ。「税率6%」は、どこで使われても「税率6%」でしかない。</p>
<p>この区別は、フロントエンドでなぜ重要なのだろうか？次のコード例で見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxpayerName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameFiling</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.id;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  currency</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "KRW"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "USD"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameMoney</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  a.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.currency </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.currency;</span></span></code></pre></figure>
<p>TaxFilingはidをアイデンティティの基準とするため、Entityである。（idフィールドを持つこと自体がEntityの定義なのではなく、「そのidによって同一かどうかを判断する」点が重要である。）Moneyはidを持たず、amountとcurrencyの組み合わせだけで識別され、すべての属性が同じなら同じ値とみなされる。</p>
<p>EntityはIDによる比較、Value Objectは属性による比較。この区別が明確であれば、状態管理で「このデータが同じものか別のものか」を判断するロジックが自然に整理される。リスト内の項目を更新するとき、EntityならIDで探して置き換え、Value Objectならイミュータブルな置き換え（immutable replace）を行う、といった具合だ。</p>
<h2 id="ドメインオブジェクトモデルdomain-object-model"><a class="anchor" href="#ドメインオブジェクトモデルdomain-object-model">ドメインオブジェクトモデル（Domain Object Model）</a></h2>
<p>「ドメインモデル」と「ドメインオブジェクト」は分かった。では、<strong>ドメインオブジェクトモデル</strong>とは何だろうか？</p>
<p>調べてみると、意外にも合意された定義はない。多くの文献では、「ドメインモデル」「ドメインオブジェクトモデル」「概念モデル（conceptual model）」「分析オブジェクトモデル（analysis object model）」を <strong>実質的に同義語</strong> とみなしている。オブジェクト指向分析の段階で描かれる概念モデルを指す、複数の呼び方だという立場である。</p>
<p>一方で、もう少し分離された層として捉える見方もある。<strong>ドメインモデルが実際のコードへ変換される地点がオブジェクトモデルである</strong>、という説明が代表的だ。</p>
<p>この二つ目の観点では、<strong>オブジェクトモデル</strong>とは、システム内の <strong>すべてのコードオブジェクトの構造</strong> である。そこには<code>HttpClient</code>や<code>useDebounce</code>のような技術的な道具も含まれる。その中で、<strong>ドメインの概念を表すオブジェクトの部分集合と、それらの関係</strong>が <strong>ドメインオブジェクトモデル</strong> である。「Object Model」をシステムの静的構造（クラス、属性、操作、関係）と定義してきた、オブジェクト指向モデリングの伝統にもつながっている。</p>
<p>筆者は、こちらの観点のほうがフロントエンド開発者にとって実用的だと考えている。実際に書くコードには、ドメインオブジェクトと技術的なオブジェクトが常に混在しているからだ。</p>
<p>結局、<strong>ドメイン → ドメインモデル → ドメインオブジェクトモデル → ドメインオブジェクト</strong> は、抽象から具体へと下りていく階層である。ドメインが最も広く、ドメインオブジェクトが最も具体的だ。したがって、フロントエンドのコードを書くときに実質的に考えるべき領域は、<strong>ドメインオブジェクトモデル（ドメインの概念を表す型と、その間の関係）をどのように構造化するか</strong> ということになる。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、<strong>ドメイン</strong>は私たちが解決しようとする問題領域であり、<strong>ドメインモデル</strong>はその問題を選択的に抽象化した概念体系、<strong>ドメインオブジェクトモデル</strong>は筆者が採った観点ではその概念体系をコードとして実装したもの、そして <strong>ドメインオブジェクト</strong>はその実装内の個々のオブジェクトである。</p>
<p>この区別がコードでどんな違いを生むのか、つまり税金計算のようなドメインロジックをコンポーネントの外のどこに置き、どこまで分けるのかは、<a href="/260418" target="_blank" rel="noopener noreferrer">ドメインモデル</a>で続けて扱う。</p>
<p>この記事を読んだ皆さんも、次にAPIレスポンスの型を見たときには「これはデータモデルなのか、ドメインモデルなのか」と一度問いかけてみてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design (Book)</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>アーキテクチャ</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Toss Frontend Fundamentals 模擬試験 第2回のリファクタリングを終えて]]></title>
            <link>https://hooninedev.com/ja/260328</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260328</guid>
            <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、Toss Frontend Fundamentals 模擬試験の第2回に参加し、取り組んだリファクタリングについて振り返ってみたい。 以前からコードレビューやリファクタリングに関心があった筆者は、Tossが公開した「Frontend Fundamentals 模擬試験」という興味深い形式の課題に取り組むことになった。課題は、与えられた会議室予約アプリをリファクタリングするというもの...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、Toss Frontend Fundamentals 模擬試験の第2回に参加し、取り組んだリファクタリングについて振り返ってみたい。</p>
<p>以前からコードレビューやリファクタリングに関心があった筆者は、Tossが公開した「Frontend Fundamentals 模擬試験」という興味深い形式の課題に取り組むことになった。課題は、与えられた会議室予約アプリをリファクタリングするというものだった。テストコードも用意されており、リファクタリングの過程で機能が壊れていないかを検証できるセーフティネットが整っていた。</p>
<p>最終的に2日間かけてリファクタリングを行った。ここでは、その過程で感じたことをまとめてみようと思う。</p>
<h2 id="初めてコードに向き合ったとき"><a class="anchor" href="#初めてコードに向き合ったとき">初めてコードに向き合ったとき</a></h2>
<p>コードを初めて開いたとき、筆者が真っ先にしたのは<strong>テスト仕様を読むこと</strong>だった。テストコードは、このアプリケーションが何をすべきかを最も正直に教えてくれるドキュメントだからだ。<code>App.easy.spec.tsx</code>と<code>App.hard.spec.tsx</code>に目を通し、アプリケーション全体の要件を把握した。</p>
<p>次に実際のコードを確認すると、目に入ったのは2つのモノリシックなコンポーネントだった。</p>
<ul>
<li><code>ReservationStatusPage</code> は385行のコンポーネントで、日付選択、タイムラインの可視化、予約詳細のツールチップ、自分の予約一覧、キャンセル機能がすべて1つのファイルに収められていた。</li>
<li><code>RoomBookingPage</code> は300行あまりのコンポーネントで、フィルター、部屋一覧、予約作成ロジック、URLパラメーターの同期がひとつに絡み合っていた。</li>
</ul>
<p>筆者はコードを読みながら、「改善が必要だ」と判断するより先に、まず<strong>コードの性質を分類すること</strong>に集中した。どのコードがドメイン情報を持ち、どのコードがユーティリティの役割を担い、どのコードが純粋なUIレイヤーなのかを見分けていった。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 정보: 장비 라벨, 타임 슬롯 등 비즈니스 상수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 유틸리티: 날짜 포맷, 시간 변환</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> timeToMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">time</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 서버 상태: 인라인 useQuery, useMutation 호출</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [] } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], getRooms);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [] } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, date], () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// UI + 비즈니스 로직 혼재: 필터링, 정렬, 충돌 감지가 JSX 사이에 산재</span></span></code></pre></figure>
<p>このように性質ごとに分類しておくと、どこから手を付けるべきかが自然と見え始めた。それぞれのコード領域に簡単なコメントを付け、改善の方向性をメモしていった。（今の会社に入社した当時、jQueryベースのプロジェクトを移行していたときと似た気分だった）</p>
<p>では、どこから手を付けるべきだろうか。</p>
<h2 id="リファクタリング戦略を立てる"><a class="anchor" href="#リファクタリング戦略を立てる">リファクタリング戦略を立てる</a></h2>
<p>筆者は、次の順序でリファクタリングを進めることにした。</p>
<ol>
<li><strong>サーバーコードの整理</strong>：queryとmutationの分離</li>
<li><strong>ドメインロジックの分離</strong>：Equipment、Room、Reservationモデル</li>
<li><strong>型定義</strong>：ドメインモデルに基づく型体系の整理</li>
<li><strong>ユーティリティ関数の分離</strong>：日付フォーマット、タイムライン計算など</li>
<li><strong>UIレイヤーの分離</strong>：コンポーネントを関心事ごとに適切な粒度で分ける</li>
<li><strong>抽象化と関心の分離</strong>：エラー／ローディング処理、クエリキー管理</li>
</ol>
<p>この順序を選んだのは、<strong>依存関係の外側から内側へ</strong>進めるためだ。インフラ（サーバーコード、ユーティリティ）から整理し、ドメインモデルを確立したあと、最後にUIを整える。もしUIコンポーネントを先に分割すると、まだ整理されていないドメインロジックやクエリコードを複数のコンポーネント間で何度も移動させることになりかねない。</p>
<p>戦略が決まったところで、一つずつ実行に移そう。</p>
<h2 id="サーバーコードとユーティリティから整理する"><a class="anchor" href="#サーバーコードとユーティリティから整理する">サーバーコードとユーティリティから整理する</a></h2>
<h3 id="日付表示ユーティリティの分離"><a class="anchor" href="#日付表示ユーティリティの分離">日付表示ユーティリティの分離</a></h3>
<p>最初に手を付けたのは<code>formatDate</code>関数だった。2つのページで同じ関数がそれぞれインライン定義されていたためだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/formatYYYYMMDD.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> year</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getFullYear</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> month</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMonth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">year</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">month</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">date</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>小さな変更ではあるが、リファクタリングの最初のコミットとして重要な意味があった。最も独立していて副作用の少ない部分から触り、テストが引き続き通るかを確かめる<strong>ウォームアップ</strong>のようなものだ。</p>
<h3 id="react-queryフックの分離"><a class="anchor" href="#react-queryフックの分離">React Queryフックの分離</a></h3>
<p>次に、コンポーネント内へ直接記述されていた<code>useQuery</code>、<code>useMutation</code>の呼び出しを別ファイルに分離した。<code>queryOptions</code>パターンを活用し、クエリ設定を再利用可能な単位にまとめた。</p>
<p>この過程で、<code>remotes.ts</code>にあったAPIレスポンスの型も明示的に定義した。従来は<code>any</code>のまま流れていた型が、<code>GetRoomsResponse</code>、<code>GetReservationsResponse</code>などとして明確になった。</p>
<p>インフラレイヤーを整理できたので、次はドメインモデルに目を向けよう。</p>
<h2 id="ドメインモデルの分離"><a class="anchor" href="#ドメインモデルの分離">ドメインモデルの分離</a></h2>
<p>リファクタリングにおける最大の転換点は、<strong>ドメインモデルを独立した<code>models/</code>ディレクトリへ分離</strong>したことだった。</p>
<p>従来のコードでは、<code>EQUIPMENT_LABELS</code>や<code>TIME_SLOTS</code>のようなビジネス上の定数がコンポーネントファイルの先頭に宣言されていた。<code>Room</code>や<code>Reservation</code>の型もサーバーハンドラー（<code>_tosslib/server/types.ts</code>）にしか存在せず、クライアントコードでは<code>any</code>に近い状態で使われていた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/equipment.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> typeof</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ALL_EQUIPMENT</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/reservation.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  capacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  roomId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>なぜドメインモデルの分離が重要なのだろうか。ビジネスロジックがUIコンポーネントに依存していると、そのロジックを変更する際にコンポーネントのレンダリングロジックまで確認しなければならない。一方、<code>models/</code>ディレクトリに独立して存在していれば、ビジネスルールの変更をUIから切り離して行える。もちろん現実的に完全な分離は難しい。それでも少なくとも、<strong>「このロジックならここにあるはずだ」と予測できる構造</strong>を作ることが重要だ。</p>
<p>ドメインモデルを分離したことで、UIはどこまで軽くできるだろうか。</p>
<h2 id="コンポーネントの解体"><a class="anchor" href="#コンポーネントの解体">コンポーネントの解体</a></h2>
<h3 id="reservationstatuspage"><a class="anchor" href="#reservationstatuspage">ReservationStatusPage</a></h3>
<p>この作業は、最も劇的な変化をもたらしたコミットであり、最も時間がかかった。385行のモノリシックなコンポーネントを次のように分割した。</p>
<pre><code>ReservationStatusPage/
├── index.tsx                    # 페이지 레벨
└── components/
    ├── DateSelector.tsx         # 날짜 선택 UI
    ├── ReservationTimeline.tsx  # 타임라인
    └── MyReservation.tsx        # 내 예약 목록 + 취소
</code></pre>
<p>分割の基準は、「<strong>このコードが独立して意味を持つか</strong>」だった。タイムラインの可視化は、日付に応じた予約データを受け取ってグリッドを描画する独立した関心事だ。自分の予約一覧は、ユーザーの予約データを取得してキャンセルする独立した関心事だ。それらが同じファイルにある理由はなかった。</p>
<p>分割後の<code>index.tsx</code>は、<strong>オーケストレーター（orchestrator</strong>）としての役割だけを担うようになった。状態管理、メッセージの表示、子コンポーネントの組み合わせを担当するだけで、実際のデータ取得やレンダリングの詳細は子コンポーネントへ委ねた。</p>
<h3 id="roombookingpage"><a class="anchor" href="#roombookingpage">RoomBookingPage</a></h3>
<p>予約ページも同じ原則で分割した。</p>
<pre><code>RoomBookingPage/
├── index.tsx                    # 페이지 레벨
├── components/
│   ├── BookingFilter.tsx        # 날짜, 시간, 인원, 장비, 층 UI
│   └── AvailableRoomList.tsx    # 예약 가능 방 목록
└── hooks/
    └── useBookingParams.ts      # URL searchParams 기반 상태 관리
</code></pre>
<p>この過程では、ひとつ興味深い選択があった。当初は<code>react-hook-form</code> + <code>zod</code>を導入してフォームのバリデーションを試みた。しかし最終的にはそれを取り除き、カスタムフック<code>useBookingParams</code>へ置き換えた。この決定については、後ほど詳しく説明する。</p>
<p>ここまで読めば、自然とひとつの疑問が浮かぶ。いったいどこまで抽象化すべきなのだろうか。</p>
<h2 id="抽象化の適切なライン"><a class="anchor" href="#抽象化の適切なライン">抽象化の適切なライン</a></h2>
<p>このセクションは、筆者が今回の模擬試験で最も悩んだ部分だ。</p>
<h3 id="ネストした条件文はどこまで分解すべきか"><a class="anchor" href="#ネストした条件文はどこまで分解すべきか">ネストした条件文は、どこまで分解すべきか</a></h3>
<p>会議室が予約可能かを判定するロジックには、複数の条件が組み合わされている。収容人数が十分か、必要な設備があるか、希望する階と一致するか、時間が重なっていないか。元のコードでは、これらすべての条件がひとつの<code>filter</code>コールバック内にインラインで記述されていた。</p>
<p>筆者はこれを<code>models/roomFilter.ts</code>へ抽出し、各条件を<strong>名前の付いた関数</strong>に分けた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isEnoughCapacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.capacity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> attendees;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasRequiredEquipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">every</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">eq</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">includes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(eq));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isOnPreferredFloor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasNoTimeConflict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">reservations.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">some</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.roomId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.start </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> filterAvailableRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> rooms</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isEnoughCapacity</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.attendees) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasRequiredEquipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.equipment) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isOnPreferredFloor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasNoTimeConflict</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, reservations, params.date, params.startTime, params.endTime)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.name.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">localeCompare</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.name);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ここで重要なのは、<strong>抽象化できる明確な名前がある場合に限って関数へ分割した</strong>ことだ。<code>isEnoughCapacity</code>や<code>hasRequiredEquipment</code>という名前なら、実装を見なくても何をするのか予測できる。もし<code>processRoomConditions</code>のような曖昧な名前にせざるを得ないなら、その抽象化はかえって読み手の認知負荷を高める可能性がある。</p>
<p>もちろん、これが唯一の正解というわけではない。ただ、筆者の判断基準は「<strong>この関数名だけを見て動作を予測できるか</strong>」だった。予測できるなら抽象化し、できないならインラインのままにするほうが、かえって可読性を高められると考えた。</p>
<h3 id="searchparamsとフォームの状態"><a class="anchor" href="#searchparamsとフォームの状態">searchParamsとフォームの状態</a></h3>
<p>予約フィルターの状態をどこに置くかも、かなり悩んだ部分だった。元のコードでは<code>useState</code>で各フィルター値を管理しつつ、<code>useEffect</code>でURLのsearchParamsと同期していた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 원본: useState + useEffect 동기화 방식</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">startTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setStartTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ... 6개의 개별 상태</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {};</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (date) params.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 모든 상태를 searchParams에 동기화</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [date, startTime, endTime, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>筆者は最初に<code>react-hook-form</code> + <code>zod</code>を導入し、フォームとして管理する方法を試した。しかし最終的にはこれを取り除き、<strong>searchParamsを信頼できる唯一の情報源（Single Source of Truth</strong>）として使う<code>useBookingParams</code>フックに置き換えた。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useBookingParams: searchParams가 곧 상태</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">searchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    date: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    startTime: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [searchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> updateParam</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> extends</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prev</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 기존 파라미터 병합 후 업데이트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [setSearchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { params, updateParam };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>この決定の核心にあったのは、「<strong>状態が別々に動くのは適切ではない</strong>」という判断だ。<code>useState</code>と<code>searchParams</code>がそれぞれ状態を持つと、同期するタイミングによって不整合が起きる可能性がある。一方、searchParamsだけを状態として使えば、URLそのものがアプリケーションの状態となり、同期の問題自体がなくなる。ユーザーがURLを共有すれば同じフィルター状態を再現できるのは、おまけの利点だ。</p>
<p>ほかの参加者の振り返りにも、似た悩みが見られた。「<strong>URLのsearchParamsを信頼できる唯一の情報源として統一した」「個別のフィルターpropsをひとつの<code>filter</code>オブジェクトへまとめる方法を選んだ</strong>」。表現は異なっていても、「<strong>分散した状態をひとつの概念にまとめる必要がある</strong>」という問題意識は同じだった。</p>
<h2 id="安定性"><a class="anchor" href="#安定性">安定性</a></h2>
<h3 id="suspenseとerrorboundary"><a class="anchor" href="#suspenseとerrorboundary">SuspenseとErrorBoundary</a></h3>
<p>コンポーネント構造が固まったあと、エラー処理とローディング処理を追加した。順序が重要なのは、境界（Boundary）をどこに設けるかは、コンポーネントツリーが決まって初めて判断できるからだ。</p>
<p><code>react-error-boundary</code>ライブラリを使い、独立したデータ取得単位ごとに<code>ErrorBoundary</code>と<code>Suspense</code>で囲んだ。タイムラインの取得に失敗しても自分の予約一覧は正常に表示されるべきであり、その逆も同じだからだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 각 영역이 독립적으로 에러/로딩을 처리 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[date]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"예약 현황을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ReservationTimeline</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{date} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"내 예약을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MyReservation</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleCancel} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="query-keyの一元管理"><a class="anchor" href="#query-keyの一元管理">Query Keyの一元管理</a></h3>
<p>リファクタリングの過程でクエリフックを分離すると、query keyが複数のファイルへ分散する問題が生じた。mutationの<code>onSuccess</code>でinvalidationを行う際、どのkeyを使うべきか追いにくくなったのだ。</p>
<p><code>@lukemorales/query-key-factory</code>を導入し、クエリキーを一元管理するよう変更した。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queries/queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> roomKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  list: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> reservationKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ queryKey: [date], </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date) }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  my: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMyReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>これにより、<code>useSuspenseQueries({ queries: [roomKeys.list, reservationKeys.list(date)] })</code>という形で利用でき、クエリキーと取得関数が常に一緒に扱われるようになる。また、routeのパスも<code>PATHS</code>定数へ抽出し、文字列のハードコーディングを取り除いた。</p>
<h2 id="出題者の意図は何だったのか"><a class="anchor" href="#出題者の意図は何だったのか">出題者の意図は何だったのか</a></h2>
<p>リファクタリングを終えたあと、筆者は一歩引いて考えてみた。この模擬試験が評価しようとしていたものは何だったのだろうか。</p>
<p>ほかの参加者の振り返りを読み、興味深い共通点に気づいた。ほぼすべての記事に、「<strong>コードは読むものではなく、予測するものだ</strong>」という一文が登場していた。私たちの脳はコードを一行ずつ解釈するのではなく、経験から蓄積したパターンをもとに予測しながら読む。そして、その予測が外れたときに認知負荷が急激に高まる。</p>
<p>この観点から見れば、模擬試験が評価するのは単なるコーディング能力ではなく、「<strong>同僚が読むコードを、どれだけ予測可能なものにできるか</strong>」という協働の力なのだ。（出題者や同僚の心を読むことこそ、真のソフトウェアエンジニアの能力なのかもしれない）</p>
<p>ほかの参加者の振り返りを見ると、「<strong>他人が書いたコードを理解するのは簡単ではない」「インターフェースを先に設計することは重要だが、膨大な既存コードを前にすると、そのアプローチが揺らぐこともある</strong>」という内容に共感した。筆者も同じような経験をした。既存のコードがすでに動いていると、その構造を合理化したくなる。「もう動いているコードなのに、わざわざ？」という考えだ。しかし模擬試験の核心は、その誘惑を乗り越え、「<strong>自分ではない誰かがこのコードを読んだとき、どれだけ早く把握できるか、自分の認識に基づいて問題を判断し解決していけるか</strong>」を基準に判断することにあった。</p>
<h2 id="リファクタリングから学んだこと"><a class="anchor" href="#リファクタリングから学んだこと">リファクタリングから学んだこと</a></h2>
<p><strong>リファクタリングの順序が結果を左右する。</strong> 外側（インフラ）から内側（UI）へ進めるのが、途中で絡まない安全な道筋だった。ユーティリティとドメインモデルを整理してからコンポーネントを分割すると、各コンポーネントが何に依存しているのかが明確になった。</p>
<p><strong>抽象化の判断基準は「名前」だ。</strong> 関数や変数として抽出したとき、その名前で動作を説明できるなら抽象化する価値がある。どうしても名前が曖昧になるなら、インラインのほうが適切な選択かもしれない。</p>
<p><strong>状態を置く場所が、そのままアーキテクチャになる。</strong> 一緒に動くべき状態は同じ場所に置く必要がある。<code>useState</code>と<code>searchParams</code>を同期させるよりも、searchParamsだけを信頼できる唯一の情報源として使うほうが、構造として健全だ。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>課題を終えたあと、2人の同僚と話をした。一人でコードを眺めていたときには見えなかったものが、対話を通じて考えを解きほぐす過程で姿を現し始めた。筆者が当然のこととして見過ごしていた構造上の選択に「なぜそうしたの？」と問われた瞬間、それまで意識していなかった判断の隙が見えてくる。</p>
<p>AIがコードの作成やレビューにかかる時間を劇的に減らしているのは事実だ。それでもコードレビューやデイリーミーティングが今なお重要だと思う理由は、まさにこうした経験にある。AIはコードの整合性を検証できるが、「<strong>あなたが見落としている観点はこれだ</strong>」と指摘するのは、結局のところ同じ文脈を共有する同僚の役割だ。自分には見えなかった部分の発見、そしてその発見を通じたプロダクトの安定化。これこそが協働の本質ではないだろうか。</p>
<p>問題を解いていく過程で書くコードに、唯一の正解はない。同じ模擬試験に取り組んだほかの参加者も、それぞれ異なる道筋を選び、それぞれに根拠があった。大切なのは、<strong>「なぜこのように書いたのか」を説明できること</strong>だ。この記事を読む方にも、一度は自分のコードを初めて見る人の視点で眺めてみることを勧めたい。その視点こそが、コードの品質を決める最も強力な基準になり得るだろう。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>リファクタリング</category>
        </item>
        <item>
            <title><![CDATA[AIフロントエンドエンジニア]]></title>
            <link>https://hooninedev.com/ja/260302</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260302</guid>
            <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、AI時代にエンジニアがどのように成長し、生き残れるのかについて、個人的な視点から考えてみたい。 筆者がジュニアだった頃、特に印象深く読んだ記事の一つに、ペ・フィドンさんの「フロントエンドエンジニアのキャリアロードマップ、ジュニアのための3つの専門性トラック」がある。フロントエンドエンジニアのキャリアを、Web特化（Software Engineer）／プロダクト特化（Product...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>AI時代にエンジニアがどのように成長し、生き残れるのか</strong>について、個人的な視点から考えてみたい。</p>
<p>筆者がジュニアだった頃、特に印象深く読んだ記事の一つに、<a href="https://kr.linkedin.com/posts/hwidongbae_%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%BB%A4%EB%A6%AC%EC%96%B4-%EB%A1%9C%EB%93%9C%EB%A7%B5-%EC%A3%BC%EB%8B%88%EC%96%B4%EB%A5%BC-%EC%9C%84%ED%95%9C-3%EA%B0%80%EC%A7%80-%EC%A0%84%EB%AC%B8%EC%84%B1-%ED%8A%B8%EB%9E%99-activity-7013888624140189696-XiIz" target="_blank" rel="noopener noreferrer">ペ・フィドンさんの「フロントエンドエンジニアのキャリアロードマップ、ジュニアのための3つの専門性トラック」</a>がある。フロントエンドエンジニアのキャリアを、<strong>Web特化（Software Engineer）／プロダクト特化（Product Engineer）／運用特化（Full-Stack Engineer</strong>）という3つのトラックに分けて整理し、「優れたエンジニアに必要な5つの基礎能力」と「シニアになるための3つのポイント」まで押さえた記事だ。当時は、トラックごとにどんな力を伸ばすべきかを考えることが最大のテーマだった。ところが、この記事を読んでから2年も経たないうちに、テーマそのものがすっかり変わってしまった。</p>
<p>最近、同僚のエンジニアたちと話していると、ここ数年耳にしてきた悩みとは少し性質が違うと感じる。</p>
<ul>
<li>「会社でAIを導入したんですが、デザイン案を渡せばほとんど全部作ってくれます。便利ではあるんですが……」</li>
<li>「採用市場がものすごく冷え込んでいますね」</li>
<li>「AIが書いたコードをそのままマージするのは怖いし、かといって一つひとつレビューすると効率が落ちるので悩んでいます」</li>
</ul>
<p>筆者も同じような時期を経験したし、今も経験している。1〜2年前までは「AIは便利な補助ツール」くらいに考えていたが、今やAIなしで開発する姿そのものが想像できない環境になった（筆者もこの記事を書きながら、Claudeにリサーチを頼んでいる）。今回はペ・フィドンさんの記事の続編という位置づけで、この間に景色がどう変わったのか、そして変化した景色の中で、フロントエンドエンジニアとしてどんな力をさらに伸ばすべきなのかを、筆者なりの視点で整理してみたい。</p>
<p>今回もできる限り多くの資料を探して検証するよう努めたが、非常に変化の速い分野だけに、この記事が公開された時点ですでに一部の内容が古くなっている可能性があることを、あらかじめご了承いただきたい。反論や議論したい点があれば、いつでもコメントで知らせてほしい。</p>
<h2 id="もうaiが全部やってくれるんじゃないですか"><a class="anchor" href="#もうaiが全部やってくれるんじゃないですか">「もうAIが全部やってくれるんじゃないですか？」</a></h2>
<p>まず確認しておかなければならないことがある。「AIが全部やってくれる」という文は事実なのか。どこまでが事実で、どこからが幻想なのか。</p>
<p>2025年2月、OpenAIの共同創業者で、テスラのAIディレクターも務めた<a href="https://x.com/karpathy/status/1886192184808149383" target="_blank" rel="noopener noreferrer">Andrej Karpathy</a>は、自身のTwitterに次のように投稿した。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>私が「vibe coding」と呼ぶ新しい種類のコーディングがある。雰囲気に完全に身を委ね、指数関数的な成長を受け入れ、コードそのものが存在することさえ忘れるやり方だ。</p></div><div class="quote-original" lang="en"><p>There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists.</p></div></blockquote>
<p><strong>バイブコーディング（Vibe Coding</strong>）とは、一言でいえば「AIにキーボードを渡し、欲しいものを自然言語で説明するだけのコーディング手法」だ。アーキテクチャ文書も、ボイラープレートも、セミコロン探しもない。ただバイブだけでコードが動いていく。この言葉は1年も経たないうちに、英語圏の開発者コミュニティで標準的な語彙として定着した。</p>
<p>ところが、ちょうど1年後の2026年2月、同じKarpathyが<a href="https://thenewstack.io/vibe-coding-is-passe/" target="_blank" rel="noopener noreferrer">一歩後退した</a>。彼はvibe codingという言葉を「<strong>agentic engineering</strong>」に置き換えようと提案する。両者の違いは明確だ。</p>
<ul>
<li><strong>Vibe coding</strong>：欲しいものを説明し、出来上がったものを受け入れること</li>
<li><strong>Agentic engineering</strong>：システムを設計し、制約を仕様として定め、頭の中ですでに推論を終えた実装をAIで加速すること</li>
</ul>
<p>1年前は「頼めば全部作ってくれます」が基本的な前提だったとすれば、今では「AIに何をどう指示するかを設計する力」そのものがエンジニアリング能力として定着している。この流れは、一人のツイートだけにとどまるものではない。同じ頃、Googleのエンジニアである<a href="https://addyosmani.com/" target="_blank" rel="noopener noreferrer">Addy Osmani</a>は、<a href="https://www.amazon.com/Beyond-Vibe-Coding-AI-Era-Developer/dp/B0F6S5425Y" target="_blank" rel="noopener noreferrer">Beyond Vibe Coding: From Coder to AI-Era Developer</a>という本を出版し、「AIはアシスタントにすぎず、自律的に信頼できるコーダーではない。シニア開発者はあなたであり、LLMはあなたの判断を加速するために存在する」と明言した。</p>
<h3 id="ツールは暴走する勢いで進化している"><a class="anchor" href="#ツールは暴走する勢いで進化している">ツールは暴走する勢いで進化している</a></h3>
<p>ツール側も、この流れに合わせて急速に進化している。2026年3月時点で最もよく言及されるコーディングツールは、Cursor、Claude Code、GitHub Copilot、Windsurf、v0 by Vercel、Bolt.new、Devinあたりだ。</p>
<p>とりわけ、v0の変化は象徴的だ。Vercelは<a href="https://venturebeat.com/infrastructure/vercel-rebuilt-v0-to-tackle-the-90-problem-connecting-ai-generated-code-to" target="_blank" rel="noopener noreferrer">「90% problem」</a>という表現を使っている。現実の開発の90%は、既存のコードベースと既存のインフラの中で行われる、という意味だ。当初はグリーンフィールドのプロトタイプさえうまく作れればよかったv0が、今ではGitHubリポジトリを直接取り込んで作業し、デザインシステムを適用し、デプロイ環境変数を自動で取得して使う。「AIはおもちゃのようなデモしか上手に作れないのでは」というシニアたちの反論に、ツール側が直接答えているわけだ。</p>
<p>ビッグテックのコードベースが、この変化を最もよく表している。</p>
<p>GoogleのSundar Pichaiは、<a href="https://fortune.com/2024/10/30/googles-code-ai-sundar-pichai/" target="_blank" rel="noopener noreferrer">2024年10月の第3四半期決算説明会で「新しいコードの25%以上がAIで生成され、その後エンジニアがレビュー・承認したもの」だと発表</a>し、2025年4月には30%以上に増えたと述べた。MicrosoftのSatya Nadellaは、<a href="https://www.cnbc.com/2025/04/29/satya-nadella-says-as-much-as-30percent-of-microsoft-code-is-written-by-ai.html" target="_blank" rel="noopener noreferrer">2025年4月のLlamaConで「私たちのコードの最大30%はAIが書いたコード」だと明かした</a>。Metaの社内目標は、「2026年前半までに、エンジニアの65%がコミットの75%以上をAIで生成する」という水準にまで引き上げられた。</p>
<p>韓国でも流れは変わらない。<a href="https://toss.tech/article/toss-frontend-ai-docs" target="_blank" rel="noopener noreferrer">Toss</a>は、開発者がもはや文書を探さずに済み、DXを高められるようAIベースのドキュメントシステムを構築し、さらに一歩踏み込んで<a href="https://toss.tech/article/removing_designers_in_ai_era" target="_blank" rel="noopener noreferrer">「AI時代、デザイナーをなくしたら起きたこと」</a>といったテーマも扱った。Daangnは毎週火曜日の<a href="https://medium.com/daangn" target="_blank" rel="noopener noreferrer">AI Show &#x26; Tell</a>でチームごとの実験を共有し、「エンジニアを超えてビルダーへ」という<a href="https://about.daangn.com/blog/archive/%EB%8B%B9%EA%B7%BC-%ED%95%B4%EC%BB%A4%ED%86%A4-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%B1%84%EC%9A%A9/" target="_blank" rel="noopener noreferrer">採用スローガン</a>を掲げ始めた。Woowa Brothersは<a href="https://techblog.woowahan.com/22828/" target="_blank" rel="noopener noreferrer">「AIがコードを書く時代、それでも開発者になりますか？」</a>といった記事を通じて、「開発者の本質はコードではなく、問題を定義し解決する力にある」というメッセージを発信している。</p>
<h3 id="しかし数字は少し違う物語を語る"><a class="anchor" href="#しかし数字は少し違う物語を語る">しかし、数字は少し違う物語を語る</a></h3>
<p>ここまでを見れば、「もう頼むだけで全部できるんだな」という結論に向かいやすい。ところが、実際のデータを詳しく見ると、話は少し違う。</p>
<p>ソフトウェア開発の現状を総合的に分析した<a href="https://survey.stackoverflow.co/2025/ai" target="_blank" rel="noopener noreferrer"><strong>2025 Stack Overflow Developer Survey</strong></a>の数字から見てみよう。</p>
<ul>
<li>開発者の84%が、AIツールを使用している、または使用する予定だと回答した（2024年の76%から上昇）。</li>
<li>プロの開発者のうち51%が、AIツールを毎日使っている。</li>
<li>しかし、<strong>AIツールに対する好意度（positive sentiment）はむしろ下がった</strong>。2023年と2024年には70%以上だった好意度が、2025年には60%まで落ち込んだ。</li>
<li>10年以上の経験を持つシニア開発者は、AIの出力に対する信頼度が最も低い。</li>
</ul>
<p>要するに、「<strong>みんな使ってはいるが、時間が経つほど信用できなくなっている</strong>」ということだ。</p>
<p><a href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener noreferrer"><strong>METR</strong></a>という非営利研究所が2025年に行った実験は、この認識と現実の隔たりをさらに劇的に示している。平均5年の経験と平均1,500コミットの実績を持つ熟練したオープンソース開発者16人に246件の作業を依頼し、AIを使うかどうかを無作為に割り当てた対照実験だった。結果は次のとおりだ。</p>
<ul>
<li>開始前、開発者たちは「AIを使えば24%速くなる」と予測した。</li>
<li>作業を終えた直後にも、「20%ほど速くなった気がする」と自己評価した。</li>
<li>ところが、実測値では<strong>19%遅くなっていた</strong>。</li>
</ul>
<p>研究チームが指摘した原因は興味深い。AIが生成したコードの採用率は44%以下で、却下したコードにもレビューとテストの時間がかかり、採用したコードでさえレビューと修正にかなりの時間を要したという。遅くなったのに速くなったように感じる錯覚。この隔たりが、シニア開発者たちがAIに対してますます懐疑的になる理由の一つだ。</p>
<p>さらに、「AIが書くコードの品質」そのものも盤石ではない。<a href="https://www.veracode.com/blog/genai-code-security-report/" target="_blank" rel="noopener noreferrer"><strong>Veracode</strong></a>が100以上のAIモデルにコードを書かせた実験を見てみよう。</p>
<ul>
<li>AI生成コードの<strong>45%にOWASP Top 10のセキュリティ脆弱性</strong>が含まれていた。</li>
<li><strong>XSS（クロスサイトスクリプティング）の防御に失敗した割合は86%</strong>。</li>
<li>ログインジェクション（Log Injection）の防御失敗率は88%。</li>
<li>別の研究では、AIコードの脆弱性密度は人間のコードと比べて<strong>2.7倍高い</strong>と報告された。</li>
</ul>
<p>フロントエンドに直結するXSSの失敗率86%は、とりわけ重く受け止めるべき数字だ。AIが書いたform inputをそのままマージすることが何を意味するのか、この数字がよく示している。（フロントエンドのセキュリティ監査を経験した開発者なら、<code>dangerouslySetInnerHTML</code>を自分で直接書くときでさえ違和感を覚えて不安になるのに、AIがこっそり差し込んだものはさらに怖いと感じるだろう。）</p>
<p>品質面でも、同じような兆候が表れている。<a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" target="_blank" rel="noopener noreferrer"><strong>GitClear</strong></a>が2020〜2024年の間に行われた2億1,100万行のコード変更を分析した結果は次のとおりだ。</p>
<ul>
<li>作成後2週間以内に元に戻された（reverted）コードの割合（Code Churn）：2020年5.5% → 2024年<strong>7.9%</strong></li>
<li>リファクタリングが占める割合：2021年25% → 2024年<strong>10%未満</strong></li>
<li>コピー＆ペースト（クローン）の割合：2021年8.3% → 2024年<strong>12.3%</strong>（2025年には実に4倍に増加）</li>
</ul>
<p>解釈はそれほど難しくない。コードを素早く量産する力は高まった一方で、再び手を入れる価値のあるコードを書く力は低下したということだ。Fortune 50企業を分析した<a href="https://www.softwareseni.com/ai-generated-code-security-risks-why-vulnerabilities-increase-2-74x-and-how-to-prevent-them/" target="_blank" rel="noopener noreferrer">Apiiroのデータ</a>では、さらに強い傾向が見られる。AI支援を利用する開発者は、同僚の3〜4倍のコミットを生み出す一方、セキュリティ上の指摘事項は10倍多く生み出す。権限昇格経路（privilege escalation）は322%増、アーキテクチャ設計上の欠陥は153%増と急増した。</p>
<h2 id="aiは何を代替し何を代替できなかったのか"><a class="anchor" href="#aiは何を代替し何を代替できなかったのか">AIは何を代替し、何を代替できなかったのか</a></h2>
<p>ツールは暴走する勢いで進化しているが、数字が示す実態は一筋縄ではいかない。ではAIは、具体的に何を代替し、何をまだ代替できていないのか。この区別を明確にしてこそ、どこに時間を投資すべきかが見えてくる。</p>
<p>代替されたのは、開発者が自らタイピングする比重だ。ボイラープレートや反復的なコードを書く場面は減り、デザイン案さえあれば、規約に沿った水準の画面が数分で出来上がる。構文やAPIを調べる時間も、学習曲線を上る時間も急激に短くなった。要するにAIは、<strong>「生産速度」を平準化した</strong>。</p>
<p>しかし、「判断」の領域はまだ代替できていない。（正確には「まだ期待を満たせていない」に近い。人によってAI活用能力には差があるだろうが、ここでは平均的な利用体験を基準に話を進める。）</p>
<p>最初に立ちはだかるのは、<strong>要件を仕様へ翻訳する作業</strong>だ。曖昧なビジネス要件を、正確なエッジケースとステートマシンに落とし込む作業には、今なお人の手がより深く必要になる。<strong>システム全体への影響を把握すること</strong>も同様だ。このコンポーネントがバンドルにどのような影響を与えるのか、依存関係はツリーシェイキング可能なのか、データフェッチのパターンが<a href="https://web.dev/articles/vitals" target="_blank" rel="noopener noreferrer">Core Web Vitals</a>の<a href="https://web.dev/articles/inp" target="_blank" rel="noopener noreferrer">INP（Interaction to Next Paint）</a>スコアにどう影響するのか。こうした問いにAIがもっともらしい答えを出しても、結局は人がもう一度確認しなければ安心できない。</p>
<p>先ほど見た45%のOWASP脆弱性の問題と同様に、<strong>セキュリティとリスク評価</strong>も欠かせない。新しいコンポーネントが既存システムのトークン、アクセシビリティルール、インタラクションパターンと整合しているかを見る<strong>デザインシステムと一貫性の維持</strong>、なぜこの機能が必要なのか、どのユーザーフローに組み込むべきなのかを考える<strong>顧客・市場の文脈理解</strong>といった領域も同じだ。</p>
<p>最後に、<a href="https://yceffort.kr/2026/02/frontend-engineering-in-ai-era" target="_blank" rel="noopener noreferrer">yceffortさん</a>の記事にある表現を借りれば、「システムの複雑さと、チームがそのシステムを理解している度合いとの隔たり」、すなわち<strong>認知負債（Cognitive Debt）の管理</strong>は、むしろAI導入後により速く広がる領域だ。この隔たりを縮めること自体が、人の役割として残っている。</p>
<blockquote>
<p>なくなるのは開発者ではなく、開発者がしていた仕事の形だ。ボトルネックが「作る速さ」から「決める速さ」へ移った。</p>
</blockquote>
<p>同じ文脈で、Tossの<a href="https://toss.tech/article/will-ai-replace-developers" target="_blank" rel="noopener noreferrer">「開発者はAIに代替されるのか」</a>は、もう少し重い診断を下している。記事の要旨はこうだ。AIはすべての人員を代替するのではなく、徒弟制度の階段（apprenticeship ladder）を取り払いつつある。今のシニアたちが引退する10〜20年後には、複雑なシステムを設計する次世代の人材が不足するだろう。これは「うちの会社は来年どう採用しようか」という次元ではなく、業界全体に仕掛けられた時差式爆弾のような問題だ。（悩みの多い時期に、本当によく書かれた記事だと思う。）</p>
<p>AIが作ってくれる「動く最初のバージョン」は70%だ。「実際のユーザーに提供してもよいバージョン」に至るまでの30%が、人の領域だ。そして、その30%を埋める力は一朝一夕には身につかない。これが徒弟制度の階段をめぐる問題の本質だ。ボイラープレートや単純なコンポーネントを書きながら「手を汚す時間」がなくなれば、その30%を埋められる人も同時にいなくなる。</p>
<p>ペ・フィドンさんの元記事では、「優れたエンジニアに必要な5つの基礎能力」として、<strong>良いコードを書くこと、現在価値の最大化（迅速なリリースと長期的な保守性のバランス）、データに基づく意思決定、同僚の効果的な意思決定の支援、継続的な学習</strong>を挙げていた。5つすべてがAI時代にも有効だが、その中でも最後の項目が最も危うい位置にある。学習そのものがなくなったのではなく、学ぶ対象が変わった。以前は「このツールをどう使うか」を学んでいたとすれば、これからは「このシステム全体がどう動くのか」を学ぶことに時間を使わなければならない。さらに恐ろしいのは、<a href="https://evan-moon.github.io/2026/04/18/developers-who-stopped-growing-in-ai-era/" target="_blank" rel="noopener noreferrer">Evan Moonさんが指摘した</a>「AIがコード作成を代行した瞬間、脳の認知負荷が急激に減る問題」だ。認知負荷が減るのは一見よいことに聞こえるが、その負荷こそが学びの材料だったという点で危険だ。<strong>楽になるほど成長しない。</strong></p>
<p>ここで自然に浮かぶ疑問がある。では、ペ・フィドンさんの記事にある3つのトラック（Web特化／プロダクト特化／運用特化）は、もう意味がないのだろうか。</p>
<p>筆者の考えは違う。トラックそのものは今も有効だ。ただし、それぞれのトラックがAI時代に合わせて一段階ずつ進化したと見るのが妥当だろう。トラックごとに景色がどう変わったのか、整理してみよう。</p>
<h2 id="生産者から検証者へ"><a class="anchor" href="#生産者から検証者へ">生産者から「検証者」へ</a></h2>
<p>ペ・フィドンさんの元記事では、Web特化トラックは<strong>Software Engineer</strong>という名前でまとめられていた。「インターネット、Webブラウザ、HTML/CSS/JSに対する深い理解と活用」、Webエコシステムのツールの長所と短所を見極める力とトラブルシューティング経験、新しい技術に敏感な姿勢が核心であり、シニアへの道としては、<strong>Webエコシステムのツール開発会社のエンジニア／フロントエンドの教育者／複雑なプロダクト組織のテックリード</strong>が提示されていた。一言でいえば「ブラウザとHTML/CSS/JSの動作原理を深く掘り下げる人たち」であり、1〜2年前まで彼らの最大の武器は「誰よりも正確にコードを書けること」だった。</p>
<p>AI時代に彼らの価値はどう変わったのだろう。コードを書く速さだけを見れば、AIが追いついた。しかし、「<strong>AIが書いたコードを正確に評価する力</strong>」は、むしろ彼らがほぼ独占していると言ってよい。</p>
<ul>
<li>AIを使う一般の人：自分が望んだ要件どおりに実装されて、問題なく動いている。</li>
<li>AIを使う開発者：動いてはいるが、この依存関係はこういう問題を引き起こす可能性があり、このパターンはこう改善したほうが規約に合っている。関連部分をもう一度確認しよう。</li>
</ul>
<p>先ほど取り上げたVeracodeの研究には、XSSで86%、ログインジェクションで88%が防御に失敗したという数字があった。これを見つけて食い止められる人こそ、私たちのような専門家だ。彼らは、AIの成果物を品質管理（QA）するシニアの役割へと自然に進化していく。</p>
<p>もう一つ、専門家の領域にまったく新しいトピックが加わった。<strong>生成UI（Generative UI）<strong>と</strong>AIインターフェースデザイン</strong>だ。LLMの応答をストリーミング表示するチャットUI、途中で停止できるabortコントロール、Markdownやコードブロックの段階的レンダリング、ツール呼び出しの結果をインラインで表示するUX、<a href="https://sdk.vercel.ai/" target="_blank" rel="noopener noreferrer">Vercel AI SDK</a>や<a href="https://modelcontextprotocol.io/" target="_blank" rel="noopener noreferrer">MCP（Model Context Protocol）</a>を利用したアシスタント統合などがその例だ。この領域では、「Webの動作原理を正確に理解しながら、同時にLLMの動作特性も理解して応用・活用できる人」への需要が爆発的に高まっている。</p>
<h2 id="product-engineerへの自然な進化"><a class="anchor" href="#product-engineerへの自然な進化">Product Engineerへの自然な進化</a></h2>
<p>プロダクト特化トラックは、最も大きな恩恵を受けたトラックだ。市場と顧客への理解が深く、外部のステークホルダーと頻繁にコミュニケーションを取る人は、AIが加わった瞬間、はるかに強力な武器を手にした。シニアへの道として、<strong>グロースエンジニア・コンサルタント／PM・PO・CPOへの転向</strong>など、他職種への広がりもあわせて提示されていたのが、このトラックの特徴だ。</p>
<p>興味深い変化は、このトラックの名称がグローバルスタンダードとして定着し始めたことだ。元記事でもすでにこのトラックを「Product Engineer」と呼んでいたが、記事を読んだ当時、筆者にとってはやや耳慣れない表現だった。それから1年が経った今では、<a href="https://leerob.com/product-engineers" target="_blank" rel="noopener noreferrer">Vercelが職務記述書の「Fullstack Engineer」を一斉に「Product Engineer」へ変更するほど</a>定着している。</p>
<p>Lee Robinsonは、Product Engineerの核となる資質として3つを挙げている。</p>
<ul>
<li><strong>反復（Iteration）重視</strong>：デプロイ → フィードバック → 調整のサイクルを素早く回す。</li>
<li><strong>顧客中心性</strong>：顧客と直接対話しながらプロダクトを改善する。</li>
<li><strong>実用性</strong>：「技術選択はすべて手段にすぎない」。プロダクトの目標に貢献しないツールは思い切って捨てる。</li>
</ul>
<p>ここで一つ注意したいのは、プロダクト特化エンジニアを「速く作る人」とだけ捉えるのは危険だということだ。AIが登場した今、その危険はさらに大きくなった。「機能を素早く実装すること」は、今やどの職種でもAIツールを使えばできるからだ。Product Engineerの違いは、「顧客の問題を正確に定義し、最小のソリューションで素早く検証する力」にあって、「手が速いこと」にあるのではない。</p>
<p>この流れの中で、<strong>Design Engineer</strong>という職種が正式なポジションへ格上げされ始めた。Vercelは<a href="https://cjroth.com/blog/2026-02-18-building-an-elite-engineering-culture" target="_blank" rel="noopener noreferrer">デザインエンジニアを年俸20万ドル超の正式なトラック</a>として採用しており、LinearやStripeも同じ方向に動いている。フロントエンドとデザインの間のハンドオフそのものをなくす職種だ。AIが素早く絵を描いてくれるようになったからこそ、「何を描くか」と「描かれた結果が一貫したデザインシステムに沿っているか」を同時に扱う能力が、さらに希少になった結果だ。</p>
<h2 id="aiオーケストレーター"><a class="anchor" href="#aiオーケストレーター">AIオーケストレーター</a></h2>
<p>運用特化トラックは、最も劇的に変わりつつあるトラックだ。ペ・フィドンさんの元記事では、このトラックを<strong>Full-Stack Engineer</strong>に分類し、「プロジェクトの構造・統合・テスト・デプロイに強い関心を持ち、簡単なAPIとインフラを自ら扱いながら、組織の隙間を埋め、プロセスを改善する人」と定義していた。その上に、この1〜2年で<strong>AIエージェントそのものを運用する役割</strong>が新たに加わり、トラックの裾野が急速に広がっている。</p>
<p><a href="https://beyond.addy.ie/2026-trends/" target="_blank" rel="noopener noreferrer">2026年のトレンド</a>を整理する中で、「<strong>コーディングエージェントのオーケストレーション（Orchestrating Coding Agents</strong>）」という概念が中心的なものとして挙げられた。これは、一つのAIに指示することを超えて、複数のAIエージェントを同時に協働させるシステムを設計・運用することを意味する。同じ文脈で、彼は<a href="https://github.com/addyosmani/agent-skills" target="_blank" rel="noopener noreferrer">「agent-skills」</a>というフレームワークを提案している。プロフェッショナルなワークフローや品質ゲート、業界のベストプラクティスを、エージェントの動作ロジックに直接組み込もうという意見も出ている。</p>
<p>関連資料をまとめてみたところ、筆者が考える、運用トラックのエンジニアが新たに扱うべきキーワードは次のとおりだ。</p>
<ul>
<li><strong>MCP（Model Context Protocol）</strong>：Anthropicが提案した、LLMと外部ツールを接続するための標準</li>
<li><strong>AIガバナンス</strong>：誰がどのコンテキストでAIを使えるのか、シークレットが漏れていないかを管理</li>
<li><strong>エージェント評価（Evaluation）</strong>：エージェントが生み出した成果物を自動採点するパイプライン</li>
<li><strong>AIゲート</strong>：PRのマージ前に行う自動的なセキュリティ・品質検証、AIコードのラベリング</li>
</ul>
<p>元記事では、運用トラックのシニアへの道として、<strong>大規模組織のプラットフォームチームのエンジニア／テックリード／アジャイルコーチ／テクニカルプログラムマネージャー（TPM）／CTO</strong>といった役割を挙げていた。この道は今も有効だが、そこに「<strong>AI開発インフラリード</strong>」、「<strong>開発者生産性（DevProd）エンジニア</strong>」といった新しい役割が加わったと考えればよい。</p>
<p>3つのトラックがそれぞれ進化する一方で、すべてのトラックに共通して、より重要になった能力がある。本来は5年後を基準に考えたかったが、最近の進歩の速さを見ると、1年という単位さえ長すぎるように感じる。そこで、ひとまず「来年」程度まで視野を狭め、筆者が今後さらに重要になると考える能力を見ていきたい。</p>
<h2 id="5つの能力"><a class="anchor" href="#5つの能力">5つの能力</a></h2>
<p><strong>一つ目は、仕様（Specification）を書く力だ。</strong> AI時代における「コーディングの出発点」は、キーボードではなく<strong>仕様</strong>だ。AIに何をさせるかを正確に書き表す力が、コードそのものより重要になった。ここでいう仕様とは、大がかりなRFC文書ではない。ビジネスロジックに期待する動作をコードで記述した<strong>テスト</strong>、UIコンポーネントのシナリオと視覚的な契約を整理した<strong>Storybookのストーリー</strong>、データフローの契約を明示する<strong>型定義</strong>などだ。結局のところ、AIが作った成果物を自動で検証する基準をあらかじめ用意する作業であり、これがない状態でAIコーディングを進めれば、問題が積み重なっていく。</p>
<p><strong>二つ目は、検証力と判断力だ。</strong> AIは、もっともらしいが間違ったコードを自信満々に作り出す。そのため、「AIのコードを素早く正確にレビューする力」そのものが中核になると考えている。セキュリティヘッダー、入力値のサニタイズ、CSRFトークンを見落としていないか、アクセシビリティ（ARIA、キーボードナビゲーション、フォーカストラップ）が保たれているか、レンダリングコスト、メモリ、バンドルサイズなど、パフォーマンスへの影響に問題がないかを見極めることだ。レビューせずにAIスロップをPRへ投げ込むのは、エンジニアとしての職務放棄だ。マージボタンを押すのは今も人であり、その責任をAIに押しつけることはできない。Stack Overflowの調査でシニアのAIへの信頼度が最も低いのも、結局はこうした細部を見抜く目を持っているからである可能性が高い。</p>
<p><strong>三つ目は、システム理解とアーキテクチャ思考だ。</strong> AIは一度に一つのファイルをうまく扱い、フローや関連性を認識する力にも優れている。AIは症状を素早く直すが、能力の高い開発者は根本原因を探す。この力を伸ばす方法の一つは、Architecture Retrospectiveのような意識的な活動を行うことだ。コードの変化が速くなったぶん、チームのシステム理解も意識して引き上げなければ、認知負債が急速に蓄積する。</p>
<p><strong>四つ目は、AIオーケストレーション能力だ。</strong> AIそのものを扱う力も、独立したスキルセットとして分化しつつある。単にプロンプトを上手に書くという次元ではなく、作業を小さなチケットに細分化する力、同じ作業にどのモデルを使うか選ぶ力、エージェントの評価・検証パイプラインを設計する力、エージェントが失敗した際の復旧（rollback）戦略までを、一つのまとまりとして扱う領域になった。<a href="https://sourcegraph.com/blog/revenge-of-the-junior-developer" target="_blank" rel="noopener noreferrer">Steve Yegge</a>は、この流れを<strong>6つのwave（traditional → completions → chat → coding agents → agent clusters → agent fleets</strong>）に整理している。</p>
<p><strong>五つ目は、Context Engineeringだ。</strong> 2025年半ばから<a href="https://www.faros.ai/blog/context-engineering-for-developers" target="_blank" rel="noopener noreferrer">KarpathyとShopifyのCEOであるTobi Lütkeがともに推し始めた</a>概念で、一言でいえば「AIにどのコンテキストを、どのような形で、どの程度見せるかを設計する力」だ。具体的には、プロジェクトの規約、アーキテクチャ原則、禁止事項をAIが参照できる場所にまとめておく<strong>CLAUDE.md／rulesファイル</strong>の整備、すべてのファイルをコンテキストに入れず、関連するモジュールだけを選んで見せる<strong>意図的なコンテキストの縮小</strong>、計画 → 実装 → 検証を別々のセッションに分け、コンテキストの汚染を防ぐ<strong>明示的な段階分離</strong>、デザインシステム、APIスキーマ、モニタリングデータなどの外部コンテキストを標準インターフェースで接続する<strong>MCPを通じた外部コンテキスト</strong>といった形で現れる。<a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropicの公式文書</a>はこれを「the new prompt engineering」と呼び、単一のプロンプトだけでは、システムのアーキテクチャ知識、パターン、組織内の暗黙知を決して収められないと明言した。言い換えれば、「一度でよいプロンプトを書くこと」よりも、「AIが常によいコンテキストを受け取れるよう環境を設計すること」のほうが、はるかに重要になった。</p>
<p>ここまで読めば、自然な疑問が浮かぶ。では、具体的にどう学べばよいのか。筆者が実践している方法は、大きく4つある。</p>
<h2 id="学び方"><a class="anchor" href="#学び方">学び方</a></h2>
<p>元記事で挙げられた「継続的な学習」という項目は今も有効だが、学習時間の配分を変えなければならない。</p>
<p>以前は多くの時間を使っていたものの、今では減らしてもよい領域がある。その一方で、以前は難しくて手をつけなかった領域や、時間のかかる複雑な領域もある。後者には、テスト仕様の作成、パフォーマンス測定ツールの活用（<a href="https://developer.chrome.com/docs/lighthouse" target="_blank" rel="noopener noreferrer">Lighthouse</a>、<a href="https://www.webpagetest.org/" target="_blank" rel="noopener noreferrer">WebPageTest</a>、Chrome DevTools Performance）、アクセシビリティ（<a href="https://www.w3.org/WAI/standards-guidelines/wcag/" target="_blank" rel="noopener noreferrer">WCAG</a>）、セキュリティ（特に<a href="https://owasp.org/www-project-top-ten/" target="_blank" rel="noopener noreferrer">OWASP Top 10</a>）などがある。そして、Vercel AI SDK、LangChain.js、MCP、ストリーミングUIパターン、エージェント評価パイプラインのように、まったく新しく身につけるべき領域も存在する。<strong>自分に必要な能力が何かを認識し、時間を配分することが重要だ。</strong></p>
<p>AIが生み出すコードは、大きくなりがちだ。1分間に数百行を出力する。そのため、PRのサイズとマージの周期を意識的に管理しなければ、コードレビューそのものが崩壊する。社内では、AI導入後に平均PRサイズが18%増、PR当たりのインシデントが24%増、変更失敗率が30%増となった。先ほどまでのデータとあわせて考えれば、大きく作って一度にマージすると、<strong>流れを把握して意図を反映するのが難しくなるため、作業単位を細分化することが重要だ。</strong></p>
<p>Evan Moonさんの記事が指摘した、認知負荷の減少という問題に直結する話がある。1日に1〜2時間は、AIなしでコードを書く時間を別に確保するとよい。アーキテクチャを手で描いてみたり、慣れていない領域のコードを自分で一行ずつ読んだりすることが、その例だ。（筆者も毎日、昼食後の眠くなる時間帯にはAIなしでコードを書いている。昔からの感覚を手放さないよう、つなぎ止めておく時間だ。）</p>
<p>これは単に「昔のやり方を忘れないため」ではない。AIが代わりにやってくれる時間の分だけ、自分自身の深さも育たないからだ。検証力や判断力、システム理解といった能力は、自ら向き合った時間の関数である。</p>
<h2 id="だから私たちは"><a class="anchor" href="#だから私たちは">だから、私たちは</a></h2>
<p>ここまで長く書いてきたが、実のところ、AI時代を生き残るフロントエンドエンジニアの姿は、元記事が示した結論とそれほど変わらない。元記事で挙げられていた、優れたシニアエンジニアになるための3つのポイントは次のとおりだった。</p>
<ul>
<li><strong>基本に忠実であろう</strong>と努める（5つの基礎能力を継続的に維持・強化する）。</li>
<li>明示的なリーダーでなくても、模範となる行動によって自然な影響力を発揮する。</li>
<li>与えられた仕事をうまく終わらせるだけで満足せず、前後の文脈に目を配り、大きな影響を生み出す。</li>
</ul>
<p>これをAI時代の観点から捉え直すと、次のようになる。</p>
<ul>
<li>AIが作るコードの先にある<strong>基礎力</strong>（Web、システム、ドメイン）を大切にする。</li>
<li>AIではなく、自分が方向を決める。明示的な責任者でないときでさえ、「どこへ向かうべきか」を判断する。</li>
<li>AIを個人の生産性ツールとして使うだけでなく、チームとシステムのボトルネックを解消するために使う。</li>
</ul>
<p>OpenAIの権威であるAndrej Karpathyの文章を見ると、彼が今強調している「<strong>agentic engineering</strong>」の核心も、結局は同じだ。システムを設計し、制約を仕様として定め、頭の中ですでに推論を終えた実装をAIで加速する。ツールが変わっても、方向を決めるハンドルは今も人の手にある、という話だ。</p>
<p>元記事が最後に伝えたメッセージも、結局は「与えられた仕事をうまく終わらせることに満足せず、前後の文脈に目を配り、大きな影響を生み出す人」がシニアになる、というものだった。AI時代には、その「インパクト」の定義が変わっただけだ。AIが1時間で作った画面を「動くからこれでいい」とマージする人と、その画面がアクセシビリティ、セキュリティ、パフォーマンス、システム整合性の面でどこまで妥当かを、さらに30分かけて確認する人がいる。1年後にシニアとして認められるのは後者だ。70%（動作）と30%（応用と活用）の境界で、30%側に立つ人が生き残る。</p>
<p>この記事を読むフロントエンドエンジニアの皆さんにも、「これから何をもっと勉強すべきか」という問いに、自分なりの答えを持ち帰ってもらえたらと思う。正解は誰にも分からない。それでも、AIがコードを書く時代であるほど、「コードの先にあるもの」を見る人が生き残るという点については、筆者はかなり確信している。1年後、この景色がさらにどう変わっているのか、そのとき改めて文章にまとめられることを願って、この記事を終える。<strong>（もしこの記事が1年後にはあまりにも当たり前、あるいは古い話に見えるなら、それだけ私たちがうまく対応できたということではないだろうか。）</strong></p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>キャリア</category>
            <category>AI</category>
        </item>
        <item>
            <title><![CDATA[抽象化]]></title>
            <link>https://hooninedev.com/ja/260201</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260201</guid>
            <pubDate>Sun, 01 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、プログラミングにおける抽象化と、抽象化の観点から良いコードを書く方法について考えてみたい。 筆者はフロントエンド開発をする中で、「このロジックはどこまで分離すべきだろう？」「このコンポーネントはどの単位で分割すべきだろう？」と数え切れないほど悩んできた。最初は、単に共通部分を取り出すことが抽象化だと思っていた。繰り返されるコードを関数にし、似たコンポーネントの共通点を抽出して一つに...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、プログラミングにおける抽象化と、抽象化の観点から良いコードを書く方法について考えてみたい。</p>
<p>筆者はフロントエンド開発をする中で、「このロジックはどこまで分離すべきだろう？」「このコンポーネントはどの単位で分割すべきだろう？」と数え切れないほど悩んできた。最初は、単に共通部分を取り出すことが抽象化だと思っていた。繰り返されるコードを関数にし、似たコンポーネントの共通点を抽出して一つにまとめること。しかし、そうして作ったコードが時間とともにかえって手を入れにくい怪物へと変わっていく経験を何度かして、抽象化について改めて考えるようになった。</p>
<p>この記事では、抽象化の本質とは何か、そしてフロントエンド開発で抽象化をどう活用すれば良いコードを作れるのかについて、筆者が考えてきたことを整理したい。</p>
<h2 id="抽象と抽象化"><a class="anchor" href="#抽象と抽象化">抽象と抽象化</a></h2>
<p>本題に入る前に、「抽象」と「抽象化」という言葉がプログラミングで正確に何を意味するのかを確認しておきたい。この二つの言葉は似ているように見えるが、性質はかなり異なる。</p>
<p><strong>抽象（Abstract）</strong> は状態であり、属性である。「これは抽象的だ」と言うとき、それは具体的な詳細が省かれ、<strong>本質的な概念だけが残っている状態</strong>を意味する。JavaやTypeScriptで<code>abstract</code>キーワードが付いたクラスやメソッドが、まさにこの意味に当たる。具体的な実装がまだ埋められておらず、本質的な形だけが定義された未完成の設計図なのだ。</p>
<p><strong>抽象化（Abstraction）</strong> はプロセスであり、行為である。複雑な対象から本質的な特徴だけを残し、不要な詳細を取り除いて単純化する、そのプロセス自体を指す。重要なのは、抽象化は「大雑把にひとまとめにすること」ではなく、<strong>各レベルで役割を正確に定義する行為</strong>だという点である。</p>
<p>日常では「抽象的」という言葉が、しばしば「曖昧だ」というニュアンスで使われる。しかし、プログラミングにおける抽象化はその正反対だ。抽象化の目的は曖昧になることではなく、絶対的に正確であり得る新たな意味のレベルを作ることである。与えられた文脈で関連する情報を保持し、関連しない情報を忘れる行為こそが、抽象化の本質となる。</p>
<p>結局、抽象と抽象化を区別して理解すると、こうなる。<strong>抽象は「本質だけが残った状態」であり、抽象化は「本質だけを残すプロセス」</strong> である。コードを設計するときに私たちが行っているのは、まさにこの抽象化、つまり複雑な実装から本質的なインターフェースだけを残し、それ以外を隠すプロセスなのだ。</p>
<p>では、こうした抽象化はなぜプログラミングに必要なのだろうか。</p>
<h2 id="なぜ抽象化が必要なのか"><a class="anchor" href="#なぜ抽象化が必要なのか">なぜ抽象化が必要なのか</a></h2>
<p>プログラミングに抽象化が必要な根本的な理由は、意外にも単純だ。<strong>より複雑なものを作るため</strong>である。より複雑なものを作ろうとしても、複雑な要素が多すぎると、そのすべてを覚えて扱うことが難しいからだ。そこで私たちは、複雑な要素をグループ化し、単純化された抽象的な概念にする。</p>
<p>フロントエンド開発者が毎日使うReactも同じだ。コンポーネントを一つレンダリングするために、内部ではVirtual DOMの生成、再調整（Reconciliation）、実際のDOM操作という複雑なプロセスが行われる。しかし私たちは、それらを気にせずJSXを書けばよい。Reactがこの複雑なプロセスを抽象化してくれているからだ。</p>
<p>次のコードを見てみよう。UserProfileコンポーネントを使うとき、内部で行われるVDOMの生成やdiffingといった複雑なプロセスを知らなくても、十分にUIを作り、扱うことができる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">UserProfile</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>以前はWebpackを自分で設定することがフロントエンド開発者の日常だったが、現在ではNext.jsやViteのようなフレームワークがバンドル設定を抽象化している。おかげで私たちは、バンドラーの内部動作を知らなくてもアプリケーションを開発できるようになり、その時間を使って、<strong>ビジネスロジックやユーザー体験といった、より高次の問題に集中</strong>できるようになった。（だからこそ筆者は、フロントエンド開発者の役割において抽象化の概念が非常に重要だと考えている。）</p>
<p>結局、抽象化の核心的な価値はこれだ。複雑なものを隠して単純に見せ、それぞれが自分の領域だけに集中できるようにすること。そのおかげで私たちは、一人ですべてを理解しなくても、ますます巨大で複雑なソフトウェアを作り出せるようになった。</p>
<p>では、抽象化がこれほど良いものなら、多ければ多いほど良いのだろうか。どのような目的で抽象化すべきなのかを考えてみよう。</p>
<h2 id="文脈を減らす"><a class="anchor" href="#文脈を減らす">文脈を減らす</a></h2>
<p>多くの開発者は、抽象化を「共通部分を選び出すこと」だと捉えている。間違いではないが、これは抽象化を行う手法の一つにすぎず、抽象化の本質を説明するものではない。</p>
<p>筆者が考える抽象化の本質は、<strong>「コードを読む人が知るべき文脈を、適切なレベルまで減らすこと」</strong> である。</p>
<p>簡単な例を見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "pending"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> total </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> orders</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"a"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"pending"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"c"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">8000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (orders[i].status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders[i].amount;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>このコードを読む開発者は、ループの初期化・条件・更新を把握し、インデックスで要素にアクセスし、条件を確認して分岐し、外部の累積変数がどのように更新されるのかまで、すべて頭に入れなければならない。このコードが実際にやろうとしていることは、<strong>「完了した注文の合計を求める」</strong> という一文にすぎないのに、その一文を理解するために四つの異なる文脈を同時に抱えているわけだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> total</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> order.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> order.amount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p><code>filter</code>と<code>reduce</code>という抽象化のおかげで、開発者は「完了した注文だけを選ぶ」と「金額を累積する」という二つの意図だけを追えばよい。インデックスの管理、累積変数の宣言と更新という文脈は、コードの表面から消えた。</p>
<p>さらに一歩進めることもできる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> total</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sumCompletedOrders</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(orders);</span></span></code></pre></figure>
<p>これでコードを読む人は、この計算が配列の走査によって行われるという事実すら知る必要がなくなる。「完了した注文の総額を求める」というビジネス上の意図だけが残る。コードが<strong>どのように（How）計算されるか</strong>ではなく、<strong>何を（What）計算するか</strong>に集中できるようになったのだ。</p>
<p>この観点から見ると、私たちが日々書いているReactコードも、数多くの抽象化の組み合わせであることが分かる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { css } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@emotion/css"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { format } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "date-fns"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodayHeader</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> now</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Date 객체 생성이라는 복잡한 과정을 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> formatted</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(now, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"yyyy-MM-dd"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 날짜 포맷팅 로직을 추상화</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h1</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">css</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">        font-size: 1.8rem;</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">      `</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* CSS-in-JS 처리 과정을 추상화 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      Today is {formatted}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// React.createElement를 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 그리고 위 모든 것을 다시 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TodayHeader</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span></code></pre></figure>
<p>もし<code>emotion</code>、<code>date-fns</code>、<code>react</code>の内部コードがすべてこのコンポーネントファイルに展開されていたら、どうなるだろう。どこから読めばよいのか、どの部分がビジネスロジックで、どの部分がライブラリのコードなのかを区別するのが難しいはずだ。抽象化が各領域の文脈を適切に隠してくれているからこそ、私たちは「今日の日付を表示する」という本質だけに集中できる。</p>
<p>では、実際にコードを設計するとき、抽象化にはどのような方向から取り組むべきだろうか。</p>
<h2 id="抽象化レベルが高い低いとはどういうことか"><a class="anchor" href="#抽象化レベルが高い低いとはどういうことか">抽象化レベルが高い、低いとはどういうことか</a></h2>
<p>抽象化を語る上で欠かせない概念が、<strong>抽象化レベル（Level of Abstraction）</strong> である。コードの抽象化レベルが「高い」あるいは「低い」とは、一体どういう意味なのだろうか。</p>
<p><strong>抽象化レベルが低いコード</strong>は、コンピューターが実行する具体的な手順に近いコードである。文字列を直接パースし、インデックスで配列を走査し、バイトを操作する。<strong>どのように（How）</strong> 動作するのかが、ありのままに表れている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 낮은 코드</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/api/users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filteredUsers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'active'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filteredUsers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> element</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> document.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getElementById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`user-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">id</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (element) element.style.display </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'block'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><strong>抽象化レベルが高いコード</strong>は、ビジネスドメインや問題領域の言葉で表現されたコードである。<code>processPayment(order)</code>、<code>sendNotification(user, message)</code>、<code>validateUserInput(formData)</code>などがそれに当たる。抽象化レベルが高いコードには、<strong>何を（What）</strong> するのかが表れ、どのようにするかは隠されている。</p>
<p>Robert C. Martinは<strong>Clean Code</strong>で、この概念を「<strong>一つの関数につき一つの抽象化レベル（One Level of Abstraction per Function）」</strong> という原則にまとめた。一つの関数内に高いレベルと低いレベルのコードが混在すると、読む人は「これは中心的なロジックなのか、それとも実装の詳細なのか」を一行ごとに判断しなければならないからだ。</p>
<p>実際のコードを見ると、この問題は明確になる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 뒤섞인 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 비즈니스 규칙 검증</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 비밀번호 해싱 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> encoder</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TextEncoder</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> encoder.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">encode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashBuffer</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.subtle.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">digest</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"SHA-256"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, data);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(hashBuffer))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">16</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 사용자 저장</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 이메일 전송 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> verifyToken</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">randomBytes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">32</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"hex"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> db.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">execute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    "INSERT INTO email_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [user.id, verifyToken, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> transporter.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sendMail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    from: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"noreply@example.com"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    to: user.email,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subject: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Welcome! Please verify your email"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    html: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`&#x3C;a href="/verify?token=${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">verifyToken</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}">Verify your account&#x3C;/a>`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 환영 이메일 발송</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>この関数を読む人は、「ユーザー登録の流れ」というビジネスルールに基づく高いレベルの文脈を追っていたのに、突然、ハッシュバッファの操作、SQLクエリ、メールテンプレート文字列という低いレベルの文脈へと引きずり下ろされる。そして再び、<code>sendWelcomeEmail</code>という高いレベルへ飛ぶ。このように抽象化レベルが上がったり下がったりすると、読む人の頭の中も一緒に上下することになる。</p>
<p>同じ関数を、抽象化レベルを揃えて書き直すと次のようになる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 일관된 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashPassword</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendVerificationEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>すべての文が同じ高さの抽象化レベルで語られている。メール送信がどのように実装されているのか、パスワードのハッシュ化にどのアルゴリズムを使うのかは、それぞれ下位の関数が担う。この関数を読む人は、「ユーザー登録の全体的な流れ」という一つの文脈だけに集中すればよい。</p>
<p>Martinはこれを、<strong>「ステップダウン・ルール（The Stepdown Rule）」</strong> とも呼んでいる。コードを上から下へ読むとき、新聞記事のように、上には全体像があり、下へ進むほど詳細が現れるべきだという考え方だ。</p>
<p>Kent Beckも<strong>Smalltalk Best Practice Patterns</strong>で、同じ原則を<strong>Composed Methodパターン</strong>として提示した。一つのメソッドは同じ抽象化レベルの処理だけで構成され、各ステップは一行のメソッド呼び出しで表現されるべきだというものだ。</p>
<p>結局、これらの話はすべて一つに集約される。<strong>一つの関数は、一つの抽象化レベルだけで語るべきである。</strong> これを守るだけでも、コードの可読性は目に見えて変わる。</p>
<p>では、抽象化の方向性はどう定めればよいのだろう。具体的なものから始めるべきか、それとも抽象的なものから始めるべきか。</p>
<h2 id="共通点の抽出ではなく部品の組み立てとして考える"><a class="anchor" href="#共通点の抽出ではなく部品の組み立てとして考える">共通点の抽出ではなく、部品の組み立てとして考える</a></h2>
<p>OOPでは、「具体的なものの共通点を抜き出して、抽象的なものを定義せよ」とよく言われる。このアプローチ自体が間違っているわけではないが、筆者は、この方法にこだわりすぎると、現在の要件に縛られた設計を作る危険があると考えている。</p>
<p>例を挙げよう。要件にA、B、Cというボタンがあり、三つとも青く丸い形で、違いはラベルのテキストだけだとする。共通点だけを抜き出して設計すると、次のように表現できる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BlueRoundButton</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue round"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{label}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>現在の要件は完璧に満たされる。ところが数日後、企画担当者がこう言う。</p>
<blockquote>
<p>「Bボタンの色を変更できるようにしてください。」</p>
</blockquote>
<p>この瞬間、<code>BlueRoundButton</code>という名前からして不自然になる。色のpropを追加すれば対応はできるが、そもそも「青く丸いボタン」という具体的な共通点から出発した設計が、変更に弱かったのだ。（この程度ならまだかわいいものだ。現実には、ボタンの形やサイズなど、数え切れないほどの要望が飛んでくる。）</p>
<p>こうした状況が繰り返されると、自然と気づくことがある。<strong>具体的な要件から共通点を抜き出す方法では、抽象化された成果物さえも現在の要件だけを反映したものになりやすい</strong>ということだ。</p>
<p>そこで筆者が好むのは、反対方向のアプローチである。具体的なものから抽象を抜き出すのではなく、<strong>まず抽象的な部品を考え、それらを組み立てて具体的なものを作る方法</strong>だ。</p>
<p>トースト通知コンポーネントを作るとしよう。最初の要件は単純だ。「保存が完了したら、画面下部に短い案内メッセージを表示してください。」共通点を抽出するアプローチなら、次のようになる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 현재 요구사항의 공통 특성에서 출발한 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  hasAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  actionLabel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">hasAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">actionLabel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">onAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{message}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    {hasAction </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onAction}>{actionLabel}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>現在の要件には完璧に対応できる。ところが数日後、企画担当者が「成功／警告に応じて左側にアイコンを入れてください」と言う。<code>hasIcon</code>と<code>iconName</code>が追加される。続いて、「アップロードの進捗バーが付いたトーストも必要です」という要望が飛んでくる。<code>progress</code>というpropがまた一つ増える。このプロセスを何度か繰り返すと、<code>Toast</code>は十個を超えるpropsを持ち、<strong>この組み合わせは使えるが、あの組み合わせは使えない</strong>という隠れたルールまで暗記しなければならないコンポーネントになる。（そしてたいてい、そのルールはコメントにも残されない。）</p>
<p>そもそも、<strong>「トーストはこういう形であるべきだ」という現時点の具体的なイメージ</strong>から出発した設計が、変更に弱かったのだ。</p>
<p>部品を組み立てるアプローチなら、話は変わる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 부품을 조립하는 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Icon</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "check"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "warn"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "info"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Action</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  onClick,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;{ </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onClick}>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Progress</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 필요한 부품만 골라 조립한다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>저장되었습니다&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Icon</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"check"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>업로드 완료&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{undo}>실행 취소&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Progress</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>파일 전송 중...&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>「トーストは何かを収める浅いコンテナである」という変わらない本質と、「その中に何が入るか」という変わりやすい具体が分離された。これで<code>Toast</code>の内部に手を加えなくても、新しい部品をいくらでも追加でき、既存の部品を新たな組み合わせで配置できる。propの組み合わせに関する有効性のルールもなくなる。単純に、<strong>入れたいものを入れればよい</strong>のだ。</p>
<p>もちろん、経験豊富な開発者なら「最初からIoC（制御の反転）で設計すればいいのでは」と言うかもしれない。その通りだ。しかし、その判断ができるのは、過去に数多くの試行錯誤を重ね、「どこが変更されやすい部分なのか」を見抜く感覚が培われているからである。</p>
<p>こうした感覚がまだ十分でないなら、「この機能はどのような部品で構成され、それぞれの部品をどう組み立てるべきか」という問いから始めるほうが、変更に開かれた設計をはるかに作りやすい。</p>
<p>ここまで読むと、自然に一つの疑問が浮かぶ。では、部品をどのような基準で分け、外部にどう表現すればよいのだろうか。</p>
<h2 id="良い抽象化のための三つのこと"><a class="anchor" href="#良い抽象化のための三つのこと">良い抽象化のための三つのこと</a></h2>
<h3 id="表現について考える"><a class="anchor" href="#表現について考える">表現について考える</a></h3>
<p>抽象化されたモジュールにとって最も重要なのは、ソースコードを開かなくても動作を推測できることである。Kent Beckはこれを「<strong>意図が伝わる名前（Intention-Revealing Name）」</strong> パターンと呼び、簡潔な名前を付けられないなら、抽象化そのものを見直すべきだと述べた。</p>
<p>そのために使える道具は、大きく二つある。<strong>名前</strong>と<strong>型</strong>だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도대체 뭘 하는 건지 알 수 없는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">price</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이름과 타입만으로 동작을 유추할 수 있는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateDiscountedPrice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">originalPrice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">discountRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><code>calculateDiscountedPrice</code>は、名前を見るだけで、元の価格と割引率を受け取り、割引後の価格を計算することが分かる。<code>number</code>を受け取って<code>number</code>を返すという型情報も、それを裏付けている。内部でどのような計算ロジックが適用されるかは、知らなくてもよい。</p>
<p>一方、<code>calculate(price: number, rate: number): number</code>は、何を計算するのか分からないため、結果を予想できない。結局、ソースコードを開かなければ使えず、抽象化の利点を失ってしまう。</p>
<p>ここで、名前の付け方そのものが抽象化レベルを反映する点に注目したい。プログラミングにおける関数名は、一般に<strong>動詞＋名詞</strong>の組み合わせで構成されるが、どの動詞を選ぶかによって、その関数がどの抽象化レベルで動作するかが表れる。</p>
<blockquote>
<p>ただし、動詞だけで抽象化レベルが決まるわけではない。一緒に使われる名詞（ドメインの文脈）が最終的なレベルを決める</p>
</blockquote>
<p>抽象化レベルが<strong>低い</strong>側でよく使われる動詞がある。<code>parse</code>、<code>encode</code>、<code>decode</code>、<code>serialize</code>、<code>read</code>、<code>write</code>、<code>push</code>、<code>pop</code>、<code>convert</code>、<code>transform</code>などだ。これらの言葉は、データの物理的な変換やデータ構造の直接的な操作を示唆する。</p>
<p>中間レベルでは、<code>get</code>、<code>save</code>、<code>load</code>、<code>validate</code>といった動詞が登場する。技術的な動作ではあるものの、その動作の意図がある程度伝わるレベルだ。</p>
<p>抽象化レベルが<strong>高い</strong>側では、<code>register</code>、<code>refund</code>、<code>confirm</code>、<code>cancel</code>、<code>submit</code>といった動詞が使われる。これらはビジネスドメインの言葉である。内部でどのような技術的手順が行われるかは一切表れず、<strong>ユーザーの行為やビジネスプロセス</strong>だけが表現される。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 낮은 수준: 기술적 동작이 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseJSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">text</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> object</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> encodeBase64</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 중간 수준: 의도가 드러나되 기술적 맥락이 남아있음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getUserById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> validateEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 높은 수준: 비즈니스 의도만 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">form</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RegistrationForm</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refundPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">orderId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OrderId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Refund</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>Robert C. Martinは<strong>Clean Code</strong>で、これについて「<strong>短く謎めいた名前より、長く説明的な名前のほうがよい</strong>」と述べている。また、「<strong>一つの概念には一つの単語を使う</strong>」という原則も提示した。同じ文脈の動作に<code>fetch</code>、<code>retrieve</code>、<code>get</code>を混在させると、読む人が「この三つは異なる動作なのか」と混乱するからだ。</p>
<p>この原則は、Reactコンポーネントやフックの命名にもそのまま当てはまる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />           </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SearchInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />      </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SubmitOrderButton</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>コンポーネントは、抽象化レベルに応じて名前の具体性が異なる。Buttonは低いレベルの汎用UIプリミティブとして使われるが、SubmitOrderButtonは高いレベルでビジネス上の意図が明確に表れる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FormData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Form</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit} />           </span></span></code></pre></figure>
<p><code>on*</code>は、コンポーネントが外部に公開するpropの名前である。コンポーネントを使う側で「どのイベントに反応するか」を宣言する。<code>handle*</code>は、そのpropに実際に渡される実装関数の名前だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();                  </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">items</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCartItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModal</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();   </span></span></code></pre></figure>
<p>カスタムフックは<code>use</code>接頭辞を使ってReactのルールに従い、フックが提供する状態や動作をコンポーネントで利用できるようにする。</p>
<blockquote>
<p><a href="https://blog.codinghorror.com/" target="_blank" rel="noopener noreferrer">Coding Horror</a>を運営するJeff Atwoodは、<code>Manager</code>という接尾辞の問題を指摘したことがある。<code>UrlManager</code>という名前では、URLをプールするのか、検証するのか、生成するのかがまったく分からない。<code>UrlBuilder</code>、<code>UrlValidator</code>、<code>UrlPool</code>のように、具体的な役割が伝わる名前のほうがはるかに良い。名前が曖昧であることは、そのモジュールの責務自体が曖昧である兆候かもしれないのだ。</p>
</blockquote>
<p>結局、良い名前とは、<strong>そのコードがどの抽象化レベルで動作するのかを、読む人に即座に伝える名前</strong>である。</p>
<h3 id="入力の自由度を意図的に設計する"><a class="anchor" href="#入力の自由度を意図的に設計する">入力の自由度を意図的に設計する</a></h3>
<p>抽象化されたモジュールを設計するとき、よく直面する悩みがある。「機能をどこまで開放するか」だ。この決定によって、モジュールを使う開発者の体験は大きく変わる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 닫힌 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 완전히 열린 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"button"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">props} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>最初のボタンは<code>children</code>しか受け取れない。<code>onClick</code>も<code>type</code>も<code>disabled</code>も設定できない。その代わり、使う人が迷うことはない。</p>
<p>二つ目のボタンは、<code>button</code>要素のすべての属性を受け取れる。自由度は高いが、使う側は数十個のpropのうち何を使うべきか悩むことになる。筆者はこの状況を、<strong>「コンポーネントが開発者に判断を強いる」</strong> と表現している。</p>
<p>正解はない。ただし、モジュールの目的と利用者に応じて、適切なレベルを見つける必要がある。デザインシステムの基本ボタンなら、制限されたPropsで一貫性を保つほうがよいかもしれないし、汎用ユーティリティコンポーネントなら、柔軟に開放するほうがよいかもしれない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 닫힌 인터페이스 — 다양한 상황에 대응 불가</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit}>제출&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// onClick 외의 이벤트, className, disabled 등을 전달할 방법이 없다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 열린 인터페이스 — 의도가 사라짐</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">anyProps} /></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무엇을 전달해야 하는지 사용자가 직접 파악해야 한다</span></span></code></pre></figure>
<p>抽象化の幅は、利用者が誰なのかに応じて決めるべきだ。内部実装を理解して細かく制御する必要がある利用者には、低いレベルのインターフェースが適している。一方、詳細を知らなくてもよい利用者に、開きすぎたインターフェースを提供すれば、混乱が増すだけだ。反対に、さまざまな状況に対応しなければならない利用者に対して入力を過度に制限すれば、そもそも利用できなくなってしまう。</p>
<h3 id="抽象化の単位を適切に保つ"><a class="anchor" href="#抽象化の単位を適切に保つ">抽象化の単位を適切に保つ</a></h3>
<p>抽象化の単位、つまり「どこまでを一つのモジュールとしてまとめるか」も重要な論点である。</p>
<p>フロントエンドでよく見られるアンチパターンの一つが、カスタムフックの過剰な抽出だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이 훅은 단 하나의 컴포넌트에서만 사용된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useUserProfileData</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setUser)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { user, loading };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>一つのコンポーネントでしか使われないロジックを、あえてフックとして分離すると、コードを読む人は二つのファイルを行き来しながら文脈を把握しなければならない。抽象化が文脈を減らすどころか、増やしてしまったのだ。</p>
<p>反対に、一つのフックにあまりに多くのものを詰め込むことも問題だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 관련 없는 관심사가 하나의 훅에 뒤섞여 있다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useEverything</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> auth</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> theme</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useTheme</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> analytics</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAnalytics</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useToast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { auth, theme, analytics, toast };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>こうした「God Hook」はテストが難しく、一つを変更すると無関係な部分にまで影響が及ぶことがある。</p>
<p>抽象化の適切な単位を判断する基準は、<strong>「この分離によって、コードを読む人の文脈が実際に減るか」</strong> である。分離した結果、かえって文脈が分散して把握しにくくなるなら、その抽象化にはまだ早いということだ。</p>
<h2 id="早すぎる抽象化に注意せよ"><a class="anchor" href="#早すぎる抽象化に注意せよ">早すぎる抽象化に注意せよ</a></h2>
<p>ここまで読むと、「では、いつ抽象化すればよいのか」という疑問が残るかもしれない。筆者の考えはこうだ。<strong>基本的な前提として、抽象化を急ぐべきではない。</strong></p>
<p>明確な抽象化の兆候が見えない限り、コードをそのままにしておくほうが、誤った抽象化を作って後から解きほぐすよりも、悪い状態になりにくい。誤った抽象化が作られる過程は、おおよそ次のようなものだ。</p>
<ol>
<li>コードAとコードBに似たパターンが見つかる。</li>
<li>「DRY原則だから共通関数に切り出そう！」と抽象化する。</li>
<li>コードCにも似たパターンが見つかり、同じ関数を使う。ただし、少し違う動作に対応するため、引数を一つ追加する。</li>
<li>コードD、Eでも使うようになり、条件文と引数が増え続ける。</li>
<li>こうして、この関数はあらゆる場所で使われているのに、誰も触れるのを恐れるコードになる。</li>
</ol>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 처음에는 단순했던 함수가...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">firstName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">} ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">lastName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 요구사항이 추가될 때마다 매개변수가 늘어나고...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  includeMiddleName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "full"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "short"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  locale</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  honorific</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (format </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (includeMiddleName </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.middleName) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (honorific </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> locale </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "ko"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...끝없는 분기</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>この状況に陥ったなら、解決策は明確だ。抽象化されたコードを各利用箇所にもう一度インライン化し、それぞれの箇所で不要なコードを取り除く。そしてきれいになった状態で本当の共通点が見えたら、そのとき改めて抽象化する。<strong>「最速で前に進む方法は、引き返すことだ。」</strong></p>
<p>では、抽象化すべきなのはどのようなときか。筆者が感じる<strong>抽象化の兆候</strong>は、おおよそ次のようなものだ。</p>
<ul>
<li><strong>一貫性が崩れている</strong>：同じロジックなのに、あるコンポーネントではインラインで書かれ、別のコンポーネントでは別関数に分離されている。同じ計算ロジックがあちこちに散らばっている。</li>
<li><strong>内部構造が外部に不必要に公開されている</strong>：外部が知る必要のない実装の詳細を、呼び出し側が一つずつ扱っている。</li>
<li><strong>自身の動作が露出し続けている</strong>：モジュールが内部の手順を隠せず、使う側がその手順をそのまま追わなければならない。</li>
</ul>
<p>問題は、こうした兆候を検知すること自体はたいてい単純なのに、<strong>実際にはその兆候を無視し、より「重要な」要件を満たすことに集中しがちだ</strong>という点である。スケジュールに追われたり、機能実装に没頭したりすると、「ひとまず動くから、後で整理しよう」となり、その「後で」はなかなか訪れない。</p>
<p>ここでもう一つ重要なのは、<strong>一貫した抽象化の基準を保つこと</strong>である。コードベース内で、同じ種類のロジックが、ある場所ではインライン、ある場所では関数、また別の場所ではカスタムフックとして分離されていれば、新しくコードを読む人は「この違いには意図があるのか」と混乱する。抽象化するにせよ、しないにせよ、チーム内の基準は一貫していなければならない。</p>
<p>Joel Spolskyが2002年に提唱した「<strong>漏れのある抽象化の法則（The Law of Leaky Abstractions）」</strong> も、この文脈で覚えておく価値がある。漏れのある抽象化の法則とは、抽象化は複雑な実装を隠そうとするが、その実装の詳細は結局、外へ漏れ出す（leak）というものだ。つまり、抽象化を使う人は内部実装を知らなくてもよいように設計したはずなのに、実際には、それを知らなければ正しく使えない状況が生じることを指す。</p>
<p>TCPは不安定なネットワークを安定した接続のように見せる形で抽象化するが、ケーブルが切れれば、その抽象化は崩れる。ReactはUIの更新を宣言的に抽象化するが、再レンダリングを最適化するには、結局その内部動作を理解しなければならない。完璧な抽象化は存在しないからこそ、抽象化するときには、<strong>「この抽象化が崩れたとき、利用者は対処できるか」</strong> まで考える必要がある。</p>
<p>結局、<strong>「抽象化は作業時間を節約してくれるが、学習時間を節約してはくれない。」</strong></p>
<h2 id="抽象化は体得するものだ"><a class="anchor" href="#抽象化は体得するものだ">抽象化は体得するものだ</a></h2>
<p>以前、同僚と抽象化について話したことがある。そのときに出た話が印象深かった。抽象化の兆候を捉え、適切なタイミングで適切なレベルに分離することは、結局のところ<strong>感覚の領域</strong>だという話だ。</p>
<p>もちろん、ここまで述べてきた原則、つまり抽象化レベルを揃え、適切な名前を付け、入力の自由度を設計することは、間違いなく重要である。しかし、実際にコードを書く瞬間にこれらの原則を一つひとつ思い浮かべ、「これを分離すべきか、しないべきか」と考えるやり方は、かえって流れを妨げることがある。試合中にジャブを打つとき、肘の角度を意識すると逆にタイミングを逃してしまうように、コードを書くときも、抽象化は意識的な判断ではなく、自然な感覚から生まれるべきなのだ。</p>
<p>コードを書き進める中で、何かに抵抗を感じる瞬間がある。「このロジックはここにあるべきではない気がする」「このコンポーネントはあまりに多くのことを知りすぎている気がする」という感覚だ。その感覚こそが抽象化の兆候であり、それを自然に捉えて反応できることが、体得するということである。</p>
<p>ただし、この感覚は一朝一夕には身につかない。数多くのパターンを学び、多様なコードを読み、自ら試行錯誤を重ねて初めて、<strong>「これは分離したほうがよさそうだ」</strong> という勘が自然に働き始める。後から同僚に「なぜこれを分離したの？」と聞かれたとき、「これは〇〇だから分離した」と自然に説明できるなら、それは体得できているということだ。</p>
<p>どの分野でも同じなのだと思う。暗記して上手くなろうとすると、かえって判断が難しくなる。結局は大きな流れだけを押さえ、細部は自然に埋まるようにしなければならない。そして、その自然さは、日頃から積み重ねてきた多様なパターンと経験から生まれるのだ。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>プログラミングにおける抽象化とは、複雑なものを隠して単純に見せ、コードを読む人が必要な文脈だけに集中できるようにする行為である。</p>
<p>良い抽象化のために覚えておきたいことを、もう一度確認すると次のようになる。</p>
<ul>
<li>基本的な前提として抽象化を急がず、明確な兆候が見えたときに初めて分離しよう。</li>
<li>一つの関数は、<strong>一つの抽象化レベル</strong>だけで語ろう。</li>
<li>名前と型で動作を十分に<strong>表現</strong>し、ソースコードを開かなくても使えるようにしよう。</li>
<li>入力の自由度を、モジュールの目的と利用者に合わせて<strong>意図的に設計</strong>しよう。</li>
<li>誤った抽象化の上に付け足すより、<strong>解きほぐしてやり直す勇気</strong>を持とう。</li>
<li>そして、これらすべてを意識しなくても自然にできるよう、<strong>多様なパターンを体得</strong>しよう。</li>
</ul>
<p>もちろん、この記事で筆者が述べたことが正解というわけではない。適切な抽象化レベルは、ビジネスの状況、チームの構成、プロジェクトの性質によって変わらざるを得ない。ただし、一つだけ変わらないものがあるとすれば、抽象化の究極の目的は、<strong>人にとって理解しやすいコードを作ること</strong>だという点である。</p>
<p>この記事を読んだ方にも、それぞれのコードベースで「この抽象化は本当に文脈を減らしているだろうか」と、一度問いかけてみてほしい。その問い一つだけでも、コードを見る視点が少し変わるはずだ。</p>
<h2 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h2>
<p>この記事は、複数の公式ドキュメントや先行記事から多くの着想を得た。直接引用した箇所の出典と、思考の枠組みを作る上で役立った記事を併せて記載する。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://evan-moon.github.io/2023/01/15/what-is-abstract/" target="_blank" rel="noopener noreferrer">Evan Moon, 抽象、そして抽象化</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">React, カスタムフックでロジックを再利用する</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>設計</category>
            <category>抽象化</category>
        </item>
        <item>
            <title><![CDATA[queryKey]]></title>
            <link>https://hooninedev.com/ja/260104</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/260104</guid>
            <pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、TanStack Query の queryKeyについて掘り下げてみたい。 TanStack Query を使いながら、queryKey をインライン配列のままにするか、定数オブジェクトやクエリキーファクトリーにまとめるか、v5 の queryOptions に移すかで悩んでいるフロントエンド開発者に向けた記事だ。最後まで読めば、それぞれの管理方法がどんな問題を解くために登場したのかがわ...]]></description>
            <content:encoded><![CDATA[<p>今回は、<strong>TanStack Query の queryKey</strong>について掘り下げてみたい。</p>
<p>TanStack Query を使いながら、queryKey をインライン配列のままにするか、定数オブジェクトやクエリキーファクトリーにまとめるか、v5 の <code>queryOptions</code> に移すかで悩んでいるフロントエンド開発者に向けた記事だ。最後まで読めば、それぞれの管理方法がどんな問題を解くために登場したのかがわかり、今のコードベースにどの段階が合うのかを判断する基準が得られる。</p>
<p>筆者は実務で TanStack Query を使う中で、<strong>queryKey の管理方法を何度も作り直してきた</strong>。最初はコンポーネント内に <code>['user', userId]</code> のような配列を直接書いていたが、無効化のたびに同じキーを複数箇所へ記述するうちにタイプミスが増え、<code>QUERY_KEYS</code> のような定数オブジェクトへ移行した。その後、TkDodo の記事を読んでクエリキーファクトリーパターンを採用し、しばらくして <code>@lukemorales/query-key-factory</code> ライブラリを導入した。そして v5 の登場を機に、今度は <code>queryOptions</code> を使う形へと再び作り直した。</p>
<h2 id="querykey-がなかった時代"><a class="anchor" href="#querykey-がなかった時代">queryKey がなかった時代</a></h2>
<p>本題に入る前に、一つ確認しておこう。今では <code>TanStack Query</code> や <code>SWR</code> といったライブラリを当たり前のように使っているが、これらがなかった時代には非同期データをどのように扱っていたのだろうか。</p>
<p>最も一般的だったのは、<code>useState</code>、<code>useEffect</code>、<code>fetch</code>、<code>axios</code> などを組み合わせる方法だろう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/users/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">userId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">err</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(err);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [userId]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>このコードの問題は明白だ。同じ <code>userId</code> を参照するコンポーネントがページ内に二つあるだけで、<strong>同一のリクエストが二度送られる。</strong> キャッシュがないからだ。また、ユーザーが別のページへ移動して戻ってくると、再び最初からフェッチする。データを取得したのが1秒前なのか1時間前なのかを判別できないため、「キャッシュ済みの値を表示しながらバックグラウンドで更新する」といった動作を再現するのも難しい。（独自のキャッシュシステムを導入すれば実現できるが、その管理はかなり厄介だと思う。）</p>
<p>この問題を解決するために登場したのが、Redux と redux-thunk（または redux-saga）の組み合わせだった。データ取得ロジックをサンクに切り出し、結果をストアへ保存しておけば、別のコンポーネントでも同じデータを再利用できた。しかし、その都度アクションタイプを定義し、リデューサーを書き、ローディング・成功・失敗の状態を自分で管理しなければならなかった。データを一つ取得するためだけに、膨大な定型コードが必要だった。（筆者はこの時期に実務を始め、「データを一つ取得するだけなのに、なぜ複数のファイルを作らなければならないのか」と疑問に思っていた。）</p>
<p>この流れの本質は、結局のところ「<strong>このリクエストが何のリクエストかを識別できて初めて、同じリクエストを繰り返さずに済む</strong>」ということだ。そして、その「何のリクエストか」を示す識別子こそが queryKey の正体である。</p>
<p>SWR と React Query（現在の TanStack Query）は、この問題に真正面から取り組んだ。「非同期リクエストには識別子が必要であり、識別子が同じならキャッシュを共有する」。この単純な原則一つで、先ほどの定型コードがすべて不要になった。</p>
<h2 id="querykey-の本質"><a class="anchor" href="#querykey-の本質">queryKey の本質</a></h2>
<p>では、queryKey とは正確には何だろうか。TanStack Query の公式ドキュメントでは、次のように定義されている。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>TanStack Query は、その中核において、クエリキーに基づいてクエリのキャッシュを管理する。クエリキーの最上位は配列でなければならない……クエリキーがシリアライズ可能で、かつ<strong>クエリのデータに対して一意である限り</strong>、使用できる。</p></div><div class="quote-original" lang="en"><p>At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level... As long as the query key is serializable, and <strong>unique to the query's data</strong>, you can use it.</p></div></blockquote>
<p>要点は二つある。<strong>シリアライズ可能であること、そしてそのデータに対して一意であること。</strong> 同じキーは同じデータを意味し、異なるデータには異なるキーが必要だ。この単純な規則が、キャッシュシステム全体の動作を決める。</p>
<p>さらに、もう一つ重要な点がある。<strong>queryKey は依存配列としての役割も担う。</strong> React の <code>useEffect</code> で依存配列が変わると副作用が再実行されるのと同じように、queryKey が変わると TanStack Query は自動的に新しいデータをフェッチする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><code>userId</code> が <code>'A'</code> の場合と <code>'B'</code> の場合では、queryKey が異なる。異なればキャッシュミスとなり、キャッシュミスならフェッチする。これは自動で行われる。この単純さのおかげで、「userId が変わったので再度フェッチする」というロジックを自分で書く必要がない。</p>
<p>では、レンダリングのたびに新しく作られる配列を、TanStack Query はどうやって同じキーだと見分けるのだろうか。配列の参照を比較するのではなく、queryKey を <code>hashKey</code> でシリアライズした文字列（<code>queryHash</code>）をキャッシュのキーとして使う。この関数はプレーンオブジェクトのキーを並べ替えながら <code>JSON.stringify</code> でシリアライズするので、オブジェクト内のキー順はハッシュに影響せず、配列の要素の順序は影響する。値が <code>undefined</code> のプロパティはシリアライズの過程で消えるため、<code>{ a: 1, b: undefined }</code> と <code>{ a: 1 }</code> は同じキーになる。この動作を実装コードとともに追った内容は <a href="/251230" target="_blank" rel="noopener noreferrer">queryKey比較の仕組み</a> に別途まとめておいた。</p>
<h2 id="querykey-の記述規則"><a class="anchor" href="#querykey-の記述規則">queryKey の記述規則</a></h2>
<p>queryKey がキャッシュの識別子であり依存配列でもあることを理解すれば、記述規則も自然に見えてくる。公式ドキュメントが推奨する規則を整理すると、次のようになる。</p>
<h3 id="常に配列で"><a class="anchor" href="#常に配列で">常に配列で</a></h3>
<p>文字列を渡しても動作はする（内部で配列へ変換される）。ただし、一貫性を保つため、最初から配列で記述する方がよい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 비권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, queryFn });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], queryFn });</span></span></code></pre></figure>
<h3 id="queryfn-が使う変数をすべて入れる"><a class="anchor" href="#queryfn-が使う変数をすべて入れる">queryFn が使う変数をすべて入れる</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 잘못된 예: userId가 쿼리키에 없다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 올바른 예</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><code>useEffect</code> の依存配列と同じ考え方だ。関数内で使う変数はすべてキー（＝依存関係）に含めなければならない。この規則を破ると、対象ユーザーが変わったにもかかわらず以前のユーザーのデータがそのまま表示されるような、追跡しにくい不具合が生じる。</p>
<h3 id="generic-から-specific-の順に"><a class="anchor" href="#generic-から-specific-の順に">generic から specific の順に</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 좋다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, todoId]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안 좋다 (순서가 뒤집혀 있음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[{ filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span></code></pre></figure>
<p>この順序が重要なのは、<strong>無効化</strong>に関係するからだ。TanStack Query の <code>invalidateQueries</code> は、既定では<strong>接頭辞一致</strong>で動作する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...], ['todos', 'detail', ...] 모두 매치된다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// list 쿼리만 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...]만 매치된다</span></span></code></pre></figure>
<p>キーをツリー構造として設計しておけば、「このドメインのすべてのデータを再取得する」から「この一つの項目だけを再取得する」まで、一行で表現できる。（初めて見たときは大したことではないように思えるが、一度設計を誤り、無効化の範囲が意図どおりに動かない経験をすると、その価値を痛感する。）</p>
<h2 id="querykey-管理の変遷"><a class="anchor" href="#querykey-管理の変遷">queryKey 管理の変遷</a></h2>
<p>ここまでは queryKey の動作原理と使い方を見てきた。ここからは、<strong>queryKey の管理方法がどのように変化してきたのか</strong>という問いに移ろう。</p>
<p>筆者が実務でたどってきた段階を、時系列で整理する。</p>
<h3 id="インライン配列"><a class="anchor" href="#インライン配列">インライン配列</a></h3>
<p>最も単純な形式だ。コンポーネント内で固定文字列とプロパティの値を組み合わせる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PostList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PostFilter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, filter],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPosts</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filter),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>使い始めの段階では、これでも十分だ。</p>
<p>問題は、コードベースが大きくなるにつれて現れる。ユーザー情報を更新するミューテーションで無効化を行いたくても、「ユーザー関連のクエリキーは何だったか」を毎回検索しなければならない。ある箇所では <code>['user', userId]</code>、別の箇所では <code>['users', userId]</code>（複数形）と書かれることも起きる。両者はまったく別のキャッシュスロットなので、無効化は片方にしか適用されない。</p>
<h3 id="定数オブジェクト"><a class="anchor" href="#定数オブジェクト">定数オブジェクト</a></h3>
<p>タイプミスを防ぐため、クエリキーを定数として一か所にまとめる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> QUERY_KEYS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  USER: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  POSTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  COMMENTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용처</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QUERY_KEYS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">USER</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>タイプミスはなくなる。しかし、キーを組み立てる責任は依然として利用側に残る。<code>[QUERY_KEYS.USER, userId]</code> という組み合わせを、ある人は <code>[QUERY_KEYS.USER, userId, 'detail']</code> と書き、別の人は <code>['user', 'detail', userId]</code> と書く。どれが正しいか、別途規約として覚えなければならない段階が訪れる。</p>
<h3 id="クエリキーファクトリー"><a class="anchor" href="#クエリキーファクトリー">クエリキーファクトリー</a></h3>
<p>このパターンは、TkDodo の<a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">効果的な React Query のキー</a>という記事で具体化された。ドメインごとにキーを生成するオブジェクトを定義し、階層構造を関数で表現する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// features/todos/queries.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  all: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.all, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), { filters }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.all, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), queryFn: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), queryFn: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.all });        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 전체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });    </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 리스트</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) });  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p>このパターンが強力なのは、<strong>階層構造がコード上に明示されるから</strong>だ。<code>todoKeys.all</code> は todos に関するすべてのクエリ、<code>todoKeys.lists()</code> はすべてのリスト形式のクエリ、<code>todoKeys.detail(1)</code> は特定の項目を指す。無効化の範囲を一行のコードで正確に表現できる。</p>
<p>もう一つの利点は、<strong>コロケーション</strong>だ。TkDodo は、キーをグローバルなファイルへまとめることを推奨していない。代わりに、機能ディレクトリ内へ <code>queries.ts</code> を置き、その中にキーとフックをまとめる。</p>
<pre><code>src/
└── features/
    └── todos/
        ├── index.tsx
        └── queries.ts   # 키와 훅을 모두 여기에
</code></pre>
<p>こうすることで、「todos を変更するなら todos フォルダだけ見ればよい」という単純なメンタルモデルができる。ともに変化するものを同じ場所に置く、という原則を忠実に実践した形だ。</p>
<h3 id="lukemoralesquery-key-factory"><a class="anchor" href="#lukemoralesquery-key-factory">@lukemorales/query-key-factory</a></h3>
<p>3番目のパターンを毎回手作業で書いていると、定型コードが増えていく。また、複数ドメインのキーを統合して管理したい場合、標準化されたインターフェースが欲しくなる。<a href="https://github.com/lukemorales/query-key-factory" target="_blank" rel="noopener noreferrer">@lukemorales/query-key-factory</a> は、このパターンをライブラリとして実装したものだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createQueryKeys, mergeQueryKeys } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@lukemorales/query-key-factory'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [{ filters }],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todos</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> mergeQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(users, todos);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.todos.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users._def);            </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 user 쿼리</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p><code>createQueryKeys</code> は接頭辞を自動的に付け、<code>mergeQueryKeys</code> を使えば複数のドメインを統合できる。また、<code>_def</code> という所定のプロパティからドメイン全体のキーへアクセスできる。手書きのファクトリーで毎回 <code>as const</code> を付け、型を自分で絞り込んでいた作業が不要になる。</p>
<p>このライブラリは、しばらくの間、事実上の標準として使われていた。（筆者も長い間愛用していた。）しかし、queryOptions の登場によって状況が変わった。</p>
<h3 id="queryoptionsv5-公式"><a class="anchor" href="#queryoptionsv5-公式">queryOptions（v5 公式）</a></h3>
<p>TanStack Query v5 における最も重要な変更の一つが、<code>queryOptions</code> API の導入だ。v4 から v5 への移行で、すべてのフックの引数が単一のオブジェクトに統一された。この変更の真の目的は、そのオブジェクトを<strong>再利用可能な単位</strong>として切り出せるようにすることだった。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> userDetailOptions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 어디서나 사용 가능</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSuspenseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">prefetchQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey, newUser);</span></span></code></pre></figure>
<p>一見すると、「何が違うのか。単にオブジェクトを関数で包んだだけではないか」と思うかもしれない。TkDodo も<a href="https://tkdodo.eu/blog/the-query-options-api" target="_blank" rel="noopener noreferrer">クエリオプション API</a>という記事で、その点を認めている。実行時には、本当に受け取ったオブジェクトをそのまま返すだけだ。</p>
<p>本当に重要な働きは、<strong>型システムの中で</strong>生じる。続けて見ていこう。</p>
<h2 id="queryoptions-の-datatag"><a class="anchor" href="#queryoptions-の-datatag">queryOptions の DataTag</a></h2>
<p><code>queryOptions</code> が単なる補助関数ではない理由は、<strong>返された queryKey にデータ型の情報を埋め込むから</strong>だ。この仕組みは、TanStack Query の内部で <code>DataTag</code> と呼ばれている。</p>
<p>概略的な実装は、次のとおりだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">declare</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dataTagSymbol</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unique</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> symbol</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">declare</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dataTagErrorSymbol</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unique</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> symbol</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DataTag</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  [dataTagSymbol]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  [dataTagErrorSymbol]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p><code>unique symbol</code> を使った<strong>ブランド型</strong>だ。実行時には何の影響もない印にすぎないが、TypeScript から見ると、「この配列は単なる配列ではなく、<code>TValue</code> 型のデータに紐づいた配列である」という情報を持っている。</p>
<p>ここで、あえて <code>unique symbol</code> を使うのには理由がある。Zenn の <a href="https://zenn.dev/tsuboi/articles/tanstack-query-options-unique-symbol?locale=en" target="_blank" rel="noopener noreferrer">DataTag の背後にある unique symbol を解き明かす</a>という記事では、この仕組みを「型情報専用の駐車スペース」にたとえている。通常の文字列キーなら、別のライブラリやユーザーコードのキーと衝突する可能性がある。しかし、<strong>各 <code>unique symbol</code> 宣言はそれ自体が一意な型を作るため</strong>、ほかのどの宣言とも同じ型にはならない。絶対に衝突しない識別子になるわけだ。</p>
<p>この一つの仕組みが生む違いは大きい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// unknown</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// User | undefined</span></span></code></pre></figure>
<p><code>getQueryData</code> や <code>setQueryData</code> は queryKey 一つしか受け取らない。しかし、その queryKey にデータ型がすでに刻み込まれているため、返り値の型が自動的に推論される。ジェネリック型を明示的に渡す必要がなく、誤った型の値を <code>setQueryData</code> に渡そうとすれば、コンパイラが即座に検出する。</p>
<p>もちろん、制約もある。<code>getQueriesData</code> のように複数のクエリを一度に取得するメソッドでは、結果が異種のタプルからなる配列になるため、型推論は適用されない。また、<code>unique symbol</code> を使用するため、<code>.d.ts</code> の生成時にモノレポ環境で TS4023 エラーが発生することがある。この場合は、<code>dataTagSymbol</code> を明示的に import することで回避できる。</p>
<p>ここまでの仕組みを整理すると、一つの事実が明確になる。<strong>queryOptions の型推論は、queryKey と queryFn が同じ場所で一緒に宣言されていることに全面的に依存する。</strong> queryFn の返り値の型を queryKey に刻み込むには、両者が同じ場所で宣言されていなければならないからだ。</p>
<p>この点は、クエリキーファクトリーの設計方針に重要な示唆を与える。以前の世代のパターンは、queryKey の管理を独立した抽象化単位として切り離すことを重視していた。ところが、v5 の推奨は正反対だ。<strong>queryKey と queryFn を一つの単位として再びまとめること</strong>である。TkDodo はこれについて、「queryKey と queryFn を分離したのは誤りだった」とまで述べている。結局、キーは関数が使う依存関係の集合であり、両者は切り離せない関係にあるからだ。</p>
<h2 id="実務で使う-queryoptions-の合成パターン"><a class="anchor" href="#実務で使う-queryoptions-の合成パターン">実務で使う queryOptions の合成パターン</a></h2>
<p><code>queryOptions</code> の真価は、ドメイン別のファクトリーと組み合わせたときに発揮される。v5 の公式ドキュメントが推奨する形は、次のとおりだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoQueries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodoFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), filters],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodos</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">30</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>このパターンが優れている理由を、一つずつ見ていこう。</p>
<h3 id="階層構造と型推論"><a class="anchor" href="#階層構造と型推論">階層構造と型推論</a></h3>
<p><code>todoQueries.all()</code> や <code>todoQueries.lists()</code> は単に配列を返すが、<code>todoQueries.detail(1)</code> は <code>queryOptions</code> によって作られた、データタグ付きのオブジェクトを返す。無効化には配列を、クエリの呼び出しにはオプションオブジェクトを使えばよい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));                                </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 옵션 객체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() }); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배열</span></span></code></pre></figure>
<h3 id="コンポーネントごとのオプション上書き"><a class="anchor" href="#コンポーネントごとのオプション上書き">コンポーネントごとのオプション上書き</a></h3>
<p><code>queryOptions</code> の結果は最終的にはオブジェクトなので、呼び出す時点で一部のオプションを合成できる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">title</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  select</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">todo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todo.title,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 컴포넌트별로 다른 select 적용</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>このパターンが特に強力なのは、<code>select</code> の返り値の型が自動的に推論され、<code>data</code> の型が <code>string</code> に絞り込まれる点だ。コンポーネント側では必要な部分だけを選んで使いながら、ドメインの定義は一か所に保てる。</p>
<h3 id="減っていく-usequery-のカスタムフック"><a class="anchor" href="#減っていく-usequery-のカスタムフック">減っていく <code>useQuery</code> のカスタムフック</a></h3>
<p>v4 の頃は、ドメインごとにカスタムフックを作るのが一般的なパターンだった。</p>
<p>この方法の問題は、<strong>プリフェッチが必要になった瞬間、同じ定義をもう一度書かなければならないこと</strong>だった。<code>useTodoDetail</code> はフックなのでコンポーネントの外では呼べない。そのため、ルーターのローダーやイベントハンドラーでは、再び <code>queryClient.prefetchQuery({ queryKey: [...], queryFn: ... })</code> と書く必要があった。</p>
<p><code>queryOptions</code> を使えば、この重複はなくなる。</p>
<p>同じ一つの定義が、どこでも機能する。そのため TkDodo は、「v5 ではフックを作るより queryOptions を定義する」ことを推奨している。フックは必要なときだけ薄くラップするための道具となり、ドメインの定義はフックがなくても自立して存在できる。</p>
<h2 id="ミューテーションの無効化"><a class="anchor" href="#ミューテーションの無効化">ミューテーションの無効化</a></h2>
<p>queryKey の階層構造が真価を発揮するのは、ミューテーション後の無効化だ。TanStack Query の<a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-invalidation" target="_blank" rel="noopener noreferrer">クエリの無効化</a>に関するドキュメントによると、<code>invalidateQueries</code> は既定で<strong>接頭辞一致</strong>を行う。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 (list, detail, lists 모두)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 list만 (detail은 건드리지 않음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 정확히 이 키만 (자식 키 매치 안 함)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  exact: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 더 복잡한 조건은 predicate으로</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  predicate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    query.queryKey[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'todos'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (query.queryKey[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)?.version </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>キーが階層的に設計されていれば、<strong>無効化の範囲とコードの意味が一致する。</strong> 「todos をすべて更新する」は <code>all()</code>、「リストだけ更新する」は <code>lists()</code>、「この項目だけ更新する」は <code>detail(id)</code> で表現できる。</p>
<p>もしキーが <code>['todoList']</code>、<code>['todoDetail', 1]</code> のように平面的に散らばっていたら、「todos ドメイン全体」を無効化するために二つの呼び出しを別々に記述するか、専用の接頭辞定数を作って管理しなければならなかっただろう。（そして新しいドメインキーを追加するたびに、その定数への追加を忘れると、無効化から漏れる不具合が発生する。）</p>
<h2 id="queryfn-内で-querykey-を取り出して使う"><a class="anchor" href="#queryfn-内で-querykey-を取り出して使う">queryFn 内で queryKey を取り出して使う</a></h2>
<p>最後に、もう一つ触れておきたいパターンがある。<code>queryFn</code> は実際には <code>QueryFunctionContext</code> というオブジェクトを引数として受け取り、その中には呼び出し時の queryKey がそのまま含まれている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId, { include: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'profile'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">options</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryKey;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>このパターンはなぜ役立つのだろうか。TkDodo の<a href="https://tkdodo.eu/blog/leveraging-the-query-function-context" target="_blank" rel="noopener noreferrer">クエリ関数コンテキストを活用する</a>によれば、<strong>queryKey と queryFn の依存関係を強制的に同期できるから</strong>だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> sortBy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'name'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>このコードは、queryFn が外部変数に依存している危険なコードだ。しかも、<code>sortBy</code> が変わってもキャッシュは更新されない。依存関係をキーに含めていないからだ。しかし、<code>queryFn</code> が外部のクロージャから変数を取り込む限り、このようなミスはいつでも起こり得る。</p>
<p>解決策は単純だ。<code>queryFn</code> が外部変数に依存しないようにする。<strong>すべての依存関係を queryKey から取り出して使えば</strong>、queryKey に含まれていない変数は、そもそも関数内で使えなくなる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { sortBy }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [, { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sortBy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>このように書けば、新しい依存関係が増えたとき、queryKey に含めずに関数内で使うことはできない。コンパイラが「そのキーは存在しない」と検出してくれる。キーと関数の同期を規約ではなく、<strong>型システムに委ねる</strong>わけだ。</p>
<h2 id="どこまで分離するべきか"><a class="anchor" href="#どこまで分離するべきか">どこまで分離するべきか</a></h2>
<p>ここまで読むと、一つ疑問が浮かぶかもしれない。「では、すべてのクエリを <code>queryOptions</code> に切り出すべきなのか」</p>
<p>筆者の答えは、いつもどおり「<strong>状況による</strong>」だ。</p>
<p>覚えておきたいのは、<strong>抽象化が常によいとは限らないこと</strong>だ。一度しか使わないクエリをわざわざドメインファクトリーへ切り出すと、コードを読む人が二つのファイルを行き来するだけになる。queryKey 管理パターンの進化は、「常により精巧な道具を使うべきだ」という意味ではない。「<strong>必要になったとき、はしごを一段ずつ上る選択肢がある</strong>」と捉えるのがよい。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、queryKey は、<strong>TanStack Query が非同期データを識別してキャッシュするための最も基本的な単位</strong>だ。その小さな配列には、キャッシュスロットの識別子、依存配列、無効化の範囲、そして v5 ではデータ型の情報までが凝縮されている。一点にこれほど多くの責務が集まっているからこそ、どのように記述し、どのように管理するかが、コードベース全体の認知負荷に直結する。</p>
<p>各段階は、その時点で誰かが直面した現実の問題に対する答えだった。だから、単に「今は v5 だから、常に <code>queryOptions</code> だけを使えばよい」のではなく、「<strong>自分のコードベースは今、どの段階の問題に直面しているのか</strong>」を先に見極めるのが正しい順序だ。インライン配列で十分なプロジェクトへドメインファクトリーを導入することは、それ自体が過剰設計になり得る。</p>
<p>読者の皆さんも、自分のプロジェクトで一度確認してみてほしい。queryKey がコード全体にどのように散らばっているか、無効化がどのように行われているか、そしてその構造が現在のチーム規模とドメインの複雑さに合っているかを。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query：クエリキー</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-options" target="_blank" rel="noopener noreferrer">TanStack Query：クエリオプション</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <a href="https://tanstack.com/query/v5/docs/framework/react/typescript" target="_blank" rel="noopener noreferrer">TanStack Query：TypeScript</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[記事] <a href="https://tanstack.com/blog/announcing-tanstack-query-v5" target="_blank" rel="noopener noreferrer">TanStack：TanStack Query v5 の発表</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[queryKey比較の仕組み]]></title>
            <link>https://hooninedev.com/ja/251230</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/251230</guid>
            <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、TanStack Query が二つの queryKey を同じキーと判定する仕組みについて話してみたい。 レンダリングのたびに新しい配列として作られる queryKey がなぜ毎回キャッシュミスにならないのか、オブジェクトのキー順や undefined の値がキャッシュに影響するのかが気になっていた TanStack Query ユーザーに向けた記事で、読み終えればキーを同じと見なす規則...]]></description>
            <content:encoded><![CDATA[<p>今回は、<strong>TanStack Query が二つの queryKey を同じキーと判定する仕組み</strong>について話してみたい。</p>
<p>レンダリングのたびに新しい配列として作られる queryKey がなぜ毎回キャッシュミスにならないのか、オブジェクトのキー順や <code>undefined</code> の値がキャッシュに影響するのかが気になっていた TanStack Query ユーザーに向けた記事で、読み終えればキーを同じと見なす規則と、その規則がキャッシュに与える影響が分かる。結論から言うと、TanStack Query は queryKey を <code>hashKey</code> でシリアライズした文字列をキャッシュのキーとして使い、その過程でオブジェクトのキー順は無視され、配列の要素の順序はそのまま残る。</p>
<p>queryKey は、TanStack Query がクエリキャッシュを管理する基準となる配列だ。同じキーは同じデータを意味し、<code>['user', userId]</code> の <code>userId</code> が変わってキーが変わるとキャッシュミスが起き、改めて fetch する。</p>
<h2 id="querycache-の内部"><a class="anchor" href="#querycache-の内部">QueryCache の内部</a></h2>
<p>TkDodo の <a href="https://tkdodo.eu/blog/inside-react-query" target="_blank" rel="noopener noreferrer">React Query の内部</a>によれば、<code>QueryCache</code> は結局のところ、<strong>メモリ上に保持される一つのデータ構造</strong>にすぎない。v5 の<a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/queryCache.ts" target="_blank" rel="noopener noreferrer">公式実装</a>で使われているデータ構造は、プレーンオブジェクトではなく <code>Map&#x3C;string, Query></code> だ。フィールドの型は <code>QueryStore</code> で、コンストラクターで <code>new Map&#x3C;string, Query>()</code> を代入する。保存と検索は <code>queryHash</code> をキーにして行う。キーは queryKey をシリアライズした形式（<code>queryHash</code>）、値は <code>Query</code> クラスのインスタンスである。この記事のコードと実行結果は <code>@tanstack/query-core</code> 5.104.1 を基準にしている。</p>
<p>古いバージョンで使われていたプレーンオブジェクトと違い、<code>Map</code> はプロトタイプから継承したキーとぶつかることがない。</p>
<p><code>useQuery</code> が呼ばれるたびに起こることは単純だ。<strong>queryKey をハッシュ値へ変換し、そのハッシュ値を使って Map を検索する。</strong> 存在すればキャッシュ済みの <code>Query</code> インスタンスを取得し、なければ新しく作成して <code>set</code> する。</p>
<p>ここで自然に次の疑問が生まれる。<strong>なぜわざわざ queryKey を文字列へシリアライズするのか。</strong> <code>Map&#x3C;QueryKey, Query></code> のように配列自体をキーとして使えばよいのではないか。</p>
<p>その答えは、JavaScript の等価性モデルにある。ネイティブの <code>Map</code> はキーを<strong>参照等価性</strong>で比較する。内容が同じでも、メモリ上で別のオブジェクトなら異なるキーとして扱われる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> m</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'alice'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined. 새로 만든 배열은 다른 참조다</span></span></code></pre></figure>
<p>ところが、React コンポーネントで <code>useQuery({ queryKey: ['user', userId] })</code> と記述すると、<strong>レンダリングのたびに新しい配列インスタンスが作られる。</strong> 最初のレンダリングと二度目のレンダリングで使われる queryKey 配列は、内容が同じでもメモリ上では別のオブジェクトだ。もしキャッシュが参照等価性に依存していたら、同じデータを参照するコンポーネントがレンダリングのたびにキャッシュミスを起こしていただろう。</p>
<p>参照等価性によって生じる問題の解決策は単純だ。<strong>参照等価性を構造的等価性へ変換すること</strong>である。queryKey の内容だけから決定論的な文字列を作り、その文字列を Map のキーとして使う。そうすれば、「内容が同じなら同じキー」という期待どおりの意味論を取り戻せる。<code>JSON.stringify</code> は、その変換を行う最も単純な手段にすぎない。</p>
<h2 id="hashkey-のキーの並べ替え"><a class="anchor" href="#hashkey-のキーの並べ替え">hashKey のキーの並べ替え</a></h2>
<p>ハッシュ値を作る関数は <code>hashKey</code> だ。<a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/utils.ts#L284-L295" target="_blank" rel="noopener noreferrer"><code>packages/query-core/src/utils.ts</code></a> に定義された公式実装は、次のようになっている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MutationKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">val</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    isPlainObject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">result</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            result[key] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val[key]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">            return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          }, {} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>JSON.stringify</code> に<a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter" target="_blank" rel="noopener noreferrer">置換関数のコールバック</a>を挟み、<strong>プレーンオブジェクトのキーを辞書順に並べ替えて</strong>からシリアライズする。正確には <code>sort()</code> の既定の比較である UTF-16 コード単位順なので、大文字のキーが小文字のキーより前に来る。</p>
<p>この並べ替えが本質的なのは、文字列へのシリアライズには、さらに厳しい条件が伴うためだ。<strong>意味が同じ入力は、常に同じ文字列へ変換されなければならない。</strong> しかし、通常の <code>JSON.stringify</code> はキーの順序をそのまま維持する。<code>{ a: 1, b: 2 }</code> と <code>{ b: 2, a: 1 }</code> は意味上は同じオブジェクトなのに、異なる文字列へシリアライズされ、最終的に別々のキャッシュスロットとなる。その結果、同じデータを二度リクエストする事態が再び起きてしまう。</p>
<p>これを一貫して防ぐ手法が、<strong>正準形</strong>だ。意味上同じ入力が、常に一意な一つの表現に対応するよう強制する。<code>hashKey</code> の置換関数がプレーンオブジェクトのキーを並べ替える理由は、これだ。どの順序で入力されても出力が同じになるようにして、意味が同じオブジェクトが常に同じ文字列になるようにする。逆方向は保証されないという点は後で扱う。</p>
<p>配列を並べ替えないのも、同じ原理の裏返しだ。配列は順序そのものに意味があるデータ構造なので、並べ替えると情報が失われる。オブジェクトのキー順は偶然だが、配列の要素順は意図である。<code>hashKey</code> は両者を区別して扱う。メンテナーの TkDodo が <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a> で、queryKey を最も汎用的なものから最も具体的なものの順に構成するよう勧めているのも、配列の順序が意味を持つからだ。彼が挙げる理由は無効化である。先頭部分が同じキーを <code>['todos']</code> 一つでまとめて無効化できる。この比較を担うのはハッシュではなく、後で見る prefix マッチングだ。</p>
<p>キーの並べ替えが適用されるのは、<strong>プレーンオブジェクト</strong>だけだ。同じファイル内の <code>isPlainObject</code> は、<code>Object.prototype.toString</code> の結果が <code>[object Object]</code> かどうかと、プロトタイプが <code>Object.prototype</code>（または <code>null</code>）かどうかを検査し、<strong>純粋なオブジェクトリテラル</strong>と<strong>クラスのインスタンス</strong>を区別する。そのため、<code>{ foo: 1 }</code> のようなリテラルは並べ替えられる一方、<code>class User { ... }</code> で作成したインスタンスは並べ替えられず、そのまま処理される。クラスのインスタンスをそのまま queryKey に入れると、キーが並べ替えられないのでフィールドを代入した順にシリアライズされ、値が同じでも異なるハッシュになることがある。</p>
<p>使う側から見ると、結果は二つある。</p>
<h3 id="オブジェクトのキー順は問わない"><a class="anchor" href="#オブジェクトのキー順は問わない">オブジェクトのキー順は問わない</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 같은 캐시 슬롯을 공유한다</span></span></code></pre></figure>
<p>キーの並べ替えがなければ、オブジェクトリテラルを使うたびにキーの順序を覚えておかなければならなかっただろう。</p>
<h3 id="配列の要素順は重要である"><a class="anchor" href="#配列の要素順は重要である">配列の要素順は重要である</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status, page], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page, status], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 다른 캐시이다</span></span></code></pre></figure>
<h2 id="シリアライズが変える値"><a class="anchor" href="#シリアライズが変える値">シリアライズが変える値</a></h2>
<p>また、<code>undefined</code> 値はシリアライズの過程で消えることも覚えておくとよい。<code>{ a: 1, b: undefined }</code> と <code>{ a: 1 }</code> は同じハッシュ値になる。（筆者はこのことを知らず、「undefined を明示的に入れたのだから別のキャッシュだ」と考えてしまったことがある。）</p>
<p>配列の中では挙動が異なる。配列要素の <code>undefined</code> は消えずに <code>null</code> になる。そのため <code>['user', undefined]</code> と <code>['user', null]</code> は同じキーで、<code>['user']</code> とは別のキーだ。<code>['user', userId]</code> の <code>userId</code> がまだ <code>undefined</code> の場合がこれに当たる。<code>enabled: false</code> で fetch を止めておいても、キャッシュには <code>["user",null]</code> のスロットが作られる。<code>useQuery</code> が内部で使う <code>QueryObserver</code> を同じオプションで作って確認した。</p>
<p><code>undefined</code> だけではない。<code>hashKey</code> は <code>JSON.stringify</code> の上に成り立っているので、JSON が表現できない値はほとんどがエラーなしで別の値に変わる。以下のコードを 2026-10-08 に <code>@tanstack/query-core</code> 5.104.1、Node v24.16.0 で実行した。コメントが実際の出力だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hashKey, QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user"]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'f'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cb</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["f",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["m",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'d'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'2025-12-30T00:00:00Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["d","2025-12-30T00:00:00.000Z"]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'mapA'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'b'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// mapA</span></span></code></pre></figure>
<p>同じ方法でほかの値も実行すると、次のようにまとまる。</p>
<table>
<thead>
<tr>
<th>キーに入れた値</th>
<th>シリアライズ結果</th>
<th>同じキーとして扱われるもの</th>
</tr>
</thead>
<tbody>
<tr>
<td>配列要素の <code>undefined</code>、<code>NaN</code>、<code>Infinity</code>、関数</td>
<td><code>null</code></td>
<td>その位置に <code>null</code> を入れたキー</td>
</tr>
<tr>
<td>オブジェクトのプロパティの <code>undefined</code>、関数</td>
<td>プロパティが消える</td>
<td>そのプロパティがないキー</td>
</tr>
<tr>
<td><code>Map</code>、<code>Set</code></td>
<td><code>{}</code></td>
<td>中身に関係なくすべての <code>Map</code>、<code>Set</code>、空オブジェクト</td>
</tr>
<tr>
<td><code>Date</code></td>
<td>ISO 文字列</td>
<td>同じ ISO 文字列</td>
</tr>
<tr>
<td><code>BigInt</code></td>
<td><code>TypeError</code> を投げる</td>
<td>なし</td>
</tr>
<tr>
<td>循環参照</td>
<td>循環がプレーンオブジェクトだけでつながれば <code>RangeError</code>、配列かクラスのインスタンスを経由すれば <code>TypeError</code> を投げる</td>
<td>なし</td>
</tr>
</tbody>
</table>
<p>最も危険なのは <code>Map</code> と <code>Set</code> だ。上のコードでは、<code>new Map([['a', 1]])</code> をキーにして入れたデータが <code>new Map([['b', 2]])</code> で取り出せた。エラーがないので、画面に別のデータを描いていることに気づく手がかりもない。</p>
<p>エラーとして表に出るのは <code>BigInt</code> と循環参照の二つだけだ。循環参照は、何が循環しているかによってエラーが分かれる。循環がプレーンオブジェクトだけでつながっていれば <code>RangeError: Maximum call stack size exceeded</code> で終わり、<code>arr.push(arr)</code> のように配列かクラスのインスタンスを一つでも経由すれば <code>TypeError: Converting circular structure to JSON</code> で終わる。置換関数がプレーンオブジェクトごとに新しいオブジェクトを作って返すため、<code>JSON.stringify</code> の循環検出は同じオブジェクトに再び出会えない。一方、配列とクラスのインスタンスは置換関数がそのまま返すので循環検出に引っかかる。逆に <code>Date</code> は <code>toJSON</code> で ISO 文字列になるので、キャッシュの検索では同じ時刻なら同じキーになり、比較的安全だ。</p>
<p>筆者は一度、<code>Date</code> をそのまま入れておいて「同じ時点なのに、なぜキャッシュが更新されるのか」と長いあいだ悩んだことがある。同じ時刻を指す <code>Date</code> は、インスタンスが違っても同じ ISO 文字列になるので同じハッシュを作る。毎回違うキーが出ていたなら、同じ時点に見えても時刻そのものが違っていたということだ。レンダリング中に <code>new Date()</code> を作ると、レンダリングごとにミリ秒単位で異なる時刻が入り、そのたびに新しいキーになる。</p>
<p>そのため、queryKey には文字列、数値、真偽値、<code>null</code> と、それらからなる配列とプレーンオブジェクトだけを入れるのが安全だ。</p>
<h2 id="querykeyhashfn"><a class="anchor" href="#querykeyhashfn">queryKeyHashFn</a></h2>
<p>この制約には逃げ道がある。TanStack Query は <code>queryKeyHashFn</code> というオプションで、<strong>ハッシュ関数自体を差し替えられる</strong>ようにしている。内部では <code>hashQueryKeyByOptions(queryKey, options)</code> が、オプションに <code>queryKeyHashFn</code> があればそれを、なければ既定の <code>hashKey</code> を呼ぶように分岐している。</p>
<p>差し替えるとは、<code>hashKey</code> を丸ごと置き換えるという意味だ。先に見たキーの並べ替えも一緒に消えるので、並べ替えが必要なら自分で実装しなければならない。このオプションが役に立つのは、<code>BigInt</code> のように既定のシリアライズが例外を投げる値だ。以下のコードも同じ環境で実行した。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bigintSafeHash</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'bigint'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  defaultOptions: { queries: { queryKeyHashFn: bigintSafeHash } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">})</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'9007199254740993'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined</span></span></code></pre></figure>
<p>二つ目の出力は、<code>BigInt</code> と同じ数を持つ文字列が同じキーになるという意味だ。最後の出力はキーの並べ替えが消えた結果で、キーの順序だけが違うオブジェクトをもう同じキーとは見なさない。</p>
<p>登録する場所も結果を変える。上のように <code>QueryClient</code> の <code>defaultOptions</code> や <code>setQueryDefaults</code> で登録すると、<code>setQueryData</code> と <code>getQueryData</code> もその関数を使う。二つの API がハッシュする前に <code>defaultQueryOptions</code> で既定のオプションをマージするためだ。一方、<code>useQuery</code> の呼び出しにだけ書くと命令型 API は既定の <code>hashKey</code> を使い、同じキーがキャッシュの中で二つのスロットに分かれる。v3.2.0 のベータ版の頃にはグローバルの既定値も <code>setQueryData</code> に適用されなかったが、<a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">Issue #1343</a> の報告者が v3.2.0-beta.30 で直ったことを確認した。</p>
<p>そのため実務では、逃げ道を使うよりも <strong>queryKey を作る時点でシリアライズ可能な形に変換して入れるほう</strong>がはるかに安全だ。ハッシュ関数を自分で書くと、キーの並べ替えと登録場所の両方を気にかけなければならないからだ。</p>
<h2 id="フィルターのキー比較"><a class="anchor" href="#フィルターのキー比較">フィルターのキー比較</a></h2>
<p>二つのキーを同じと判定する方法はもう一つある。ここまで述べた「同じキー」とはハッシュ文字列が同じという意味で、ハッシュが使われるのはキャッシュの検索と <code>exact: true</code> のフィルターだ。<code>invalidateQueries</code> や <code>findAll</code> のようなフィルターは既定で <code>partialMatchKey</code> によって判定し、この関数はハッシュ文字列ではなく、元の queryKey の構造を再帰的に比較する。配列は先頭から照合し、オブジェクトはフィルター側に書かれたキーだけを見る。以下のコードも同じ環境で実行した。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { partialMatchKey } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [{ status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todo'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span></code></pre></figure>
<p>ハッシュを通らないので、<code>queryKeyHashFn</code> を差し替えてもこのマッチングは変わらない。先の例のように並べ替えのないハッシュ関数を登録した <code>QueryClient</code> では、キーの順序だけが違うオブジェクトを <code>exact: true</code> では見つけられないが、prefix マッチングでは見つけられる。</p>
<p>そのため、ハッシュで同じキーがフィルターでも同じになるという保証はない。<code>['user', undefined]</code> で作ったクエリは <code>['user', null]</code> フィルターの prefix マッチングに引っかからず、<code>NaN</code> を含むキーは自分自身ともマッチしない。逆に <code>Date</code> や <code>Map</code> をフィルターに入れると、列挙するプロパティがないため、同じ位置にあるどの <code>Date</code> やオブジェクトともマッチする。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、TanStack Query は queryKey 配列の参照を比較しない。<code>hashKey</code> がプレーンオブジェクトのキーを並べ替えながら <code>JSON.stringify</code> で作った文字列（<code>queryHash</code>）を <code>Map</code> のキーとして使う。そのため、オブジェクトのキー順はキャッシュに影響せず、配列の要素の順序は影響し、値が <code>undefined</code> のプロパティはハッシュでは存在しないのと同じになる。JSON が表現できない値はほとんどがエラーなしで別の値に変わり、異なるキーが静かに同じキーになる。<code>queryKeyHashFn</code> でハッシュ関数を替えることはできるが、キーの並べ替えも一緒に捨てることになるので、キーを作る時点でシリアライズ可能な値に変換して入れるほうが安全だ。無効化のようなフィルターは既定でハッシュを通さず、queryKey の構造を先頭から照合するので、ハッシュで同じキーがフィルターでも同じだと期待しないほうがよい。結局、キャッシュの検索は <code>hashKey</code> でキーの中身を比べ、フィルターはキーの先頭部分を比べるので、queryKey にはシリアライズしても意味が変わらない単純な値だけを入れれば、二つの比較がどちらも期待どおりに動く。</p>
<p>この判定基準が queryKey をどう書き、どう管理するかにつながる話、つまりインライン配列からクエリキーファクトリーを経て <code>queryOptions</code> に至った流れは <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a> で扱う。</p>
<p>読者の皆さんも、次に queryKey にオブジェクトや <code>Map</code> を入れるとき、その値がどんな文字列にシリアライズされるのかを一度思い浮かべてみてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query：クエリキー</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <a href="https://tanstack.com/query/latest/docs/framework/react/reference/classes/QueryClient" target="_blank" rel="noopener noreferrer">TanStack Query：QueryClient</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[ErrorBoundary の配置]]></title>
            <link>https://hooninedev.com/ja/251203</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/251203</guid>
            <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、ErrorBoundary をいくつ置き、どこに置くのかについて話してみたい。React Router と TanStack Query で画面を作りながら、失敗をルートの ErrorBoundary、react-error-boundary、useQuery の isError のどこで受けるかに悩んでいるフロントエンド開発者に向けた記事である。最後まで読めば、ErrorBoun...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong><code>ErrorBoundary</code> をいくつ置き、どこに置くのか</strong>について話してみたい。React Router と TanStack Query で画面を作りながら、失敗をルートの <code>ErrorBoundary</code>、<code>react-error-boundary</code>、<code>useQuery</code> の <code>isError</code> のどこで受けるかに悩んでいるフロントエンド開発者に向けた記事である。最後まで読めば、<code>ErrorBoundary</code> を置く場所を決める基準と、場所ごとに fallback と再試行の条件を合わせる方法が得られる。</p>
<p>先に結論を書くと、<code>ErrorBoundary</code> の数は好みではなく二つのことが決める。<strong>何が投げるのか</strong>と、<strong>それが死んだときに画面に何が残らなければならないのか</strong>だ。この記事は後者から始め、前者は必要な場所で触れる。</p>
<h2 id="明け渡してよい範囲"><a class="anchor" href="#明け渡してよい範囲">明け渡してよい範囲</a></h2>
<p><code>ErrorBoundary</code> をどこに置くかと問うと、たいていはどのコンポーネントを包むかを考える。その問いでは答えが出ない。包めるコンポーネントはいつも複数あり、どれを選んでもコードは動くからだ。</p>
<p>問い方を変えなければならない。<strong>これが死んだら画面に何が残らなければならないか。</strong></p>
<p>この問いには場所ごとに違う答えがある。画面の骨格になるデータがなければ、その画面は成立しない。名前も状態もないのに操作ボタンだけをぽつんと置くのは意味がない。逆に、脇役の一覧ひとつが失敗したからといって画面全体を覆えば、ユーザーは問題なく見られたはずの残りをすべて失う。取り消しにくい操作はまた違う。失敗したという事実を、押したその場で知らせなければならない。</p>
<p><strong><code>ErrorBoundary</code> がやることは失敗を捕まえることではなく、fallback が描かれる範囲を決めることだ。</strong> fallback とは、失敗したときに元の画面の代わりに描くものだ。包んだ分だけが消える。だから <code>ErrorBoundary</code> の位置は、捕まえたいものではなく <strong>明け渡してよい範囲</strong> で決まる。</p>
<p>その範囲が四段階に分かれる。</p>
<table>
<thead>
<tr>
<th>層の名前</th>
<th>何を受けるか</th>
<th>何で戻すか</th>
</tr>
</thead>
<tbody>
<tr>
<td>ルート</td>
<td>loader が投げたもの、下で誰も捕まえなかったもの</td>
<td><code>revalidate()</code></td>
</tr>
<tr>
<td>画面</td>
<td>レンダー中に投げられたもの</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>領域</td>
<td>その中で投げられたもの</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>コンポーネントの中</td>
<td><code>useQuery</code> の失敗、mutation の失敗</td>
<td><code>refetch()</code>、トースト</td>
</tr>
</tbody>
</table>
<p><picture><source type="image/webp" srcset="/content/251203/1.720w.webp 720w, /content/251203/1.1440w.webp 1440w, /content/251203/1.1800w.webp 1800w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251203/1.png" alt="ルート ErrorBoundary の中に画面 ErrorBoundary があり、その中に領域 ErrorBoundary とコンポーネントの中が並んで置かれた入れ子の四角形。左から、loader が投げたものはルート ErrorBoundary へ、レンダー中に投げられたものは画面 ErrorBoundary へ、その領域の失敗は領域 ErrorBoundary へ、投げないものはコンポーネントの中へ矢印が入ってくる。コンポーネントの中から上へ向かう矢印には赤いバツ印と、上がらないという表示が付いている" style="max-width:min(720px, 100%);height:auto;" width="1800" height="1000" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>名前は四つだが種類は二つだ。<strong>ルート <code>ErrorBoundary</code> だけがルーターのもので、残りの二つはツリーに置く <code>ErrorBoundary</code> だ。</strong> 画面 <code>ErrorBoundary</code> と領域 <code>ErrorBoundary</code> は同じコンポーネントであり、付いている場所だけが違う。いちばん下のコンポーネントの中は <code>ErrorBoundary</code> ではなく、コンポーネントが直接描く分岐だ。</p>
<p>以下の節では、この表の各行をなぜ消せないのかを見る。</p>
<h2 id="消せない三つの層"><a class="anchor" href="#消せない三つの層">消せない三つの層</a></h2>
<p><code>react-error-boundary</code> をインストールすると、ルーター側は消してもよさそうに見える。名前が同じなのでやることも同じに見える。ところが、どれひとつ消してはいけない理由が三つある。ここで数える三つは上の表のルート、画面、領域だ。コンポーネントの中は <code>ErrorBoundary</code> ではないので外れる。</p>
<h3 id="errorboundary-の外にいる-loader"><a class="anchor" href="#errorboundary-の外にいる-loader">ErrorBoundary の外にいる loader</a></h3>
<p><code>ErrorBoundary</code> は <code>getDerivedStateFromError</code> と <code>componentDidCatch</code> で作られたクラスなので、<strong>React のツリーの中で捕まったもの</strong>だけが来る。loader はレンダーが始まる前にツリーの外で走る関数だ。そこで投げたものは React を経由しないので、いくら包んでも見えない。同じエラーをいくつもの場所から投げて到着地を確かめた過程は、<a href="/251117" target="_blank" rel="noopener noreferrer">エラーの伝播</a>にまとめておいた。</p>
<p>だから loader を使うルートがひとつでもあれば、<strong>ルート <code>ErrorBoundary</code> は消せない。</strong> 消した瞬間、その失敗は行き場をなくす。</p>
<h3 id="revalidate-が解けないキャッシュ"><a class="anchor" href="#revalidate-が解けないキャッシュ">revalidate が解けないキャッシュ</a></h3>
<p>逆方向もふさがっている。ルート <code>ErrorBoundary</code> の復旧手段は <code>revalidate()</code> だが、これは loader をもう一度走らせるだけで、クエリキャッシュには触らない。</p>
<p>loader のないルートを考えてみよう。画面の中で <code>useSuspenseQuery</code> が失敗すれば、それも結局ルート <code>ErrorBoundary</code> が受けはする。ルーターがルートツリーを <code>RenderErrorBoundary</code> という自前のクラスで包んでおり、そのクラスも <code>getDerivedStateFromError</code> を持っているからだ。ところが、その <code>ErrorBoundary</code> の再試行を押したところで、<strong>もう一度走らせる loader もなく、キャッシュに刺さったエラーもそのままだ。</strong> 受けはしたが、戻す手段がない。</p>
<p><strong>受けることと戻すことは別の仕事だ。</strong> <code>ErrorBoundary</code> を配置するときにこの二つを一緒に見なければ、受けはするが誰も解けない fallback ができる。</p>
<h3 id="範囲を狭める下の-errorboundary"><a class="anchor" href="#範囲を狭める下の-errorboundary">範囲を狭める下の ErrorBoundary</a></h3>
<p>三つめはふさがるのではなく、失いすぎることだ。</p>
<p>ルーターは失敗したとき <code>ErrorBoundary</code> をひとつ選ぶ。その選び方がソースにある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>マッチしたルートを後ろから辿り、<code>ErrorBoundary</code> を持つ最初のルートを選び、なければ先頭へ送る。ネストしたルートの子に <code>ErrorBoundary</code> がなければその役を <strong>親が</strong> 引き受け、親にもなければルートが引き受ける。</p>
<p>ルートが引き受けると画面がまるごと消える。失敗したのは内側の領域ひとつなのに、ヘッダーもナビゲーションも一緒になくなる。子ルートに <code>ErrorBoundary</code> を付ければ、fallback は <code>&#x3C;Outlet /></code> の位置にだけ描かれる。</p>
<p><strong>だから下に <code>ErrorBoundary</code> をもうひとつ置くのは重複ではない。</strong> 同じ失敗を二度捕まえるのではなく、<strong>どこまで消すかを変えること</strong>だ。三つを重ねて置いているように見える配置は、実は異なるものを異なる範囲で受けさせているのだ。</p>
<h2 id="領域-errorboundary-とコンポーネントの中のあいだ"><a class="anchor" href="#領域-errorboundary-とコンポーネントの中のあいだ">領域 ErrorBoundary とコンポーネントの中のあいだ</a></h2>
<p>層を四つに分けたあと、最後の分かれ目が残る。なくても画面が成立する領域ひとつを、<strong><code>ErrorBoundary</code> に引き上げるか、その場で受けるか</strong>だ。</p>
<p>どちらにしてもその領域だけを失い、残りは守られる。分かれるのは失う範囲ではなく、<strong>その場所に何を代わりに描くか</strong>だ。</p>
<p><code>ErrorBoundary</code> に引き上げるとコードが減る。中で <code>useSuspenseQuery</code> を呼べば、そのコンポーネントには <code>isPending</code> も <code>isError</code> もない。待機は外側の <code>Suspense</code> が、失敗は外側の <code>ErrorBoundary</code> が受ける。コンポーネントはデータがある場合だけを描く。</p>
<p>下の <code>QueryAsyncBoundary</code> は、その <code>Suspense</code> と <code>ErrorBoundary</code> をひとつにまとめたコンポーネントだ。<code>QueryErrorResetBoundary</code> の中に <code>ErrorBoundary</code> を置いて <code>onReset</code> に <code>reset</code> をつなぎ、その中を <code>Suspense</code> で包んである。名前に <code>Query</code> を付けたのは意図的だ。<code>AsyncBoundary</code> と呼ぶと、どんな非同期にも使えるように読めるが、中に <code>QueryErrorResetBoundary</code> が入っているのでそうではない。同じ理由で <code>pendingFallback</code> に既定値を置かなかった。既定値があると、呼び出し地点の一行だけを見ても何が敷かれるのか分からない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>その代わり、その領域がまるごと fallback に変わる。一覧のように、まるごと消えても残すものがない場所なら損はない。</p>
<p>その場で受けると逆になる。<code>useQuery</code> を呼び、四つの状態(待機、失敗、空の結果、結果)を自分で描く。分岐が増える代わりに、<strong>その場所に合わせた文言と動作</strong>を置ける。失敗した行にだけ小さな再試行ボタンを付けたいなら、こちらだ。<code>ErrorBoundary</code> に引き上げると、その行の見た目は fallback コンポーネントが決めることになるが、その fallback はたいてい画面 <code>ErrorBoundary</code> と共有するものなので、一行分の失敗には重すぎる。</p>
<p>基準はこう整理される。<strong>領域がまるごと消えてもよいなら <code>ErrorBoundary</code> がよく、その場所に合わせた文言や動作が必要なら <code>useQuery</code> がよい。</strong> <code>ErrorBoundary</code> はコンポーネントから分岐を取り除いてくれるが、画面を決める自由も一緒に持っていく。</p>
<p><code>ErrorBoundary</code> に引き上げないと決めたなら、<strong>四つの状態をすべて扱わなければならない。</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isPending</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isRefetching</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">commentsOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(postId))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isPending) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"alert"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글을 불러오지 못했어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">disabled</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isRefetching}>재시도&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (data.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>아직 댓글이 없어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p><code>isError</code> を落とすと、失敗が静かに空の状態へ流れ込む。失敗したクエリの <code>data</code> は <code>undefined</code> なのに、<code>data == null || data.length === 0</code> のような検査はその <code>undefined</code> と空の配列を区別しないからだ。画面にはまだコメントがないと出て、サーバーはその瞬間 500 を返している。</p>
<p><code>isError</code> を先に取り除けば、その下で <code>data</code> が配列に絞られ、<code>== null</code> の検査が消える。</p>
<h2 id="層ごとに違う-fallback"><a class="anchor" href="#層ごとに違う-fallback">層ごとに違う fallback</a></h2>
<p>層を置いたので、各層が画面に何を描くかを決める。ここでルート <code>ErrorBoundary</code> とツリーに置く <code>ErrorBoundary</code> がまた分かれるのだが、<strong>選ぶ基準は好みではなく、その層がエラーをどう受け渡されるか</strong>だ。</p>
<h3 id="自分で読むルート-errorboundary"><a class="anchor" href="#自分で読むルート-errorboundary">自分で読むルート ErrorBoundary</a></h3>
<p>ルート <code>ErrorBoundary</code> は fallback にエラーを渡す必要がない。コンポーネントが <code>useRouteError()</code> で直接読み、復旧手段も自分で持ってくる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RootErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRouteError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">revalidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRevalidator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{revalidate} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retrying</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'loading'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>だからルートにはコンポーネントを差し込むだけでよい。子ルートの <code>ErrorBoundary</code> も同じかたちで、同じフックを使う。二つを分けるのはコードではなく <strong>付いているルート</strong>だ。どのルートに付いたかが、そのまま fallback の描かれる範囲になる。</p>
<h3 id="受け渡す-errorboundary"><a class="anchor" href="#受け渡す-errorboundary">受け渡す ErrorBoundary</a></h3>
<p><code>react-error-boundary</code> の <code>ErrorBoundary</code> は逆だ。エラーも reset 関数も自分が持っているので、fallback 側へ渡さなければならない。だから三つの prop があり、型定義がその三つを互いに排他として縛っている。</p>
<p><strong><code>fallback</code></strong> は完成した要素をそのまま受け取る。<code>fallback={&#x3C;p role="alert">댓글을 불러오지 못했어요&#x3C;/p>}</code> のように書く。エラーも reset 関数も渡さない。メッセージが固定で、再試行する方法もないときだけ使える。</p>
<p><strong><code>FallbackComponent</code></strong> はコンポーネントを受け取り、<code>error</code> と <code>resetErrorBoundary</code> を props として渡す。複数の <code>ErrorBoundary</code> が同じ fallback を共有するときはきれいだが、props の名前が <code>resetErrorBoundary</code> に固定なので、受け取る側がその名前を知っていなければならない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CommentsFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{CommentsFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>コードの <code>reset</code> は、TanStack Query の <code>QueryErrorResetBoundary</code> が渡す関数だ。エラー状態で残ったクエリを解いて再試行が再リクエストするようにするもので、なぜ必要なのかは<a href="/251128" target="_blank" rel="noopener noreferrer">再試行ボタンが効かない理由</a>でソースを追って確かめる。</p>
<p><code>ErrorFallback</code> は <code>onRetry</code> を受け取るので名前が合わない。そのため、名前を移すためだけのコンポーネントがもうひとつ要る。</p>
<p><strong><code>fallbackRender</code></strong> は関数を受け取り、その場でレンダーする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>筆者はこれを選んだ。理由は <strong>その場で名前を差し替えられるから</strong>だ。上で <code>resetErrorBoundary</code> が <code>onRetry</code> に変わった。おかげで <code>ErrorFallback</code> は <code>error</code> と <code>onRetry</code> だけを知るコンポーネントになり、復旧手段が <code>revalidate</code> であるルート <code>ErrorBoundary</code> と、<code>resetErrorBoundary</code> である <code>ErrorBoundary</code> が <strong>同じ fallback を使う。</strong></p>
<p><strong>二つの層の画面が食い違わないことが、この選択の値打ちだ。</strong> 層を四つに分けると、ユーザーが見る失敗画面も四つになりかねないが、名前を一度差し替えるだけでひとつになる。</p>
<h2 id="再試行を付けない失敗"><a class="anchor" href="#再試行を付けない失敗">再試行を付けない失敗</a></h2>
<p>fallback に再試行ボタンを付けても、すべての失敗が解けるわけではない。<code>ErrorBoundary</code> は自分の状態しか戻さないからだ。クエリのエラーは <code>QueryErrorResetBoundary</code> の <code>reset</code> を <code>onReset</code> につないで初めて再リクエストされ、サーバーが 200 で返した想定外の値を読んで起きたレンダーエラーは <code>queryFn</code> で先に検査してクエリのエラーに変えて初めて解け、<code>React.lazy</code> のチャンク読み込みの失敗は再読み込みでしか解けない。三つの場合をソースで確かめた過程は、先にリンクした「再試行ボタンが効かない理由」に別途まとめておいた。このように復旧できる失敗とできない失敗が分かれるので、ボタンも分けなければならない。</p>
<p>すべての失敗に同じボタンを見せるのは、ユーザーに <strong>できない行動を案内するようなもの</strong>だ。404 で再試行を押しても同じ 404 が返ってくる。権限がなくて受け取った 403 も同じだ。チャンク読み込みの失敗は、上で見たとおりそもそも効かない。</p>
<p>共有する fallback の中で一度だけ分けておけばよい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> describe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isChunkLoadError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새 버전이 배포됐어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새로 고침하면 이어서 볼 수 있어요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reload'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isRouteErrorResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 404</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'찾을 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'주소가 바뀌었거나 삭제된 항목이에요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 400</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'요청을 처리할 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'입력한 내용을 다시 확인해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'불러오지 못했어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'잠시 후 다시 시도해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'retry'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>isRouteErrorResponse</code> がここに一緒にある理由がある。ルート <code>ErrorBoundary</code> とツリーに置く <code>ErrorBoundary</code> が fallback を共有するので、この関数が <strong>二種類のエラーをどちらも受ける。</strong> ルーターが投げた <code>Response</code> からステータスコードを読む方法と、自分で作った HTTP エラーから読む方法が違うので、両方を見る。</p>
<p>巨大な分岐を最初から作る必要はない。<strong>もう一度押して結果が変わる失敗と、そうでない失敗</strong>だけ分けておけばよい。</p>
<h2 id="errorboundary-が失敗を見る時刻"><a class="anchor" href="#errorboundary-が失敗を見る時刻">ErrorBoundary が失敗を見る時刻</a></h2>
<p><code>ErrorBoundary</code> をすべて置いたが、まだひとつ残っている。<strong><code>ErrorBoundary</code> はいつ見るのか。</strong></p>
<p>層は上から下へ決めたが、実行の順序は逆だ。再試行がすべて尽きたあとで、ようやく <code>ErrorBoundary</code> が何かを見る。だから再試行の条件は <strong><code>ErrorBoundary</code> 設計の一部</strong>だ。</p>
<p>既定値が場所ごとに違う。どちらも同じ <code>createRetryer</code> を使うが、渡す値が違う。クエリは何も渡さないので <code>retryer.js</code> の既定を受け取る。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> retry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> config.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>mutation は <code>mutation.js</code> で直接 0 を入れる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retry</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.options.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span></code></pre></figure>
<p><strong>mutation が 0 だというのは、リクエストが一度エラーで終われば、その操作がすぐ失敗するという意味だ。</strong> 取り消しにくい操作に再試行がないのは安全な既定値だが、その操作が冪等なら、もう一度送るほうがユーザーにはよい。</p>
<p>両方に同じ条件を明示するほうがよい。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> retryOnServerError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (failureCount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>failureCount</code> は 0 から始まり、<strong>最初の失敗のときに 0 で問い合わせる。</strong> だから <code>MAX_RETRY</code> が 2 なら、合計三回送って止まる。再試行の回数であって、試行の回数ではない。</p>
<p><strong>4xx を外したのが要点だ。</strong> リクエスト自体が誤っているので、何度送っても同じ答えが返ってくる。ところが外す理由は無駄だけではない。既定の遅延が指数的に伸びる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultRetryDelay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1e3</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> **</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> failureCount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3e4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>最初の再試行が 1 秒、その次が 2 秒なので、二回再試行するだけで 3 秒が過ぎる。4xx を除かなければ、<strong>直せない失敗を 3 秒のあいだ隠すようなもの</strong>だ。</p>
<p>レンダーのエラーとチャンクの失敗には、この時間がない。リクエストを送らないので再試行そのものがなく、投げた瞬間に <code>ErrorBoundary</code> が見る。<strong>同じ fallback がある失敗では 3 秒後に、ある失敗では即座に出る理由がこれだ。</strong></p>
<p>再試行を有効にする前に確かめる前提がひとつある。そのリクエストは <strong>冪等か。</strong> 同じリクエストを二度送っても結果が同じでなければならない。サーバーがそれを保証しないなら、再試行はバグを作る機能だ。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>この記事では、失敗を受ける場所ごとに何を置くかを決めた。内容をまとめると次のようになる。</p>
<ul>
<li><code>ErrorBoundary</code> の位置は、明け渡してよい範囲で決まる。何を包むかではなく、これが死んだら何が残らなければならないかで問う。</li>
<li>三つの層の <code>ErrorBoundary</code> は、どれひとつ消せない。loader が投げたものはツリーの <code>ErrorBoundary</code> が受けられず、<code>revalidate</code> はクエリキャッシュを解けず、下にもうひとつ置くのは範囲を狭める仕事だ。</li>
<li>領域をまるごと捨ててよいなら <code>ErrorBoundary</code>、その場所の文言が必要なら <code>useQuery</code> だ。後者を選ぶなら、四つの状態をすべて扱わなければならない。</li>
<li><code>fallbackRender</code> で名前を差し替えれば、層が違っても fallback がひとつになる。</li>
<li>再試行は、投げた側の状態を解いてはじめて効く。クエリは <code>reset</code>、レンダーのエラーは <code>queryFn</code> へ引き上げて、チャンクは再読み込みだ。</li>
<li><code>ErrorBoundary</code> が失敗を見る時刻は、再試行の条件が決める。4xx を除かなければ、直せない失敗を数秒隠すことになる。</li>
</ul>
<p><code>ErrorBoundary</code> ひとつとトーストで十分ではないかと問うこともできる。画面が二つか三つの製品ならもっともな話で、実際 <strong>層の数は製品が決める。</strong> 画面が一枚なら明け渡すものも一枚だけなので、範囲の差が見えない。画面の中に独立した領域が増えるほど、その差は大きくなる。</p>
<p>ただし数とは無関係に残るものがある。ツリーに <code>ErrorBoundary</code> をいくつ置いても <code>loader</code> が投げたものはそこへは行かず、再試行は投げた側の状態を解かないかぎり効かない。<strong>減らせるのは層であって、これらの事実ではない。</strong></p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">TanStack Query, Suspense</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/react-query-error-handling" target="_blank" rel="noopener noreferrer">TkDodo, React Query Error Handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/mastering-mutations-in-react-query" target="_blank" rel="noopener noreferrer">TkDodo, Mastering Mutations in React Query</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>エラーハンドリング</category>
        </item>
        <item>
            <title><![CDATA[再試行ボタンが効かない理由]]></title>
            <link>https://hooninedev.com/ja/251128</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/251128</guid>
            <pubDate>Fri, 28 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、ErrorBoundary の再試行ボタンがなぜ効かないのかについて話してみたい。 react-error-boundary の fallback に再試行ボタンを付けたのに、押しても同じ画面がまた出てくるフロントエンド開発者に向けて、失敗の状態が残る三つの場合と、場合ごとの解き方をまとめた記事である。短く答えると、ErrorBoundary は自分の状態しか戻さず、失敗を生んだ状...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong><code>ErrorBoundary</code> の再試行ボタンがなぜ効かないのか</strong>について話してみたい。</p>
<p><code>react-error-boundary</code> の fallback に再試行ボタンを付けたのに、押しても同じ画面がまた出てくるフロントエンド開発者に向けて、失敗の状態が残る三つの場合と、場合ごとの解き方をまとめた記事である。短く答えると、<code>ErrorBoundary</code> は自分の状態しか戻さず、失敗を生んだ状態は投げた側にそのまま残るからだ。</p>
<p>例は TanStack Query と <code>react-error-boundary</code> を一緒に使う構成だ。ライブラリの動作はインストール済みのソースを開いて確かめ、引用したコードは <code>@tanstack/react-query</code> 5.104.1、<code>react-error-boundary</code> 6.1.6、React 19.2.3 のビルドファイルと構造が同じだ（2026-10-08 に照合）。インデントと改行は読みやすさのために変えた箇所がある。</p>
<h2 id="再試行が効かない三つの場合"><a class="anchor" href="#再試行が効かない三つの場合">再試行が効かない三つの場合</a></h2>
<p>原因は三つあり、解く場所もそれぞれ違う。共通点はひとつだ。<strong><code>ErrorBoundary</code> は自分の状態だけを戻す。</strong> 投げた側が持っている状態は、投げた側で解かなければならない。</p>
<h3 id="reset-が解くクエリのエラー"><a class="anchor" href="#reset-が解くクエリのエラー">reset が解くクエリのエラー</a></h3>
<p><code>resetErrorBoundary()</code> がやることは、<code>ErrorBoundary</code> の内部状態（<code>didCatch</code>）を戻すことだけだ。children が再マウントされると、クエリのフックはキャッシュを読み直す。ところがそのクエリはキャッシュに <strong>エラー状態で刺さっている。</strong> だからフックはレンダー中に同じエラーをすぐ投げ、<code>ErrorBoundary</code> はまた fallback を描く。</p>
<p>なぜ再リクエストせずに古いエラーを使うのかもソースにある。<code>errorBoundaryUtils.js</code> が再マウント時の再リクエストをこう切る。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (options.suspense </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) options.retryOnMount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>外側のガードから読まなければならない。<strong>この条件は投げるクエリにだけかかる。</strong> <code>suspense</code> であるか <code>throwOnError</code> を有効にしたクエリが、<code>isReset()</code> が偽のままマウントされると、<code>retryOnMount</code> が <code>false</code> になって再リクエストが切られる。投げない <code>useQuery</code> は該当しないので、再マウントすればそのまま再リクエストする。</p>
<p>ただし <code>retryOnMount</code> を切ることが効くのは、キャッシュに一度もデータがなかったクエリ、つまり初回ロードで失敗したクエリだけだ。データがあったクエリは失敗すると <code>isInvalidated</code> が真になって stale として扱われ、再マウント時に <code>refetchOnMount</code> で再リクエストする。この記事が言うクエリのエラーは初回ロードの失敗だ。</p>
<p>だからといって再リクエストが永遠に切られたままではない。fallback が出ているあいだはそのクエリを見るコンポーネントがないので非アクティブなクエリになり、<a href="https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">TanStack Query のデフォルト</a>どおり 5 分たつとキャッシュから消される。そのあとで押すとキャッシュにエラーがないので、最初から再リクエストする。だから <code>onReset</code> をつなぎ忘れたコードでも、しばらくして押すと動くように見える。再現がまちまちなら、押したのが <code>gcTime</code> の前か後かをまず見る。</p>
<p><picture><source type="image/webp" srcset="/content/251128/1.720w.webp 720w, /content/251128/1.1440w.webp 1440w, /content/251128/1.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251128/1.png" alt="上は onReset をつながなかったときの流れで、再試行クリック、ErrorBoundary 解除、再マウント、キャッシュのエラーをまた投げるが続き、最後から最初の枠へ赤い矢印が戻ってきて同じ fallback と書かれている。下は onReset をつないだときで、再試行クリック、onReset と reset() の呼び出し、ErrorBoundary 解除と再マウント、再リクエストが青い矢印で一方向に続く" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong><code>retryOnMount</code> が切られるのは <code>ErrorBoundary</code> に引き上げたクエリだけであり、だから <code>ErrorBoundary</code> とクエリを一緒に解かなければならない。</strong> <code>isReset()</code> が読むフラグを立てるのが <code>QueryErrorResetBoundary</code> だ。ソースを開くと状態は boolean ひとつだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	isReset </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p>フラグを立てるだけで誰も下ろさないなら、その後のエラーでも <code>retryOnMount</code> が切られず、毎回再リクエストすることになる。下ろすのはクエリのフックだ。再マウントされたフックはレンダー中にフラグを読み、<code>retryOnMount</code> を切らない。同じフラグを見る <code>getHasError</code> もキャッシュのエラーを投げない。そして画面に載ったあと、effect でフラグを下ろす。同じ <code>errorBoundaryUtils.js</code> にある関数だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useClearResetErrorBoundary</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">errorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">		errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">clearReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	}, [errorResetBoundary]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>だから boolean ひとつで「今回一度だけ再試行する」動作になる。再試行一回を順に書くとこうなる。</p>
<ol>
<li><code>reset()</code> が <code>isReset</code> フラグを立てる。</li>
<li>再マウントされたクエリのフックがレンダー中にフラグを見て、<code>retryOnMount</code> を切らない。</li>
<li>フラグが立っていれば、フックはキャッシュのエラーの代わりにリクエスト中の状態を見て再リクエストする（<code>useSuspenseQuery</code> はレンダー中にリクエストを送って suspend する）。</li>
<li>リクエストが成功して画面に載れば effect の <code>clearReset()</code> が、また失敗すればそのリクエストの <code>catch</code> がフラグを下ろす。</li>
<li>フラグが下りたので、その後のエラーでは再び <code>retryOnMount</code> が切られる。</li>
</ol>
<p>この <code>reset</code> を <code>ErrorBoundary</code> の <code>onReset</code> につないでやればよい。TanStack Query のドキュメントとソースのコメントも、このようにつなぐコードを例として載せている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Props</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{pendingFallback}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>react-error-boundary</code> の <code>resetErrorBoundary</code> はこうなっている。ビルドされたファイルなので名前が一文字に縮んでいるが、構造はそのまま読める。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">didCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  t </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.props.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.({ args: e, reason: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"imperative-api"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }), </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(d));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>didCatch</code> が真のときだけ <code>onReset</code> を呼び、状態を初期値 <code>d</code>（<code>didCatch: false</code>）に戻す。<code>setState</code> はハンドラーが終わったあとにレンダーされるので、children が再マウントされるときには <code>reset()</code> が立てたフラグがすでに立っている。だから <code>onReset</code> に <code>reset</code> をつなぐだけでよい。</p>
<p><code>QueryErrorResetBoundary</code> で囲まずに <code>useQueryErrorResetBoundary()</code> から <code>reset</code> を取り出して <code>onReset</code> につないでも再試行は動く。囲む境界がなければ、このフックがモジュール全体のデフォルト値を返すからだ。そのかわり <a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">Suspense ガイド</a>が書くとおりリセットはグローバルにかかり、アプリ全体がひとつの <code>isReset</code> フラグを共有する。</p>
<h3 id="reset-が解けないレンダーのエラー"><a class="anchor" href="#reset-が解けないレンダーのエラー">reset が解けないレンダーのエラー</a></h3>
<p>二つめは、サーバーが 200 で想定と違うかたちを返し、それを読むレンダーが <code>TypeError</code> を投げる場合だ。同じ <code>ErrorBoundary</code> が受け、<code>onReset</code> もつないであるのに、再試行が効かない。</p>
<p><code>reset</code> が解くのは <strong>エラー状態のクエリ</strong>だ。ところがこのクエリは成功した。サーバーが 200 を返し、キャッシュにはその値が正常なデータとして入っている。エラーを出したのは、その値を読んだレンダーだ。だから <code>reset</code> には解くものがなく、再マウントされたコンポーネントはキャッシュの同じ値をレンダー中に読んで同じ行でまた投げる。画面に載る前に投げるので、クエリを購読する機会もない。<code>staleTime</code> を 0 にしても再リクエストしない理由だ。このクエリも <code>gcTime</code> が過ぎればキャッシュから消されて再リクエストされるが、サーバーが同じ値を返す限り同じ行でまた投げる。</p>
<p>直す場所は <strong><code>queryFn</code></strong> だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isArray</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data.comments)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TypeError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments 가 배열이 아니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p><code>fetch</code> のレスポンスの <code>json()</code> は <code>Promise&#x3C;any></code> を返すので、その後に付けた型は検査ではなく宣言であり、サーバーが返した値を確かめるランタイム検査は自分で置かなければならない。<strong>その検査を <code>queryFn</code> へ引き上げると</strong>、同じ失敗が <strong>クエリのエラー</strong>になる。キャッシュにはエラー状態で残り、<code>reset</code> がそれを解き、再試行が再リクエストする。</p>
<p><code>ErrorBoundary</code> が受けてくれるからランタイム検査は後回しにしよう、と先送りすると、受けはするが戻せない fallback ができる。</p>
<h3 id="再読み込みだけが解く-lazy"><a class="anchor" href="#再読み込みだけが解く-lazy">再読み込みだけが解く lazy</a></h3>
<p>三つめはチャンク読み込みの失敗だ。今度は <code>reset</code> も <code>queryFn</code> も関係がない。状態を持っているのが <code>lazy</code> 自身だ。</p>
<p>React の <code>lazyInitializer</code> は拒否を <code>payload</code> に書き留め、その後は毎回同じものをまた投げる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>もう一度 <code>import()</code> はしない。<code>lazy()</code> の呼び出しはモジュールの最上位で一度起き、その <code>payload</code> はアプリが生きているあいだそのままだ。<code>ErrorBoundary</code> を解いて再マウントしても、同じエラーがまた来る。</p>
<p>それなら <code>lazy</code> を作り直して <code>import()</code> をもう一度呼べばどうか。これまではブラウザが止めていた。モジュールマップが失敗した結果を覚えていて、同じ URL を取り直さなかった。この動作を変える <a href="https://github.com/whatwg/html/pull/10327" target="_blank" rel="noopener noreferrer">HTML 仕様の変更</a>が 2026-07-15 にマージされた。2026-10-08 に確認したエンジンごとの状況はこうだ。Firefox は <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2055211" target="_blank" rel="noopener noreferrer">155 に入れて</a> 2026-09-01 にリリースした。WebKit は 2026-08-19 に <a href="https://bugs.webkit.org/show_bug.cgi?id=319492" target="_blank" rel="noopener noreferrer">main に入り</a>、そのバグ記録にはこの変更を載せた Safari 安定版のバージョンが書かれていない。Chrome は <a href="https://chromestatus.com/feature/5214647044145152" target="_blank" rel="noopener noreferrer">chromestatus</a> でまだ Proposed だ。だから Chrome では新しい <code>import()</code> も同じ失敗を返す。</p>
<p>これは Vite のように、ブラウザのネイティブ <code>import()</code> でチャンクを取得するビルドの話だ。webpack のランタイムはチャンクを script タグで取得し、失敗したチャンクの記録を消すので、新しい <code>lazy</code> で呼び直せば再リクエストする。</p>
<p>どちらにしても、すでに作った <code>lazy</code> はもう一度試さないので、この失敗の基本的な復旧はページをもう一度受け取ることだ。ブラウザが取り直してくれるようになっても、すべてが解けるわけではない。チャンクの読み込み失敗はネットワークが切れても起きるし、<a href="https://vite.dev/guide/build#load-error-handling" target="_blank" rel="noopener noreferrer">Vite のドキュメント</a>が説明するように新しいデプロイが古いチャンクを消しても起きる。消えたチャンクはもう一度リクエストしても存在しないので、その場合の復旧はやはり再読み込みだ。原因をひとつに断定できないので、fallback の文言も新しいバージョンが出たと言い切るより再読み込みを勧めるほうがよい。</p>
<p>まとめると、クエリのエラーは <code>reset</code> で、レンダーのエラーは <code>queryFn</code> であらかじめクエリのエラーに変えて、チャンクの失敗は再読み込みで解く。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>再試行ボタンが効かないなら、ボタンや <code>ErrorBoundary</code> より先に、<strong>何が投げ、その状態がどこに残っているのか</strong>を確かめてみてほしい。クエリのキャッシュなのか、サーバーが返した値を読んだレンダーなのか、<code>lazy</code> なのかによって、解く場所が違う。</p>
<p><code>ErrorBoundary</code> を画面のどこにいくつ置くか、再試行ボタンをそもそも付けない失敗は何かは、<a href="/251203" target="_blank" rel="noopener noreferrer">ErrorBoundary の配置</a>で扱う。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>エラーハンドリング</category>
        </item>
        <item>
            <title><![CDATA[エラーの伝播]]></title>
            <link>https://hooninedev.com/ja/251117</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/251117</guid>
            <pubDate>Mon, 17 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、フロントエンドでエラーがどこまで登るのかについて話してみたい。 ErrorBoundary を置くこと自体は簡単だ。ルートに一つ置き、画面ごとに ErrorBoundary で包み、図を描けば空白はない。難しいのは、その ErrorBoundary が実際に何を受け取るのかを知ることだ。 タイトルの「伝播」は、投げられたエラーがどの場所まで登って誰が受け取るのかという意味だ。投げた...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>フロントエンドでエラーがどこまで登るのか</strong>について話してみたい。</p>
<p><code>ErrorBoundary</code> を置くこと自体は簡単だ。ルートに一つ置き、画面ごとに <code>ErrorBoundary</code> で包み、図を描けば空白はない。難しいのは、その <code>ErrorBoundary</code> が<strong>実際に何を受け取るのか</strong>を知ることだ。</p>
<p>タイトルの「伝播」は、<strong>投げられたエラーがどの場所まで登って誰が受け取るのか</strong>という意味だ。投げた場所と受け取る場所がいつも同じとは限らないので、別々に数えることにした。</p>
<p>そこで数えてみることにした。同じ <code>new Error('boom')</code> を場所だけ変えて七回投げ、それが <code>ErrorBoundary</code> に届くのか、それとも <code>ErrorBoundary</code> を素通りしてどこへ行くのかを見た。</p>
<p>下のウィジェットがその実験だ。左で投げる場所を選ぶと、点線の箱の中で実際にそのエラーが投げられる。点線の箱が <code>ErrorBoundary</code> で、<code>ErrorBoundary</code> が受け取ると中が fallback に変わる。受け取られなかったものは何も起きていないように見えるので、ウィジェットが <code>window</code> の <code>error</code> と <code>unhandledrejection</code> も一緒に聞いて、どこへ行ったのかを下に書く。</p>
<div class="interactive-widget" data-widget="error-propagation"></div>
<p>七つのうち四つだけが <code>ErrorBoundary</code> に届く。残りの三つは <code>ErrorBoundary</code> の中で投げられたのに、<code>ErrorBoundary</code> をそのまま素通りしてグローバルへ出ていく。</p>
<p>この記事では、その分かれ目がなぜ生まれるのかをエラーの種類ごとに確認する。コンパイル時に終わるもの、レンダーとライフサイクルで出るもの、サーバーデータから来るもの、ページ遷移で出るもの、外部ライブラリが投げるもの、イベントと非同期で出るものだ。それぞれがどこへ伝播するのかを調べていく。<strong>受け取る場所を何層に分けるのか、どう戻すのかは扱わない</strong>。伝播経路を知らないまま層から描くと、その層が空の箱になるので順番をこうした。</p>
<p>確認は二通りで行った。ライブラリの挙動は記憶ではなくインストール済みのソースを開いて読み、投げて確かめられるものは上のウィジェットで実際に動かした。</p>
<h2 id="errorboundary"><a class="anchor" href="#errorboundary">ErrorBoundary</a></h2>
<p>まず <code>ErrorBoundary</code> そのものを正確に置いておこう。<code>ErrorBoundary</code> は<strong>クラスコンポーネントのライフサイクルメソッド二つ</strong>だ。別の名前で呼んでいるだけである。</p>
<p><code>react-error-boundary</code> にも実装は二つしかない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">static </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDerivedStateFromError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">componentDidCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e, t) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>React の公式ドキュメントが、二つのメソッドが回る時点を分けている。<code>getDerivedStateFromError</code> は<strong>レンダー段階</strong>で回って fallback を描く状態を作り、<code>componentDidCatch</code> は<strong>コミット段階</strong>で回ってログのような副作用を担う。どちらにせよ、<strong>React がツリーの中で捕まえて渡してくれたもの</strong>だけを受け取る。</p>
<p>だから <code>ErrorBoundary</code> が受け取るものの定義は一つだ。<strong>React が捕まえられる場所で投げられたか</strong>である。</p>
<p>React のドキュメントは受け取らない側も書き残している。次の四つがそれだ。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>イベントハンドラ、サーバーレンダリング、<code>ErrorBoundary</code> 自身が投げたもの、非同期コード(たとえば <code>setTimeout</code> や <code>requestAnimationFrame</code> のコールバック)。例外が一つあり、<code>useTransition</code> フックが返す <code>startTransition</code> 関数だ。その transition 関数の中で投げたものは Error Boundary が捕まえる。</p></div><div class="quote-original" lang="en"><p>Event handlers, Server side rendering, Errors thrown in the error boundary itself (rather than its children), Asynchronous code (e.g. <code>setTimeout</code> or <code>requestAnimationFrame</code> callbacks); an exception is the usage of the <code>startTransition</code> function returned by the <code>useTransition</code> Hook. Errors thrown inside the transition function are caught by error boundaries</p></div></blockquote>
<p>上のウィジェットの結果がこの文章とぴたりと合う。七か所が二つの到達先に分かれる。</p>
<p><picture><source type="image/webp" srcset="/content/251117/1.720w.webp 720w, /content/251117/1.1440w.webp 1440w, /content/251117/1.1760w.webp 1760w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/1.png" alt="左に投げた場所が七つ縦に並び、右に到達先が二つある。レンダー中、useEffect の中、startTransition の中、lazy の import 拒否の四つは青い矢印で ErrorBoundary へ行き、onClick ハンドラの中、setTimeout コールバックの中、Promise 拒否の三つは灰色の矢印で window へ行く" style="max-width:min(720px, 100%);height:auto;" width="1760" height="940" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><code>startTransition</code> が例外である理由は、その中の作業が React のスケジューラを通るからだ。React がその実行を自分の手で包んでいるので、捕まえてツリーへ戻せる。同じ理由で <code>setTimeout</code> は捕まえられない。そのコールバックが回る時点では、React はもうその場にいない。</p>
<p>先に知っておくべきことが一つある。<strong>関数コンポーネントでは <code>ErrorBoundary</code> を作れない</strong>。</p>
<p>React は投げられたエラーに出会うと、その場所から親の方向へ登りながら受け取る境界を探す。その巡回を担う <code>throwException</code> は fiber ごとに付いている <code>tag</code> を見るのだが、立ち止まる値はクラスコンポーネントとルートの二つだけだ。関数コンポーネントの <code>tag</code> はその二つではないので、探索はそのまま通り過ぎる。<strong>境界になれない理由は API がないからではなく、探索がそもそも覗き込まないからだ</strong>。</p>
<p><picture><source type="image/webp" srcset="/content/251117/2.720w.webp 720w, /content/251117/2.1440w.webp 1440w, /content/251117/2.2016w.webp 2016w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/2.png" alt="横に四つの箱が並ぶ。左から関数コンポーネントの Child と FnBoundary が tag 0、クラスコンポーネントの ErrorBoundary が tag 1、ルートの HostRoot が tag 3 だ。Child から出発した矢印が FnBoundary を通り過ぎて ErrorBoundary で止まり、HostRoot へ続く線は点線になっている" style="max-width:min(720px, 100%);height:auto;" width="2016" height="424" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>その <code>tag</code> は <code>React.Component</code> を継承しているかどうかで決まる。だから <code>getDerivedStateFromError</code> を関数に <code>static</code> として付けても意味がない。付けて動かしてみたところ、その関数は一度も呼ばれず、エラーは境界を見つけられずルートまで登って React がツリーを画面から取り除いた。</p>
<p>だから <code>react-error-boundary</code> を入れることは、ない機能を足す作業ではない。6.1.6 の <code>ErrorBoundary</code> も <code>Component</code> を継承したクラスで、二つのメソッドをそのまま持っている。ライブラリがやっているのは、そのクラスを一度だけ書いて隠すことだ。</p>
<h2 id="コンパイル時に終わるもの"><a class="anchor" href="#コンパイル時に終わるもの">コンパイル時に終わるもの</a></h2>
<p>種類を数えるついでに、いちばん手前のものから整理しよう。型エラーだ。</p>
<p>これだけはユーザーに届かない。存在しないプロパティを読んだり引数の型が合わなかったりすればビルドが止まり、止まったコードはデプロイされない。だから伝播経路を問う必要がない。<strong>この記事から型エラーが抜けるのは重要ではないからではなく、ランタイムに存在しないからだ</strong>。</p>
<p>問題はその次だ。型検査は<strong>どこまで</strong>保証するのか。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getComments</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Comment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>返り値の型が <code>Promise&#x3C;Comment[]></code> と書いてあるので、この関数を呼ぶすべてのコードが配列を受け取ると信じる。ところが <code>Response</code> の <code>json()</code> は TypeScript 5.9.3 の <code>lib.dom.d.ts</code> にこう宣言されている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(): </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Promise</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><code>any</code> だ。ここから型検査が切れる。サーバーが渡した値をコンパイラは見たことがなく、その後ろに付けた型引数や返り値の型表記は<strong>検査ではなく宣言</strong>である。その宣言をランタイムで確かめるコードは誰も入れてくれない。</p>
<p>だからサーバーが <code>200</code> で <code>{ commits: null }</code> を返すと、<code>commits.length</code> を読む行がレンダー中に <code>TypeError</code> を投げる。HTTP は成功で型も通ったのに画面が壊れる。</p>
<p><strong>型が終わる場所がランタイム検査を置く場所だ</strong>。その検査をどこに置くかによって、同じ失敗が違う経路へ行く。レンダーで読んでいて落ちればレンダーエラーになって <code>ErrorBoundary</code> へ行き、データを受け取る場所で先に検査して投げればそのリクエストの失敗になる。下の<strong>レンダーとライフサイクル</strong>の節でその内容を扱う。</p>
<h2 id="レンダーとライフサイクル"><a class="anchor" href="#レンダーとライフサイクル">レンダーとライフサイクル</a></h2>
<p>React のツリーの中で投げたものは伝播が単純だ。<strong>いちばん近い <code>ErrorBoundary</code> が受け取る</strong>。</p>
<p>レンダー中の例外がここに入る。上の <code>commits.length</code> がそうで、配列だと思っていた値に <code>map</code> を呼ぶのもそうだ。この種類は投げること以外にできることがないので、いつでも <code>ErrorBoundary</code> に届く。</p>
<p><code>useEffect</code> の中で投げたものも捕まる。effect はコミットの後に React が直接実行するので、その実行を包める。ただし effect の<strong>中で呼んだ非同期コールバック</strong>は違う。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)          </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 가 받는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 를 지나친다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span></code></pre></figure>
<p>二つのコードは同じ <code>useEffect</code> の中にあるが、伝播経路が違う。<strong><code>ErrorBoundary</code> の中にあるかどうかではなく、React がその実行を握っているかどうかが基準だ</strong>。</p>
<p>この種類でもう一つだけ覚えておけばよい。React 19 の開発モードのコンソールは、捕まえたエラーにコンポーネント名を付けてくれる。ウィジェットを動かしたとき、レンダーと effect と transition は <code>The above error occurred in the &#x3C;Thrower> component</code> で、<code>lazy</code> だけが <code>occurred in one of your React components</code> だった。<code>lazy</code> は拒否される時点でまだコンポーネントがないので名前を書けない。スタックだけを見て場所を探すとき、この違いが手がかりになる。</p>
<h2 id="サーバーデータ"><a class="anchor" href="#サーバーデータ">サーバーデータ</a></h2>
<p>ここからがフロントエンド開発でさばかなければならない厄介な部分だ。理由は、サーバーの失敗が<strong>自動でエラーにならない</strong>からである。</p>
<h3 id="fetch-はサーバーエラーで勝手に-reject-しない"><a class="anchor" href="#fetch-はサーバーエラーで勝手に-reject-しない">fetch はサーバーエラーで勝手に reject しない</a></h3>
<p>MDN がこの点をはっきり書いている。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p><code>fetch()</code> の promise は、リクエストそのものが失敗したときだけ reject される。たとえば URL の形式が不正だったり、ネットワークエラーが起きたりしたときだ。サーバーがエラーを意味する HTTP ステータスコード(<code>404</code> や <code>504</code> など)で応答した場合には reject されない。</p></div><div class="quote-original" lang="en"><p>A <code>fetch()</code> promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A <code>fetch()</code> promise does not reject if the server responds with HTTP status codes that indicate errors (<code>404</code>, <code>504</code>, etc.).</p></div></blockquote>
<p>つまり <code>fetch</code> だけを使うなら、<code>500</code> の応答は<strong>成功した Promise</strong> だ。投げられていないので <code>ErrorBoundary</code> も知らず、データライブラリも知らない。TanStack Query のドキュメントもこの点を突いている。クエリが失敗したと判定されるには <code>queryFn</code> が投げるか拒否された Promise を返さなければならないのに、<code>axios</code> は勝手に投げるが <code>fetch</code> はそうではない、という話だ。</p>
<p>だからサーバーの失敗をエラーにするのは<strong>自分でやらなければならない仕事</strong>である。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/todos/'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todoId)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">response.ok) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Network response was not ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>この三行がなければ、この節の残りは全部意味がない。投げられなかったものはどこへも伝播しない。</p>
<h3 id="一つの失敗が分かれる五つの道"><a class="anchor" href="#一つの失敗が分かれる五つの道">一つの失敗が分かれる五つの道</a></h3>
<p>失敗が失敗になった後で、次の分かれ目が始まる。同じ <code>500</code> 一回が<strong>どう呼んだか</strong>によって五か所へ散る。オプションを触っていないデフォルトが基準だ。</p>
<p><strong><code>useQuery</code> はエラーを投げない</strong>。<code>useQuery.js</code> を開いてみると <code>throwOnError</code> という文字列がそもそもない。投げるかどうかは <code>query-core</code> の <code>shouldThrowError</code> が決める。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">throwOnError;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>値がなければ <code>!!undefined</code> なので <code>false</code> だ。だから失敗は <code>query.error</code> にだけ入り、コンポーネントは正常にレンダーされる。外側の <code>ErrorBoundary</code> は最後まで自分の番が来たことを知らない。</p>
<p><strong><code>useSuspenseQuery</code> は投げるが、いつもではない</strong>。このフックはオプションを展開した後で <code>throwOnError</code> を上書きする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBaseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">options,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  enabled: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  suspense: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  throwOnError: defaultThrowOnError,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  placeholderData: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, QueryObserver, queryClient);</span></span></code></pre></figure>
<p>上書きが <code>...options</code> の後に来るので、<strong>利用者が渡した <code>throwOnError</code> は無視される</strong>。そしてその場所に入るデフォルトの判定が <code>suspense.js</code> に一行である。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultThrowOnError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> query.state.data </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><strong>見せられるキャッシュがあれば投げない</strong>。実務でこの分岐が分かれる場所はバックグラウンドの再リクエストだ。初めて入ってきた利用者はキャッシュが空なので、失敗が <code>ErrorBoundary</code> へ行って fallback を見る。すでに画面にいた利用者が別のタブへ行って戻り、再リクエストが回ってそれが失敗すると、キャッシュに古いデータがあるので投げない。画面は古い値をそのまま見せ、自分では変わらない。</p>
<p>画面が壊れないのはおおむね良い挙動だ。<strong>画面が知らせてくれないことが一緒に付いてくる</strong>という事実を知って選ぶのと、知らずに食らうのとは違う。</p>
<p><strong>mutation が返す二つの関数は、どちらも <code>ErrorBoundary</code> へ行かない</strong>。理由はそれぞれ違う。<code>useMutation.js</code> を開くと、片方は拒否を直接飲み込んでいる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">observer.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(noop);</span></span></code></pre></figure>
<p>これが <code>mutate</code> だ。同じファイルで <code>mutateAsync</code> は <code>result.mutate</code> をそのまま出しているが、その <code>result.mutate</code> は <code>mutationObserver.js</code> が <code>mutate: this.mutate</code> で載せたものなので、結局は上の行が包んだのと同じ関数である。<strong>片方だけが <code>.catch(noop)</code> を通る</strong>。その拒否は <code>await</code> した場所で落ちるのであってレンダー中に投げられるのではないので、こちらも <code>ErrorBoundary</code> とは無関係だ。</p>
<p><strong>とはいえ mutation が <code>ErrorBoundary</code> と永久に無関係なわけではない</strong>。フックの本体にスイッチがもう一つある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.error </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(observer.options.throwOnError, [result.error])) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result.error;</span></span></code></pre></figure>
<p><code>throwOnError</code> を渡すとこの行が<strong>レンダー中に</strong>投げ、そのときは <code>ErrorBoundary</code> へ行く。返ってきた二つの関数が <code>ErrorBoundary</code> に届かないことと、フックが投げないことは別の話だ。</p>
<p><picture><source type="image/webp" srcset="/content/251117/3.720w.webp 720w, /content/251117/3.1440w.webp 1440w, /content/251117/3.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/3.png" alt="左のサーバー 500 一回から矢印が五本伸びて useQuery、useSuspenseQuery、throwOnError を入れたフック、mutate、mutateAsync へ行き、それぞれがさらに query.error、ErrorBoundary、ErrorBoundary、mutation.error、呼び出し側の catch へ行く。真ん中の ErrorBoundary 二つだけが点線で結ばれ、ErrorBoundary が受け取る二つとして示されている" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>まとめると、同じ <code>500</code> の到達先は五つだ。<code>query.error</code> フィールド、<code>mutation.error</code> フィールド、呼び出し側の <code>catch</code>、そして <code>ErrorBoundary</code> へ行く二つの場合である。<code>ErrorBoundary</code> へ行く二つは、<code>useSuspenseQuery</code> がキャッシュなしで失敗したときと <code>throwOnError</code> を入れたときだ。<strong>失敗の種類ではなく呼び方が終着点を決める</strong>。</p>
<h2 id="ページ遷移"><a class="anchor" href="#ページ遷移">ページ遷移</a></h2>
<p>画面を移るときに出る失敗は二つに分かれる。分かれる基準は<strong>React のツリーの中か外か</strong>だ。</p>
<h3 id="loader-はツリーの外で回る"><a class="anchor" href="#loader-はツリーの外で回る">loader はツリーの外で回る</a></h3>
<p>ルーターの <code>loader</code> はレンダーが始まる前に実行される関数だ。React コンポーネントではないので <code>getDerivedStateFromError</code> も <code>componentDidCatch</code> も届かない。<code>react-error-boundary</code> でいくら包んでも、その <code>ErrorBoundary</code> は loader の失敗を見られない。</p>
<p>代わりにルーターが自分の <code>ErrorBoundary</code> の体系を別に持っている。React Router のドキュメントはこう書いている。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>route module はコードで起きたエラーを自動的に捕まえ、いちばん近い <code>ErrorBoundary</code> を描く。</p></div><div class="quote-original" lang="en"><p>route modules will automatically catch errors in your code and render the closest <code>ErrorBoundary</code>.</p></div></blockquote>
<p>いちばん近いものを選ぶ方法はソースにある。<code>findNearestBoundary</code> がこう選ぶ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>マッチしたルートを後ろから辿って <code>ErrorBoundary</code> を持つ最初のルートを選び、なければ先頭のルートへ送る。<strong>だから下のルートに <code>ErrorBoundary</code> をもう一つ置くのは重複する作業ではなく、fallback が描かれる範囲を狭める作業だ</strong>。</p>
<p>読む側も違う。ルートの <code>ErrorBoundary</code> はエラーを props で受け取らず <code>useRouteError()</code> で直接取り出す。ステータスコードが載ったものかどうかは <code>isRouteErrorResponse(error)</code> で分ける。この二つは React の <code>ErrorBoundary</code> にはない道具だ。</p>
<h3 id="lazy-の拒否はツリーの中だ"><a class="anchor" href="#lazy-の拒否はツリーの中だ">lazy の拒否はツリーの中だ</a></h3>
<p>同じページ遷移でも、コードを遅れて受け取る側は逆だ。<code>lazy(() => import('./Tab'))</code> の <code>import()</code> が拒否されると、React がそれを受けて<strong>いちばん近い <code>ErrorBoundary</code></strong> へ投げる。ウィジェットの四番目のボタンがこの経路だ。</p>
<p>デプロイが出ると古いチャンクファイルは消えるが、デプロイ前に開いておいた画面は依然として古いアドレスを持っている。その状態でそのコードを要求すると <code>import()</code> が拒否され、Chrome は <code>TypeError: Failed to fetch dynamically imported module</code> を投げる。</p>
<p>ここにもう一つ付く。<strong><code>lazy</code> は拒否を覚える</strong>。React の <code>lazyInitializer</code> が結果を <code>payload</code> に書いておくのだが、拒否されると状態を変えて理由を保存する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._result </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error;</span></span></code></pre></figure>
<p>その後はこのコンポーネントをレンダーするたびに最後の分岐が回る。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>もう一度 <code>import()</code> はしない。<code>lazy()</code> の呼び出しはモジュールの最上位で一度だけ起きており、その <code>payload</code> はアプリが生きている間そのままだ。<strong><code>ErrorBoundary</code> を戻して再マウントしても同じエラーがまた来る</strong>。この失敗の復旧がページを受け取り直すことだけである理由がここにある。</p>
<p>同じページ遷移なのに、loader の失敗はルーターが受け取り <code>lazy</code> の失敗は React が受け取る。<strong><code>ErrorBoundary</code> をどこに置くか決める前にこの二つを分けておかないと、どちらか一方は行き場がなくなる</strong>。</p>
<h2 id="errorboundary-の外のグローバルハンドラ"><a class="anchor" href="#errorboundary-の外のグローバルハンドラ">ErrorBoundary の外のグローバルハンドラ</a></h2>
<p><code>ErrorBoundary</code> が捕まえられなかった三つはどこへ行ったのか。<strong>ブラウザのグローバルハンドラ</strong>である。</p>
<p>イベントハンドラと <code>setTimeout</code> のコールバックで投げたものは、結局 <code>window</code> の <code>error</code> イベントへ行く。Promise の拒否は別のイベントへ行く。MDN の定義はこうだ。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p><code>unhandledrejection</code> イベントは、拒否ハンドラを持たない JavaScript の Promise が拒否されたときにスクリプトのグローバルスコープへ送られる。通常は <code>window</code> だが <code>Worker</code> のこともある。</p></div><div class="quote-original" lang="en"><p>The <code>unhandledrejection</code> event is sent to the global scope of a script when a JavaScript Promise that has no rejection handler is rejected; typically, this is the <code>window</code>, but may also be a <code>Worker</code>.</p></div></blockquote>
<p>この二つがブラウザの最後の受け取り口だ。エラーモニタリングのツールがブラウザのエラーを捕まえる場所も、ほとんどがここである。</p>
<p>React 19 はツリー側にも受け取る場所を一つ増やした。<code>createRoot</code> のオプションだ。公式ドキュメントが三つをこう分けている。</p>
<table>
<thead>
<tr>
<th>オプション</th>
<th>いつ呼ぶか</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>onCaughtError</code></td>
<td>React が Error Boundary の中でエラーを捕まえたとき</td>
</tr>
<tr>
<td><code>onUncaughtError</code></td>
<td>エラーが投げられたのに Error Boundary が捕まえられなかったとき</td>
</tr>
<tr>
<td><code>onRecoverableError</code></td>
<td>React が自力で復旧したとき</td>
</tr>
</tbody>
</table>
<p>受け取ることと直すことは違う。<strong>グローバルハンドラが捕まえたからといって画面が復旧するわけではない</strong>。<code>onClick</code> の中で投げたエラーを <code>window</code> が受け取ってモニタリングツールへ送っても、その瞬間の利用者の画面ではボタンがただ押されなかったように見える。報告と復旧は別の仕事だ。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>フロントエンドのエラー処理を道具の一覧として覚えると、穴が残り続ける。<code>ErrorBoundary</code>、<code>throwOnError</code>、<code>useRouteError</code>、<code>lazy</code>、<code>unhandledrejection</code> を全部知っていてもそうだ。道具を知らないからではなく、<strong>何がどこへ行くのかを数えていないから</strong>である。</p>
<p>この記事で扱った内容をまとめると次のようになる。</p>
<ul>
<li><code>ErrorBoundary</code> は React が捕まえて渡したものだけを受け取る。イベントハンドラと非同期コールバックはその場にいない。</li>
<li>型検査は応答で終わる。<code>json()</code> が <code>any</code> を返す地点からは検査ではなく宣言だ。</li>
<li>サーバーの失敗はひとりでにエラーにならない。<code>fetch</code> は <code>500</code> で reject しないので、投げる作業は自分でやる。</li>
<li>投げられた後は呼び方が終着点を決める。同じ失敗がフィールドへも、呼び出し側へも、<code>ErrorBoundary</code> へも行く。</li>
<li>ページ遷移は二つに分かれる。loader はツリーの外なのでルーターが受け取り、<code>lazy</code> はツリーの中なので React が受け取る。</li>
<li><code>ErrorBoundary</code> の外にはグローバルハンドラがある。捕まりはするが画面は復旧しない。</li>
</ul>
<p>だから <code>ErrorBoundary</code> を描く前にやることは、包むコンポーネントを選ぶことではない。<strong>この画面で失敗しうる場所を書き出し、それぞれが上の六つのどれに当たるのかを印すこと</strong>である。印のない場所がそのまま穴だ。</p>
<p>いま読者の画面で失敗しうる場所がいくつあり、そのうちいくつが <code>ErrorBoundary</code> に届き、届かないものはどこへ行っているのかを調べてみてほしい。</p>
<p>到達先ごとに何で受け取るのかは、<a href="/251203" target="_blank" rel="noopener noreferrer">ErrorBoundary の配置</a>で扱う。層をいくつに分けるのか、一つの失敗に画面の範囲をどう扱うのかだ。fallback の再試行ボタンが実際に再試行するようにするには何を一緒に解かなければならないのかは、<a href="/251128" target="_blank" rel="noopener noreferrer">再試行ボタンが効かない理由</a>で別に扱う。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React, Component の Error Boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react-dom/client/createRoot" target="_blank" rel="noopener noreferrer">React, createRoot のエラーコールバック</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/lazy" target="_blank" rel="noopener noreferrer">React, lazy</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-functions" target="_blank" rel="noopener noreferrer">TanStack Query, Query Functions</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event" target="_blank" rel="noopener noreferrer">MDN, unhandledrejection</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch" target="_blank" rel="noopener noreferrer">MDN, fetch</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://axios.rest/pages/advanced/error-handling" target="_blank" rel="noopener noreferrer">axios, Error handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>エラーハンドリング</category>
        </item>
        <item>
            <title><![CDATA[React Fiber完全攻略]]></title>
            <link>https://hooninedev.com/ja/250520</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/250520</guid>
            <pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、Reactの心臓部ともいえるFiberアーキテクチャについて話したい。 「作業単位に分けて処理する」という一行の定義は知っていても、それがReactの内部でどう実装されているのかを説明するのは難しいフロントエンド開発者に向けた記事である。最後まで読めば、Fiberがなぜ登場し、Fiberノードがどんな形をしていて、ダブルバッファリングとLane、Work Loopがどう噛み合ってConcu...]]></description>
            <content:encoded><![CDATA[<p>今回は、Reactの心臓部ともいえる<strong>Fiberアーキテクチャ</strong>について話したい。</p>
<p>「作業単位に分けて処理する」という一行の定義は知っていても、それがReactの内部でどう実装されているのかを説明するのは難しいフロントエンド開発者に向けた記事である。最後まで読めば、Fiberがなぜ登場し、Fiberノードがどんな形をしていて、ダブルバッファリングとLane、Work Loopがどう噛み合ってConcurrent Featuresを可能にしているのかを、Reactのソースコードを根拠に説明できるようになる。</p>
<p>筆者が初めてReactに触れた頃、<strong>「Fiber」<strong>という言葉は、面接の定番質問くらいにしか認識していなかった。「Reactのレンダリング処理を作業単位に分割して実行する」という一行の定義を覚え、それがすべてだと思っていた。しかし実際にReactのソースコードを読み始めると、Fiberは単なる概念ではなく、Reactレンダリングの</strong>すべて</strong>を司る実行アーキテクチャだと気づいた。</p>
<h2 id="なぜfiberは登場したのか"><a class="anchor" href="#なぜfiberは登場したのか">なぜFiberは登場したのか？</a></h2>
<p>この問いに答えるには、まずFiber以前の世界、つまりReact 15まで使われていた<strong>Stack Reconciler</strong>がどのような問題を抱えていたのかを理解する必要がある。</p>
<p>Stack Reconcilerは、その名のとおり<strong>再帰呼び出しベース</strong>の差分調整エンジンだった。コンポーネントツリーを上から下へ再帰的に走査し、一度レンダリングを開始すると、ツリー全体を最後まで処理しなければ停止できなかった。これは、電話中に相手が話し終わるまで絶対に切れないような状況だ。（相手が3時間にわたる人生相談を始めたのに、途中で切れないと考えてみてほしい。恐ろしい。）</p>
<p>Stack Reconcilerには、具体的に次のような限界があった。</p>
<ul>
<li><strong>レンダリング中に中断できない</strong>：ツリー全体を一度に処理する必要があるため、複雑なUIではメインスレッドが数十〜数百ミリ秒にわたって占有された</li>
<li><strong>優先度という概念がない</strong>：ユーザーがボタンをクリックしても、バックグラウンドデータが更新されても、すべての更新が同じ方法で処理された</li>
<li><strong>アニメーションやジェスチャーへの対応が難しい</strong>：60fpsを維持するには1フレームあたり約16ms以内にすべての作業を終える必要があるが、再帰的レンダリングではそれを保証できなかった</li>
<li><strong>エラー発生時にアプリ全体が停止する</strong>：コンポーネントツリーのどこかでエラーが発生すると、アプリ全体が止まる問題があった</li>
</ul>
<p>こうした限界を克服するため、Reactチームは作業を<strong>分割</strong>し、<strong>優先度を付け</strong>、必要に応じて<strong>中断・再開</strong>できる新しい実行モデルを検討した。その成果が、まさに<strong>React Fiber</strong>である。</p>
<p>Andrew Clarkが執筆した<a href="https://github.com/acdlite/react-fiber-architecture" target="_blank" rel="noopener noreferrer">react-fiber-architecture</a>には、この設計の核心となる思想がまとめられており、Fiberを理解するうえで最も重要な参考資料だ。（この文書を書いて間もなくReactチームに加わったようだ。）</p>
<h2 id="stack-vs-fiber"><a class="anchor" href="#stack-vs-fiber">Stack vs Fiber</a></h2>
<p>では、Stack ReconcilerとFiber Reconcilerは、コードレベルでどのように異なるのだろうか？</p>
<h3 id="再帰ベースのstack-reconciler"><a class="anchor" href="#再帰ベースのstack-reconciler">再帰ベースのStack Reconciler</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> renderComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">component</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> element</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> component.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">render</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  element.props.children.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">child</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> renderComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(child)); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 재귀 호출</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Stack方式では、子コンポーネントに出会うと<strong>即座に再帰呼び出し</strong>へ入る。この方式の問題は、JavaScriptのコールスタックに直接依存している点だ。再帰呼び出しが深くなるほどコールスタックにフレームが積まれ、そのすべてが解消されるまで、ブラウザーのメインスレッドはほかの処理を実行できない。</p>
<p>簡単にいえば、コールスタックが空になるまでブラウザーは<strong>身動きすら取れない</strong>状態になる。</p>
<video width="640" height="480" controls>
  <source src="/content/250520/stack.mov" type="video/mp4">
</video>
<p>上の動画では、Stack Reconcilerがレンダリングしている間、メインスレッドが完全にブロックされる様子を確認できる。</p>
<h3 id="反復ベースのfiber-reconciler"><a class="anchor" href="#反復ベースのfiber-reconciler">反復ベースのFiber Reconciler</a></h3>
<p>Fiberは再帰を<strong>反復処理</strong>に置き換えた。コールスタックの代わりに、独自の<strong>仮想スタック</strong>をメモリ上に実装したのである。各Fiberノードが一つの「スタックフレーム」となり、これらのノードはJavaScriptオブジェクト（ヒープメモリ）として存在するため、いつでも中断し、後から続きを再開できる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deadline</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (nextUnitOfWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> deadline.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">timeRemaining</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    nextUnitOfWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(nextUnitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestIdleCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWork); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 나눠서 실행</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>上のコードは、Fiber初期の概念モデルを示している。重要なのは、<code>while</code> ループ内で一度に一つの作業単位だけを処理し、時間が足りなくなればループを抜けてブラウザーに制御を返す点だ。</p>
<p>ただし、この例は概念を示すために<code>requestIdleCallback</code>を使ったにすぎず、実際のReactはこれを使っていない。このAPIはブラウザーが決めたアイドル期間にしか呼ばれないため、メインスレッドが忙しいページではReactの作業が後回しにされ続けうると筆者は見ている。そこでReactのSchedulerパッケージは、<code>MessageChannel</code>で次のマクロタスクを予約して作業を続け、その合間にメインスレッドへ制御を返す。<code>setTimeout</code>ではなく<code>MessageChannel</code>を選んだ理由は、<a href="/250515" target="_blank" rel="noopener noreferrer">ReactがMessageChannelを使う理由</a>に別途まとめておいた。</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>Fiber方式では、レンダリング中でもユーザーイベント（ボタンクリック、入力など）にすぐ反応できる。作業を細かく分割して実行するため、ブラウザーが息をつく余地が生まれる。</p>
<p>二つの方式の違いを直接体験したければ、<strong><a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">こちら</a></strong> をクリックしてほしい。Stack ReconcilerとFiber Reconcilerの動作の違いを目で確認できる。</p>
<p>これこそ、Andrew Clarkが文書で強調したFiberの主要な目標だ。</p>
<ul>
<li><strong>作業を一時停止し、後から戻ってこられる</strong></li>
<li><strong>異なる種類の作業に優先度を付けられる</strong></li>
<li><strong>以前に完了した作業を再利用できる</strong></li>
<li><strong>不要になった作業を中止できる</strong></li>
</ul>
<h2 id="fiberノードの内部構造"><a class="anchor" href="#fiberノードの内部構造">Fiberノードの内部構造</a></h2>
<p>ここまで読むと、一つの疑問が自然に浮かぶ。「では、Fiberノードの内部はどうなっているのか？」</p>
<p>Reactチームは、Fiberの内部実装に関する公式文書を別途提供していない。しかし、Andrew Clarkのreact-fiber-architecture文書と、実際のReactソースコード（<code>ReactFiber.js</code>）からその構造を把握できる。</p>
<p>筆者はFiberノードを<strong>作業指示書</strong>にたとえたい。工場で製品を組み立てるとき、各作業指示書には「この部品はどの種類か」「どの材料を使うか」「次にどの作業を行うか」「優先度はどれくらいか」が書かれている。Fiberノードも同じだ。</p>
<h3 id="reactelementとfibernode"><a class="anchor" href="#reactelementとfibernode">ReactElementとFiberNode</a></h3>
<p>Fiberを理解するには、まず<strong>ReactElement</strong>と<strong>FiberNode</strong>を区別しなければならない。この二つはよく混同されるが、まったく別のものだ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ReactElement — React.createElement()가 반환하는 가벼운 객체</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReactElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Function</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 문자열(HTML 태그) 또는 함수(컴포넌트)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">    children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReactElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  ref</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  _owner</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FiberNode</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ReactElementはUIの<strong>設計図</strong>にすぎない。「このコンポーネントをこのpropsでレンダリングしてほしい」という依頼書であり、実際のレンダリング処理やstateは含まれていない。</p>
<p>一方、<strong>FiberNode</strong>は、この設計図を基にReactが内部で生成する<strong>実行時の作業単位</strong>だ。ReactElementにはない<code>tag</code>、<code>stateNode</code>、<code>child/sibling/return</code>、<code>memoizedState</code>、<code>updateQueue</code>、<code>lanes</code>といったフィールドがここに存在する。</p>
<p>ReactがReactElementの<code>type</code>を見てFiberNodeを生成するとき、<strong>tag</strong>の値が決まる。</p>
<ul>
<li><code>type</code>がfunctionで<code>prototype.isReactComponent</code>を持つ場合 → <code>tag = ClassComponent(1)</code></li>
<li><code>type</code>がfunctionの場合 → <code>tag = FunctionComponent(0)</code></li>
<li><code>type</code>がstring（<code>"div"</code>など）の場合 → <code>tag = HostComponent(5)</code></li>
</ul>
<p><strong>tag</strong>はFiberNodeの種類を表す数値定数だ。<code>ReactWorkTags.js</code>で定義されており、<code>FunctionComponent(0)</code>、<code>ClassComponent(1)</code>、<code>HostRoot(3)</code>、<code>HostComponent(5)</code>、<code>HostText(6)</code>など、25種類以上のtagが存在する。Reactはこのtag値を基に、<code>beginWork</code>でどの処理を実行するかを決定する。</p>
<p><strong>type</strong>は差分調整で中心的な役割を果たす。Reactが前回のレンダリングのFiberと新しい要素を比較するとき、<strong>最初に確認するもの</strong>がtypeだ。（この値はReactElementからFiberNodeへそのまま渡される。）</p>
<ul>
<li>前回も<code>div</code>で今回も<code>div</code>なら、ReactはそのFiberノードを<strong>再利用</strong>し、propsだけを更新する</li>
<li>前回は<code>div</code>だったものが今回は<code>span</code>に変わったなら、Reactは既存のFiberを<strong>破棄</strong>し、新しいFiberを生成する</li>
</ul>
<p><strong>key</strong>もReactElementからFiberNodeへ渡される値で、主にリスト（配列）のレンダリング時に使われる。keyがないと、リスト項目の順序が変わったときに、どの項目がどこへ移動したのかをReactが正確に判断できない。その結果、不要なDOM操作が発生したり、コンポーネントの内部stateが意図せず維持または失われたりする可能性がある。</p>
<h3 id="child-sibling-return"><a class="anchor" href="#child-sibling-return">child, sibling, return</a></h3>
<p>React Fiberが再帰ではなく反復を使える秘密が、ここにある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 부모</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">자식1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">/>, &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">자식2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">/>];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><strong>child</strong>は、コンポーネントのrenderが返した<strong>最初の</strong>子要素を指す。上の例では<code>&#x3C;자식1/></code>だ。<strong>sibling</strong>は、同じ親を持つ<strong>次の兄弟</strong>要素を意味する。<code>&#x3C;자식1/></code>のsiblingは<code>&#x3C;자식2/></code>である。<strong>return</strong>は、現在のFiberノードの処理が終わった後に<strong>戻る親</strong>Fiberを指す。<code>&#x3C;자식1/></code>と<code>&#x3C;자식2/></code>のreturnは、どちらも<code>부모</code>だ。</p>
<p>この三つのフィールドが作る構造は、<strong>単方向連結リスト形式のツリー</strong>だ。一般的なツリー構造では子の配列（<code>children[]</code>）を持たせるほうが直感的だが、Fiberは意図的にそれを避けた。</p>
<p>なぜだろうか。配列ベースの子構造では、走査のためにインデックスを管理する必要があり、途中で中断して再開するときには「どこまで処理したか」を別途追跡しなければならない。一方、連結リスト構造では、現在のノードへの参照さえ覚えておけば、いつでも続きから走査できる。これがFiberの<strong>中断と再開</strong>を自然に支える構造的基盤である。</p>
<p>Reactはこの構造を基に、深さ優先探索（DFS）の順序でノードを走査する。<code>child</code>に沿って下り（beginWork）、葉ノードに到達したら<code>sibling</code>を確認し、兄弟がなければ<code>return</code>に沿って上る（completeWork）方式だ。</p>
<h3 id="pendingpropsとmemoizedprops"><a class="anchor" href="#pendingpropsとmemoizedprops">pendingPropsとmemoizedProps</a></h3>
<p><strong>pendingProps</strong>は、そのFiberが処理を開始する時点で渡された<strong>新しいprops</strong>を意味し、<strong>memoizedProps</strong>は、前回のレンダリングで処理が完了した<strong>以前のprops</strong>を表す。</p>
<p>この二つの値が同じなら、Reactは「このコンポーネントには変更がない」と判断し、前回のレンダリング結果をそのまま再利用できる。これが<strong>bailout最適化</strong>の中心的な仕組みだ。</p>
<p>同様に、<strong>memoizedState</strong>はそのFiberのフックのstateを保存し、<strong>updateQueue</strong>はまだ処理されていないstate更新（setState呼び出し）を連結リストとして管理する。</p>
<h3 id="statenode"><a class="anchor" href="#statenode">stateNode</a></h3>
<p><strong>stateNode</strong>は、Fiberノードが指す<strong>実際のインスタンス</strong>を参照する。</p>
<ul>
<li><strong>HostComponent</strong>（div、spanなど）の場合：実際のDOMノード</li>
<li><strong>ClassComponent</strong>の場合：クラスインスタンス</li>
<li><strong>HostRoot</strong>の場合：FiberRootオブジェクト</li>
</ul>
<p>このフィールドは、Fiberの仮想世界とブラウザーの実際のDOMをつなぐ橋の役割を果たす。</p>
<h2 id="ダブルバッファリングcurrentツリーとworkinprogressツリー"><a class="anchor" href="#ダブルバッファリングcurrentツリーとworkinprogressツリー">ダブルバッファリング：currentツリーとworkInProgressツリー</a></h2>
<p>Fiberを理解するうえで欠かせない重要な概念が、<strong>ダブルバッファリング</strong>（Double Buffering）だ。</p>
<p>この概念を理解するために、ゲームグラフィックスを思い浮かべてみよう。ゲームで画面を描くとき、現在の画面にピクセルを直接描くと、描画途中のフレームがユーザーに見えてしまう<strong>ちらつき（flicker）<strong>が発生する。これを防ぐため、ゲームエンジンは</strong>二つのバッファ</strong>を使う。一方のバッファに次のフレームを完全に描き、完成した時点で画面に表示するバッファを一度に切り替えるのだ。</p>
<p>React Fiberもまったく同じ戦略を使う。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">currentFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgressFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">workInProgressFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> currentFiber;</span></span></code></pre></figure>
<p><strong>currentツリー</strong>は、現在画面に反映されているFiberツリーである。ユーザーが見ているUIの状態を表し、<strong>workInProgressツリー</strong>は次のレンダリングに向けてバックグラウンドで準備中のFiberツリーを表す。</p>
<p>二つのツリーは<code>alternate</code> プロパティで相互に参照する。すべての変更作業はworkInProgressツリーで行われ、作業が完了すると<code>root.current = finishedWork</code>という一行でツリーが切り替わる。以前のworkInProgressが新しいcurrentになり、以前のcurrentは次のレンダリングでworkInProgressとして再利用される。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createWorkInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 최초 렌더: 새 Fiber를 생성하고 alternate를 연결</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFiber</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current.tag, pendingProps, current.key, current.mode);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.stateNode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.stateNode; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// DOM 노드는 공유!</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    current.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 재렌더: 기존 alternate를 재사용, effect만 초기화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.pendingProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.flags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.deletions </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // lanes, child, memoizedState 등을 복사</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.childLanes </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.childLanes;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.child;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ここで重要な点を押さえよう。<code>stateNode</code>（実際のDOMノード）はcurrentとworkInProgressの間で<strong>共有</strong>される。毎回Fiberオブジェクトを新しく作るのではなく、既存のalternateを再利用し、変更されたフィールドだけを更新する。これにより、レンダリングのたびにガベージコレクション（GC）の負荷を増やすことなく、効率よくツリーを構築できる。</p>
<p>propsやstateに変更がなければどうなるだろうか。そのサブツリー全体を飛ばす<strong>bailout最適化</strong>が可能になる。ゲームのダブルバッファリングがフレーム単位の最適化なら、Fiberのダブルバッファリングは<strong>コンポーネント単位の最適化</strong>まで可能にする。</p>
<h2 id="pendingworkpriority--lanes"><a class="anchor" href="#pendingworkpriority--lanes">pendingWorkPriority => Lanes</a></h2>
<p>ではFiberは、どのようにして「この作業のほうが重要だ」と判断するのだろうか？</p>
<h3 id="expirationtimeの限界"><a class="anchor" href="#expirationtimeの限界">expirationTimeの限界</a></h3>
<p>初期のFiberは<code>pendingWorkPriority</code>という数値ベースの優先度を使い、その後、<code>expirationTime</code>という単一の数値へ発展した。期限が近いほど優先度が高いことを意味したが、この方式には根本的な限界があった。</p>
<p>単一の数値では、「この更新はAグループに属し、あの更新はBグループに属する」といった<strong>柔軟な分類ができなかった</strong>からだ。たとえばユーザー入力とTransitionの更新が同時に発生した場合、expirationTimeベースでは範囲の比較でしか分類できず、特定の更新だけを選択的に処理することに限界があった。</p>
<h3 id="lane"><a class="anchor" href="#lane">Lane</a></h3>
<p>この問題を解決するため、Andrew Clarkが<a href="https://github.com/facebook/react/pull/18796" target="_blank" rel="noopener noreferrer">PR #18796</a>で導入したのが<strong>Laneシステム</strong>だ。</p>
<p>Laneを理解するために、<strong>高速道路</strong>を思い浮かべてみよう。高速道路には複数の車線があり、それぞれ用途が異なる。第1車線は追い越し用（緊急）、第2車線は通常走行用、路肩は非常用だ。各車両（更新）は性質に合った車線へ割り当てられ、高速道路の管理システム（スケジューラー）が、どの車線の車両を先に通すかを決定する。</p>
<p>ReactのLaneも同じだ。各更新に<strong>一つのビット</strong>（Lane）を割り当て、ビット演算でグループを作成し比較する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 업데이트는 하나의 lane(단일 비트)을 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000000010</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> InputContinuousLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000001000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> DefaultLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">          /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000100000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TransitionLane1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000100000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> IdleLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0001000000000000000000000000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배치(batch)는 여러 비트의 OR 조합이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncUpdateLanes</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SyncLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InputContinuousLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> DefaultLane;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 lane이 batch에 포함되는지 확인은 단순 비트 연산</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> isIncluded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (lane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> lanes) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>合計31個のLaneが31ビット整数に収まるよう設計されている。これはV8エンジンの<strong>SMI</strong>（Small Integer）最適化を活用するためだ。31ビット以下の整数はV8でポインタータグ付き整数として処理され、ヒープ割り当てなしにスタック上で直接演算できる。主要なLaneの優先度は、<strong>ビットが低いほど高い</strong>。</p>
<p>この構造により、Reactは一度のビット演算で、どの作業を先に処理するかを判断できるようになった。<code>getNextLanes()</code> 関数は<code>pendingLanes</code>から最も優先度の高いLaneグループを選び、中断中のLaneを飛ばし、データを受信した再試行可能なLaneを優先するなど、高度なスケジューリングを可能にしている。</p>
<p>さらに、<strong>飢餓状態の防止</strong>のため、各Laneには有効期限が設定される。Sync/InputContinuousは250ms、Transitionは5,000msが経過すると<code>expiredLanes</code>へ追加され、同期的に強制処理される。どれだけ優先度が低くても、永遠に無視されることはない。（優先度が低いというだけで永遠に無視されるなら、それは優先度システムではなく差別システムだ。）</p>
<h2 id="fiberの出力"><a class="anchor" href="#fiberの出力">Fiberの出力</a></h2>
<p>ここまでFiberの構造を見てきたところで、新たな疑問が生まれる。これらのFiberノードは、どのように<strong>実際のDOM</strong>へ変換されるのだろうか？</p>
<p>出力とは、実際のDOMへ適用できる具体的なDOMノード情報を指す。ここには重要な区別がある。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용자 정의 컴포넌트 — output 없음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트 컴포넌트 — output 생성</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>実際のDOMノードを生成するのは、<strong>ホストコンポーネント</strong>（div、span、imgなど）だけだ。ブラウザーは<code>&#x3C;아바타/></code>が何なのか理解できない。ユーザー定義コンポーネントは抽象化された概念なので、最終的にホストコンポーネントへ分解されて初めてブラウザーが理解できる。</p>
<p>この過程をもう少し具体的に見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 프로필</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">유저정보</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> alt</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 유저정보</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>홍길동&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>개발자&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>これらのコンポーネントが作るFiberツリーと出力の関係は、次のとおりだ。</p>
<pre><code>프로필 (출력: 없음, 컴포넌트 함수)
  │
  └─► div.프로필 (출력: &#x3C;div class="프로필">...&#x3C;/div>)
       │
       ├─► 아바타 (출력: 없음, 컴포넌트 함수)
       │    │
       │    └─► img (출력: &#x3C;img src="profile.jpg" alt="프로필">)
       │
       └─► 유저정보 (출력: 없음, 컴포넌트 함수)
            │
            └─► div (출력: &#x3C;div>...&#x3C;/div>)
                 │
                 ├─► h2 (출력: &#x3C;h2>홍길동&#x3C;/h2>)
                 │
                 └─► p (출력: &#x3C;p>개발자&#x3C;/p>)
</code></pre>
<p>出力の収集は<strong>下から上へ</strong>進む。まず葉にあたるホストノードでDOMが生成される。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트 컴포넌트들이 실제 DOM 정보 생성</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">img_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'img'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  src: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'profile.jpg'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  alt: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'프로필'</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">h2_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'h2'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'홍길동'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">p_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'p'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'개발자'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>次に、親のホストコンポーネントが子の出力を収集する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// div 노드가 자식들의 출력을 수집</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">유저정보_div_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'div'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  h2_fiber.output,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;h2>홍길동&#x3C;/h2></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  p_fiber.output    </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;p>개발자&#x3C;/p></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 최상위 div가 모든 자식 출력을 수집</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">프로필_div_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'div'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {className: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'프로필'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  img_fiber.output,           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;img src="profile.jpg" alt="프로필"></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  유저정보_div_fiber.output   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;div>&#x3C;h2>홍길동&#x3C;/h2>&#x3C;p>개발자&#x3C;/p>&#x3C;/div></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>最後に、ユーザー定義コンポーネントは子の出力をそのまま上へ渡す。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용자 정의 컴포넌트는 자식의 출력을 위로 전달</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">아바타_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> img_fiber.output;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">유저정보_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 유저정보_div_fiber.output;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">프로필_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 프로필_div_fiber.output;</span></span></code></pre></figure>
<h2 id="fiberのスケジューリング"><a class="anchor" href="#fiberのスケジューリング">Fiberのスケジューリング</a></h2>
<p>Fiberの主要な価値が「作業を分割できること」なら、実際にその「分割」を行う場所はどこだろうか。それが<strong>Work Loop</strong>だ。</p>
<h3 id="work-loopfiber走査の心臓部"><a class="anchor" href="#work-loopfiber走査の心臓部">Work Loop：Fiber走査の心臓部</a></h3>
<p>Reactのレンダリングは、<code>ReactFiberWorkLoop.js</code>で定義されたWork Loopから始まる。Reactは状況に応じて二種類のWork Loopを使う。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동기 렌더링: 중단 없이 모든 Fiber를 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopSync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동시성 렌더링: Scheduler가 양보하라고 할 때까지 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopConcurrentByScheduler</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">shouldYield</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>二つの関数の違いに注目してほしい。<code>workLoopSync</code>は<code>workInProgress</code>が<code>null</code>になるまで<strong>無条件に</strong>回り続ける。一方、<code>workLoopConcurrentByScheduler</code>はFiberを一つ処理するたびに<code>shouldYield()</code>を尋ね、Schedulerが譲るよう答えるとループを抜ける。</p>
<p>Schedulerは、今回のタスクが始まってから <strong>5ms</strong> が過ぎたかどうかを見て答える。5msは作業の断片の大きさではなく譲るかどうかを確かめる基準なので、Fiberを一つ処理するのにそれより長くかかれば、その作業は分割されない。同じファイルには、Transitionのような非アイドル作業を25msごとに譲らせる<code>workLoopConcurrent</code>もある。しかしこの関数は<code>enableThrottledScheduling</code>フラグの後ろにあり、v19.3.0のすべてのビルドでこのフラグはオフになっている。</p>
<h3 id="performunitofwork"><a class="anchor" href="#performunitofwork">performUnitOfWork</a></h3>
<p><code>performUnitOfWork</code>は、一つのFiberノードを処理する関数だ。Fiber走査の核心が、この関数に含まれている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> current</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, unitOfWork, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  unitOfWork.memoizedProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.pendingProps;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (next </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(unitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>beginWork</code>は現在のノードを処理し、最初の子を返す。そして<code>pendingProps</code>を<code>memoizedProps</code>として確定し、子があればその子へ、なければ<code>completeUnitOfWork</code>を呼び出す。</p>
<h3 id="beginwork"><a class="anchor" href="#beginwork">beginWork</a></h3>
<p><code>beginWork</code>はFiberノードを上から下へ走査し、各ノードで必要な計算を行う関数だ。<code>ReactFiberBeginWork.js</code>で定義され、内部ではFiberの<code>tag</code>に応じた巨大な<strong>switch文</strong>で分岐する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">workInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">renderLanes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // bailout 체크: props와 context가 변경되지 않았다면 스킵</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (current </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> oldProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.memoizedProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> newProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress.pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (oldProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> newProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hasContextChanged</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bailoutOnAlreadyFinishedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  switch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress.tag) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FunctionComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateFunctionComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ClassComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateClassComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> HostComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateHostComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SuspenseComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateSuspenseComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ... 약 25가지 이상의 케이스</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>重要なのは、最上部の<strong>bailout判定</strong>だ。propsとcontextが以前と同じなら、<code>bailoutOnAlreadyFinishedWork</code>でそのサブツリー全体を飛ばす。これはReactのパフォーマンス最適化において最も重要な経路の一つだ。</p>
<p><code>beginWork</code>の返り値は、<strong>最初の子Fiber</strong>である。子があればその子が次の<code>workInProgress</code>になり、なければ（<code>null</code>）<code>completeUnitOfWork</code>へ入る。</p>
<h3 id="completework"><a class="anchor" href="#completework">completeWork</a></h3>
<p><code>completeWork</code>は葉ノードから始まり、親方向へ上りながら作業を完了する関数だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  do</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 1. completeWork로 현재 노드의 작업 마무리 (DOM 생성 등)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, completedWork, renderLanes);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 2. 형제가 있으면 형제로 이동 (다시 beginWork 시작)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> siblingFiber</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (siblingFiber </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> siblingFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 3. 형제가 없으면 부모로 올라감</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.return;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>completeWork</code>で実行される主な作業は次のとおりだ。</p>
<ul>
<li><strong>HostComponentの場合</strong>：実際のDOMノードを生成（<code>createInstance</code>）し、子DOMを追加する。すでにDOMが存在する場合は、変更されたpropsを収集して<code>updateQueue</code>へ保存する。</li>
<li><strong><code>bubbleProperties()</code></strong>：子のflagsを<code>subtreeFlags</code>へ集約する。この情報は、コミットフェーズでサブツリーをスキップする最適化に使われる。</li>
</ul>
<p>走査をまとめると、次のようになる。<strong>childに沿って下り（beginWork）→ 葉で完了した後siblingへ移動 → 兄弟がなければreturnに沿って上る（completeWork）</strong>。これがFiberの深さ優先探索の順序である。</p>
<h3 id="schedulerのタスクキュー"><a class="anchor" href="#schedulerのタスクキュー">Schedulerのタスクキュー</a></h3>
<p>ReactのSchedulerパッケージは、内部で<strong>二つの最小ヒープ</strong>を管理する。</p>
<pre><code>timerQueue (대기실)                    taskQueue (실행 대기열)
┌──────────────────┐                  ┌──────────────────┐
│ 아직 시작 시간이     │   startTime      │ 지금 실행 가능한     │
│ 안 된 태스크들       │ ──경과 시──→      │ 태스크들           │
│                  │                  │                  │
│ 정렬: startTime   │                  │ 정렬: expiration  │
│ (빠른 순)          │                  │ Time (임박한 순)   │
└──────────────────┘                  └──────────────────┘
</code></pre>
<p><strong>taskQueue</strong>は「今すぐ実行できる」タスクのキューだ。<code>expirationTime</code>（= startTime + timeout）が小さいほど、つまり期限が近いほど先に実行される。<strong>timerQueue</strong>は「まだ実行時刻になっていない」タスクの待合室だ。現在時刻がstartTimeを超えた瞬間、taskQueueへ移動する。</p>
<p>では、expirationTimeを決めるタイムアウトはどのように定まるのだろうか。各更新には、優先度レベルに応じて固有のタイムアウトが設定される。</p>
<pre><code>우선순위          timeout        만료까지         예시
─────────────────────────────────────────────────────────
Immediate        -1ms          즉시 만료         flushSync
UserBlocking     250ms         0.25초           클릭, 입력
Normal           5,000ms       5초              일반 setState
Low              10,000ms      10초             startTransition
Idle             ~1,073,741,823ms  ~12.4일      오프스크린 렌더링
</code></pre>
<p><strong>Immediate</strong>は生成された瞬間に期限切れになる。taskQueueへ入ると同時に最優先で実行されるのだ。（生まれた瞬間に期限切れとは、少し物悲しい運命ではある。）<strong>UserBlocking</strong>の250msは、人が「反応が遅い」と感じる閾値（100〜300ms）に合わせた値だ。クリックして0.25秒以内に反応がなければ、ユーザーは不快に感じる。<strong>Normal</strong>の5秒は余裕があるように見えるが、これは「最悪の場合でも必ず処理する」という保証だ。実際には、先行する作業が終わればすぐに実行される。<strong>Idle</strong>の約12.4日は、事実上無限である。ほかのすべての作業が終わって初めて実行される。（ブラウザーを閉じずに12日間使い続けることはほぼないので、無限と考えて差し支えない。）</p>
<p>これらのタイムアウト値は、同時に<strong>飢餓状態を防ぐ</strong>仕組みでもある。どれほど優先度が低くても、タイムアウトを過ぎれば期限切れ状態となり、強制的に実行される。高優先度の作業が絶えず入ってきても、低優先度の作業が永遠に無視されることはない。</p>
<h2 id="レンダーフェーズとコミットフェーズ"><a class="anchor" href="#レンダーフェーズとコミットフェーズ">レンダーフェーズとコミットフェーズ</a></h2>
<p>ここまでFiberの構造とスケジューリングを見てきた。ここで、これらすべてがどのように組み合わさり、実際のUI更新が行われるのか、全体の流れを整理しよう。</p>
<p>Fiberは内部で、<strong>レンダーフェーズ</strong>と<strong>コミットフェーズ</strong>という二つの段階を経る。この分離は、Reactの並行処理モデルを可能にする中心的な設計だ。Fiberの動作フローを直接確認したければ、下の画像をクリックしてほしい。</p>
<p><a href="https://storied-centaur-55230f.netlify.app/" target="_blank" rel="noopener noreferrer"><picture><source type="image/webp" srcset="/content/250520/2.720w.webp 720w, /content/250520/2.1440w.webp 1440w, /content/250520/2.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/250520/2.png" alt="React Fiber の動作順序を段階的に見せる可視化デモ。レンダー段階からレイアウト段階までをコンポーネントツリーと作業スタックで追う" width="1680" height="1202" loading="eager" fetchpriority="high" decoding="async"></picture></a></p>
<h3 id="レンダーフェーズ"><a class="anchor" href="#レンダーフェーズ">レンダーフェーズ</a></h3>
<p>レンダーフェーズは、UIに<strong>どのような変更が必要かを計算</strong>する段階だ。この段階では、実際のDOMには何の影響も与えない。そして最も重要な特徴は、<strong>非同期的に中断・再開できる</strong>ことだ。</p>
<p>この段階は、先ほど見た<code>beginWork</code>と<code>completeWork</code>を中心に動作する。</p>
<p><strong>beginWork(fiber)</strong> では、各Fiberのtype（FunctionComponent、ClassComponent、HostComponentなど）に応じて適切な処理を実行する。そして子Fiberノードを生成し、接続する。propsが以前と同じなら、メモ化によってスキップできる（bailout）。</p>
<p><strong>completeWork(fiber)</strong> では、DOM生成処理やエフェクト情報を準備する。そして<code>bubbleProperties()</code>を通じて子のflagsを<code>subtreeFlags</code>へ集約し、親方向へ上りながら情報を補完する。</p>
<p>この段階ではDOMを直接変更しないため、いつでも作業を中断し、後から再開しても、不完全なUIがユーザーに表示されることはない。これがConcurrent Modeの基盤である。</p>
<h3 id="subtreeflags"><a class="anchor" href="#subtreeflags">subtreeFlags</a></h3>
<p>レンダーフェーズでは、各Fiberに必要な副作用が<strong>ビットフラグ</strong>として記録される。<code>ReactFiberFlags.js</code>で定義されている主なフラグを見てみよう。</p>
<ul>
<li><code>Placement</code>：新しいノードをDOMへ挿入</li>
<li><code>Update</code>：DOMプロパティの更新が必要</li>
<li><code>ChildDeletion</code>：子ノードの削除が必要</li>
<li><code>Ref</code>：refの接続・解除が必要</li>
<li><code>Passive</code>：useEffectのコールバック実行が必要</li>
<li><code>Snapshot</code>：getSnapshotBeforeUpdateを実行</li>
<li><code>Callback</code>：ライフサイクルのコールバックを実行</li>
</ul>
<p>以前のReact（およそ16まで）では、<code>firstEffect</code> → <code>nextEffect</code> → <code>lastEffect</code>でつながる連結リストを使い、副作用を持つFiberだけを集めていた。しかしこの方式には、アンマウントされたFiberへの参照が残って<strong>メモリリーク</strong>を起こす問題があり、Suspenseのような新しいパターンを効率的に処理することも難しかった。</p>
<p>React 17からは、このエフェクトリストを削除し、<strong>subtreeFlags方式</strong>へ移行した（<a href="https://github.com/facebook/react/pull/19381" target="_blank" rel="noopener noreferrer">PR #19381</a>）。<code>completeWork</code>の段階で<code>bubbleProperties()</code>が子のflagsを親へ集約する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bubbleProperties</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">completedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.child;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.subtreeFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.flags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  completedWork.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> subtreeFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>この構造の最大の利点は、コミットフェーズで<strong>サブツリー全体をスキップ</strong>できることだ。あるFiberが<code>subtreeFlags &#x26; MutationMask === NoFlags</code>なら、そのサブツリーにはDOM変更が必要なノードが一つもないため、全体を飛ばせる。以前の連結リスト方式では不可能だった最適化である。</p>
<h3 id="コミットフェーズ"><a class="anchor" href="#コミットフェーズ">コミットフェーズ</a></h3>
<p>コミットフェーズは、レンダーフェーズで計算した変更内容を<strong>実際のDOMへ反映</strong>する段階だ。この段階は<strong>常に同期的</strong>に実行され、一度始まると最後まで中断されない。ユーザーが更新途中のUIを見ることを防ぐためである。</p>
<p>コミットフェーズは内部で、次のような詳細な順序で動作する。</p>
<ol>
<li>
<p><strong>変更前フェーズ</strong>：<code>commitBeforeMutationEffects()</code></p>
<ul>
<li>DOMが変更される前に、現在のDOMの状態を読み取る。<code>getSnapshotBeforeUpdate</code>のライフサイクルはここで実行される。この時点では<code>current</code>ツリーがまだ画面の状態を表しているため、DOMのスクロール位置やサイズなどの情報を安全に取得できる。</li>
</ul>
</li>
<li>
<p><strong>変更フェーズ</strong>：<code>commitMutationEffects()</code></p>
<ul>
<li><strong>実際のDOM操作</strong>が行われる段階だ。新しいノードの挿入、既存ノードの変更、不要なノードの削除がすべてここで発生する。<code>componentWillUnmount</code>もこの時点で実行される。まだ<code>current</code>が以前のツリーを指しているため、以前の状態を読めるからだ。</li>
</ul>
</li>
<li>
<p><strong>ツリーの切り替え</strong>：<code>root.current = finishedWork</code></p>
<ul>
<li>ダブルバッファリングの核心だ。workInProgressツリーがcurrentツリーへ昇格する。この切り替えが変更フェーズ後、レイアウトフェーズ前に行われる理由は重要だ。<code>componentWillUnmount</code>は<strong>以前のツリー</strong>を読む必要があるため変更フェーズで実行しなければならず、<code>componentDidMount</code>/<code>componentDidUpdate</code>は<strong>新しいツリー</strong>を読む必要があるためレイアウトフェーズで実行しなければならない。</li>
</ul>
</li>
<li>
<p><strong>レイアウトフェーズ</strong>：<code>commitLayoutEffects()</code></p>
<p>DOMの変更が完了した後、新しいDOMの状態を基にする処理が実行される。</p>
<ul>
<li><code>componentDidMount</code>、<code>componentDidUpdate</code>を実行</li>
<li><code>useLayoutEffect</code>のコールバックを実行</li>
<li>この時点では<code>current</code>がすでに新しいツリーを指しているため、DOMを読むと更新後の値を取得できる</li>
</ul>
</li>
<li>
<p><strong>パッシブエフェクト</strong>（非同期）</p>
<ul>
<li><code>useEffect</code>のクリーンアップとセットアップは別途スケジュールされ、<strong>非同期的</strong>に実行される。これらはDOM変更に依存しない副作用（データ取得、イベント購読など）を処理するため、同期的に実行する必要がない。非同期で処理することで、ブラウザーが先に画面を描画できるよう制御を譲る。</li>
</ul>
</li>
</ol>
<h2 id="concurrent-featuresとfiber"><a class="anchor" href="#concurrent-featuresとfiber">Concurrent FeaturesとFiber</a></h2>
<p>ここまで見てきたFiberのすべての設計（ダブルバッファリング、Laneベースの優先度、中断可能なWork Loop）が、実際にどのようなユーザー体験を可能にするのか、React 18以降のConcurrent Featuresを通じて確認してみよう。</p>
<h3 id="usetransition"><a class="anchor" href="#usetransition">useTransition</a></h3>
<p><code>startTransition(() => setState(...))</code>を呼び出すと、その更新には<code>TransitionLane</code>が割り当てられる。14個のTransitionLaneがラウンドロビン（順番に一つずつ割り当てる方式）で割り当てられ、衝突を防ぐ。</p>
<p>TransitionLaneはSyncLaneやDefaultLaneより優先度が低いため、ユーザー入力のような緊急の更新が入ると、トランジションのレンダリングを<strong>中断</strong>して緊急の更新を先に処理できる。その間、画面には<code>current</code>ツリー（以前の状態）が維持され、トランジションはworkInProgressツリーでバックグラウンド処理される。</p>
<p>ここでダブルバッファリングの価値が光る。中断されたトランジションのレンダリングはworkInProgressツリーにしか影響せず、ユーザーが見る画面（currentツリー）はまったく損なわれない。</p>
<p><code>isPending</code> フラグは、このトランジションがまだ完了していないことを示し、ローディング表示などの処理を可能にする。</p>
<h3 id="usedeferredvalue"><a class="anchor" href="#usedeferredvalue">useDeferredValue</a></h3>
<p><code>useDeferredValue(value)</code>は、初回レンダリングでは渡された<code>value</code>をそのまま返す。それ以降のレンダリングで現在のレンダリングが緊急の場合、以前に記憶した値を返し、TransitionLaneで新しいレンダリングをスケジュールする。遅延されたレンダリングは、Transitionと同じく中断できる。</p>
<p>概念的には<code>startTransition</code>と似ているが、更新をディスパッチする側ではなく、<strong>値を受け取る側</strong>で適用する点が異なる。検索入力欄の文字はすぐ反映しつつ、検索結果リストのレンダリングを遅らせるのが代表的な利用例だ。</p>
<h3 id="suspense"><a class="anchor" href="#suspense">Suspense</a></h3>
<p>コンポーネントが<code>&#x3C;Suspense></code>内でPromiseをthrowすると、<code>throwException</code>がそれをcatchし、そのFiberを<code>Incomplete</code>としてマークする。そして<code>return</code>チェーンを上りながら最も近いSuspense境界を探し、その境界がフォールバックUIを表示するよう切り替える。Promiseがresolveすると、<code>markRootPinged</code>で該当Laneに再試行の印を付け、Reactが中断中のサブツリーを再びレンダリングする。</p>
<p>Concurrent Modeでは、中断中のコンポーネントの<strong>兄弟ノードを続けてレンダリング</strong>できるため、一つのデータ要求がツリー全体のレンダリングをブロックしない。これが可能なのは、Fiberの連結リスト構造によりsiblingへ自由に移動できるからだ。</p>
<h3 id="streaming-ssrとselective-hydration"><a class="anchor" href="#streaming-ssrとselective-hydration">Streaming SSRとSelective Hydration</a></h3>
<p>React 18の<code>renderToPipeableStream</code>はSuspense境界を活用する。</p>
<ul>
<li><strong>サーバー</strong>：Suspense境界が中断すると、フォールバックHTMLを先に送信し、データの準備ができたら後から<code>&#x3C;script></code> タグで実際の内容をストリーミングする</li>
<li><strong>クライアント（Selective Hydration）</strong>：各Suspense境界を<strong>独立して</strong>ハイドレーションできる。ユーザーがまだハイドレーションされていない領域をクリックすると、<code>SelectiveHydrationLane</code>を通じてその境界のハイドレーションを<strong>優先的に</strong>処理してからイベントをディスパッチする</li>
</ul>
<p>これらすべてが可能なのは、各Suspense境界が独立してスケジュール可能なFiberノードだからだ。結局のところ、Fiberアーキテクチャの「作業を分割し、優先度を付け、中断・再開できる」という中心的な設計が、こうした機能の土台となっている。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>この記事の内容を一文でまとめると、<strong>React Fiberは、再帰を反復へ変え、コールスタックをヒープへ移すことで、レンダリングを中断・再開できるようにしたアーキテクチャ</strong>である。</p>
<p>そのために、連結リストベースのツリー構造、ダブルバッファリング、Laneベースの優先度システム、MessageChannelベースのスケジューラーなど、数多くの精巧な設計が組み合わされている。そしてこれらすべては、最終的に<strong>ユーザーが感じるUIの応答性を最大化すること</strong>という目標へ向かっている。</p>
<p>もちろん、Fiberの内部実装はReactのバージョンが上がるたびに変化を続けており、この記事で扱った内容も、ある時点のスナップショットにすぎない。しかし、「作業を分割し、優先度を付け、中断し、再開できる」というFiberの中心的な思想は、今後も変わらないと考えている。</p>
<p>この記事を通じて、React Fiberが単なる面接用キーワードではなく、Reactのすべての機能を支える実行アーキテクチャだということが伝われば幸いだ。唯一の正解はないが、読者の皆さんにもソースコードを直接読み、それぞれの理解を築いていってほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js" target="_blank" rel="noopener noreferrer">Reactソースコード, ReactFiberWorkLoop.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberBeginWork.js" target="_blank" rel="noopener noreferrer">Reactソースコード, ReactFiberBeginWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberCompleteWork.js" target="_blank" rel="noopener noreferrer">Reactソースコード, ReactFiberCompleteWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js" target="_blank" rel="noopener noreferrer">Reactソースコード, ReactFiberLane.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js" target="_blank" rel="noopener noreferrer">Reactソースコード, ReactFiber.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js" target="_blank" rel="noopener noreferrer">Reactソースコード, Scheduler.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/issues/7942" target="_blank" rel="noopener noreferrer">Issue #7942, Fiber Principles</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/37" target="_blank" rel="noopener noreferrer">React 18 WG, New Suspense SSR Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/27" target="_blank" rel="noopener noreferrer">React 18 WG, Concurrent Scheduling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/blog/2022/03/29/react-v18" target="_blank" rel="noopener noreferrer">React v18.0 Blog Post</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[ReactがMessageChannelを使う理由]]></title>
            <link>https://hooninedev.com/ja/250515</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/250515</guid>
            <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、ReactがrequestIdleCallbackではなくMessageChannelで作業を予約する理由について話してみたい。 Fiberを学ぶなかで「ブラウザーが暇なときに少しずつ働く」という説明を読んだのに、実際のReactのソースコードではMessageChannelに出会って混乱したフロントエンド開発者に向けた記事である。先に答えを書くと、requestIdleCallb...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>ReactがrequestIdleCallbackではなくMessageChannelで作業を予約する理由</strong>について話してみたい。</p>
<p>Fiberを学ぶなかで「ブラウザーが暇なときに少しずつ働く」という説明を読んだのに、実際のReactのソースコードでは<code>MessageChannel</code>に出会って混乱したフロントエンド開発者に向けた記事である。先に答えを書くと、<code>requestIdleCallback</code>はReactが望むほど頻繁には呼ばれず、<code>setTimeout</code>はネストすると4msを超える遅延が付く。そのためReactのSchedulerパッケージは、ブラウザーでは人為的な遅延なしに次のマクロタスクを予約できる<code>MessageChannel</code>を使う。その4msが実際にどれほど大きいかは、headless Chromeで測った数字で確かめる。</p>
<h2 id="捨てられた予約方式"><a class="anchor" href="#捨てられた予約方式">捨てられた予約方式</a></h2>
<p>Fiberの概念を説明するときには、<code>requestIdleCallback</code>で作業を分けて実行するコードがよく使われる。ブラウザーにやることがないたびに作業単位を一つずつ処理するモデルである。Reactも最初は実際にこのAPIを使っており、今の形にたどり着くまでにいくつかのPRを経た。</p>
<ul>
<li><strong>2017年1月</strong>：ネイティブの<code>requestIdleCallback</code>を使い、ないブラウザーでは<code>requestAnimationFrame</code>と<code>postMessage</code>で模倣したpolyfillを使うようにした（<a href="https://github.com/facebook/react/pull/8833" target="_blank" rel="noopener noreferrer">PR #8833</a>）。SafariのようにこのAPIがないブラウザーは、最初からpolyfillが担っていた。Safariの正式版には<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#browser_compatibility" target="_blank" rel="noopener noreferrer">2026年10月現在もこのAPIがない</a>。</li>
<li><strong>2018年3月と4月</strong>：ネイティブAPIがあってもpolyfillを使うフラグを入れた。Andrew ClarkはPR本文で、それまで悩まされてきたstarvation（作業が実行の機会を得られず後回しにされ続ける現象）の問題をpolyfillが減らすかを試すためだと書き、再現が難しいので確実なことは言いにくいと付け加えた（<a href="https://github.com/facebook/react/pull/12385" target="_blank" rel="noopener noreferrer">PR #12385</a>）。1か月後、社内でネイティブ実装より良いと判断してフラグを削除し、polyfillに確定した（<a href="https://github.com/facebook/react/pull/12648" target="_blank" rel="noopener noreferrer">PR #12648</a>）。</li>
<li><strong>2018年11月</strong>：polyfillが<code>window</code>に送っていたmessageイベントを<code>MessageChannel</code>に移した。<code>window</code>に送ると、ページのほかのmessageハンドラーまで毎フレーム呼ばれるからだ（<a href="https://github.com/facebook/react/pull/14234" target="_blank" rel="noopener noreferrer">PR #14234</a>）。</li>
<li><strong>2019年7月</strong>：次のvsyncを推測してフレームの終わりに譲る方式の代わりに、messageイベントの中で5ms働いて譲るループを実験フラグとして入れた（<a href="https://github.com/facebook/react/pull/16214" target="_blank" rel="noopener noreferrer">PR #16214</a>）。このループは<code>requestAnimationFrame</code>をまったく使わない。</li>
<li><strong>2019年8月と11月</strong>：8月に性能テストでmessageループのほうがCPU利用効率が良かったという報告（<a href="https://github.com/facebook/react/pull/16271" target="_blank" rel="noopener noreferrer">PR #16271</a>、マージされず）があり、それを経て11月にrAF実装を削除した（<a href="https://github.com/facebook/react/pull/17252" target="_blank" rel="noopener noreferrer">PR #17252</a>）。</li>
</ul>
<p>starvationがなぜ起きるかは、<code>requestIdleCallback</code>の定義から推し量れる。<a href="https://w3c.github.io/requestidlecallback/" target="_blank" rel="noopener noreferrer">W3C仕様</a>はアイドル期間（idle period）をブラウザーが決めるものとし、フレームとフレームの間の余った時間をその例の一つに挙げる。アニメーション中はこの期間が頻繁に来るが、60Hzの画面では普通16msより短い。メインスレッドが長いタスクで忙しければこれさえ減り、Reactの作業はその分後回しになると筆者は見ている。Dan Abramovも2018年8月のissueコメントで、ReactがこのAPIを使うのをやめた理由を「it's not as aggressive as we need」と書いている（<a href="https://github.com/facebook/react/issues/11171#issuecomment-417349573" target="_blank" rel="noopener noreferrer">facebook/react#11171</a>）。</p>
<p>その後の<code>requestAnimationFrame</code>方式を捨てた理由はPR #16214の本文にある。この方式は次のvsync（ディスプレイが画面を更新する周期に合わせた信号）のタイミングを推測しなければならなかった。最初は30fpsを仮定してフレーム長を33.33msとし、連続する二つのフレーム間隔がどちらもそれより短ければ、二つのうち長いほうにフレーム長を縮めた。今回のフレームの締め切りは、フレームが始まった時刻にこの長さを足した値だった（<a href="https://github.com/facebook/react/blob/6dc2734b41aef944e457eaa23ae218952fce0a54/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L123-L336" target="_blank" rel="noopener noreferrer">PR #17252直前のSchedulerHostConfig.default.js</a>）。フレーム長を縮める規則しかなかったので、PR本文のとおり、ページを開いた後にリフレッシュレートが上がることは検知できても、下がることは検知できなかった。</p>
<p>messageループはvsync周期のどこにいても5msごとに譲る。PR本文は、こうすればリフレッシュレートが非常に高い画面でもメインスレッドの応答性を保てるだろうと期待していた（「should keep the main thread responsive」）。同じPRは、より頻繁に譲るとほかのブラウザー作業との競合が強まりうるというリスクと、バックグラウンドタブでmessageイベントがどの程度スロットリングされるか分からないという点も併せて書いていた。現在のSchedulerのソースコメントは、ほとんどの作業はフレーム境界に合わせる必要がないと書いている。</p>
<h2 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h2>
<p>rAFを取り除いたあとに残った予約手段が<strong>MessageChannel</strong>だ。以下はReact v19.3.0の<a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/forks/Scheduler.js#L530-L561" target="_blank" rel="noopener noreferrer">Scheduler.js 530〜561行</a>で、コメントは削った。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> schedulePerformWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> localSetImmediate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // Node.js and old IE.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetImmediate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> MessageChannel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'undefined'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // DOM and Worker environments.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We prefer MessageChannel because of the 4ms setTimeout clamping.</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> channel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> port</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> channel.port2;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  channel.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    port.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We should only fallback here in non-browser environments.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>分岐は三つある。今のブラウザーには<code>setImmediate</code>がないので、二つ目の分岐の<code>MessageChannel</code>が選ばれる。ソースコメントによれば、一つ目の分岐はNode.jsと古いIEのためのもので、<code>MessageChannel</code>はNode.jsプロセスが終了しないよう引き止めるが、<code>setImmediate</code>はそうしない（<a href="https://github.com/facebook/react/issues/20756" target="_blank" rel="noopener noreferrer">facebook/react#20756</a>）。そのためJestのnode環境は<code>setImmediate</code>の経路を通る。Jest 27以降のjsdom環境はグローバルから<code>setImmediate</code>が外され（<a href="https://github.com/jestjs/jest/pull/11222" target="_blank" rel="noopener noreferrer">jestjs/jest#11222</a>）、<code>MessageChannel</code>もないので、<code>setTimeout</code>の経路を通る。</p>
<p>Schedulerがこうして次の番を予約する目的は、メインスレッドをブラウザーに返すことである。JavaScriptがメインスレッドを握っている間、ブラウザーは入力を処理することも画面を描くこともできない。ただし、途中で譲るのはTransitionやRetryのように時間を分割するレンダーだけだ。React v19.3.0のReconcilerは、Sync、InputContinuous、Defaultのようなblocking laneが含まれるか、長く待たされて期限切れになったlaneがあるか、<code>forceSync</code>で呼ばれたレンダーなら、譲らずに最後までレンダーする（<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1168" target="_blank" rel="noopener noreferrer">ReactFiberWorkLoop.js 1168行</a>）。</p>
<p>こうしたレンダーでは、ReconcilerはFiberを一つ処理するたびに<code>shouldYield()</code>を尋ねる（<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L3073-L3078" target="_blank" rel="noopener noreferrer">ReactFiberWorkLoop.js 3073〜3078行</a>）。この関数はSchedulerが公開しているもので、実際の判断はScheduler内の<code>shouldYieldToHost()</code>が行う。基準は、今回のメッセージタスクが始まってからの経過時間だ。その時間が<code>frameInterval</code>以上になると譲り、コミットの後に<code>requestPaint()</code>が呼ばれていれば、時間に関係なく譲る。<code>frameInterval</code>の初期値は<code>SchedulerFeatureFlags.js</code>で定義された<code>frameYieldMs</code>、つまり<strong>5ms</strong>である（<a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/SchedulerFeatureFlags.js#L11" target="_blank" rel="noopener noreferrer">11行</a>）。5msは作業の断片の大きさではなく、譲るかどうかを確認する時間の基準である。コンポーネント一つのレンダリングに20msかかれば、その20msは分割されない。ここまではstableビルドの動作だ。experimentalビルドでは同じファイルの<code>enableAlwaysYieldScheduler</code>が有効になっていて、Schedulerは5msを使い切らず、次のタスクがすでに期限切れでない限り、タスクを一つ終えるとすぐにブラウザーへ譲り、<code>requestPaint()</code>の合図も見ない。</p>
<h2 id="settimeoutの4ms遅延"><a class="anchor" href="#settimeoutの4ms遅延">setTimeoutの4ms遅延</a></h2>
<p>譲った後に次の断片を予約するのに、なぜ<code>setTimeout</code>ではなく<code>MessageChannel</code>を使うのか。HTML仕様の<a href="https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps" target="_blank" rel="noopener noreferrer">タイマー初期化手順</a>は、ネストの深さが5を超えた<code>setTimeout</code>の遅延が4msより短ければ4msに引き上げる。headless Chromeで<code>setTimeout(fn, 0)</code>が自分自身を再登録するチェーンを測ると、1〜6回目の呼び出しの間隔は0〜0.1msで、7回目から4〜5ms（たいてい5ms前後）が付いた。<code>MessageChannel</code>のメッセージにはこうした最小遅延がない。メッセージはタスクキューに入るだけなので、その間にブラウザーが入力処理やレンダリングを挟み込むことができ、Schedulerはまさにその隙間を狙っている。</p>
<p>4msは小さく見えるが、5msごとに譲るループでは話が違う。200ms分の作業を5msの断片40個に分け、次の断片を<code>MessageChannel</code>と<code>setTimeout(work, 0)</code>でそれぞれ予約してみた。開発者ツールのコンソールに貼り付ければそのまま動く。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">ms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ms) {} }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> raf</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { frames</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> work</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">round</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ms,'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'frames'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ch.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> work;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> schedule</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ch.port2.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// setTimeout(work, 0) 로 바꿔 비교</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>2026-10-08にmacOSのChrome 154.0.8037.98（headless）で、三つのパターンをそれぞれ8回ずつ実行した結果である。SafariとFirefoxでは測っていない。表の3行目は<code>busy(5); done += 5;</code>を<code>busy(200); done += 200;</code>に替えて、作業を分割しなかったものだ。</p>
<table>
<thead>
<tr>
<th>次の断片の予約</th>
<th>終わるまで</th>
<th>その間に描いたフレーム</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MessageChannel</code></td>
<td>201ms</td>
<td>12〜13</td>
</tr>
<tr>
<td><code>setTimeout(work, 0)</code></td>
<td>約365〜371ms</td>
<td>21〜22</td>
</tr>
<tr>
<td>分割しない</td>
<td>200ms</td>
<td>0</td>
</tr>
</tbody>
</table>
<p><code>setTimeout</code>のほうは、最初の6回の後から断片ごとに4〜5msの空き時間が付き、同じ作業に約1.8倍の時間がかかった。数字は機器や負荷によって揺れるが、約1.8倍という比率は繰り返しても変わらなかった。フレーム数が多いのは作業が遅く終わって測定区間が長くなったためで、応答性が良いという意味ではない。分割しなければ作業は200msで終わるが、その間フレームは一つも描かれない。<code>MessageChannel</code>は同じ200msのうちに作業を終えながら、たいてい約60fpsでフレームを出し続けた。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>まとめると、Reactに必要だったのは、ブラウザーが暇になるまで待つAPIではなく、短く働いてすぐに次の番を予約できるAPIだった。<code>requestIdleCallback</code>はReactが望むほど積極的には呼ばれず、<code>requestAnimationFrame</code>方式はvsyncのタイミングを推測しなければならず、<code>setTimeout</code>はネストすると断片ごとに4ms以上休ませた。その条件を満たしたのが<code>MessageChannel</code>である。</p>
<p>このSchedulerが分けて実行する作業単位であるFiberノードがどんな形をしていて、Work Loopがそれをどう巡回するのかは、<a href="/250520" target="_blank" rel="noopener noreferrer">React Fiber完全攻略</a>で扱う。この記事を読んだ皆さんも、Reactのソースコードで再び<code>MessageChannel</code>に出会ったら、なぜそこにそれがあるのかを一度思い出してみてほしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/v16.0.0/src/renderers/shared/ReactDOMFrameScheduling.js" target="_blank" rel="noopener noreferrer">React 16.0.0のReactDOMFrameScheduling.js</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[BiomeはESLintとPrettierを置き換えられるのか？]]></title>
            <link>https://hooninedev.com/ja/241201</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/241201</guid>
            <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、Biomeというツールについて紹介したい。 筆者のチームでは、WebStormやVSCodeなど異なるIDEを使う環境で、一貫したコードスタイルを維持することにかなり苦労していた。IDEごとに設定ファイルを個別管理する手間もあり、フォーマットの違いによって、コードレビューでロジックとは無関係な指摘が飛び交うことも多かった。 そうした状況でESLintのフォーマット関連ルールがDepreca...]]></description>
            <content:encoded><![CDATA[<p>今回は、Biomeというツールについて紹介したい。</p>
<p>筆者のチームでは、WebStormやVSCodeなど異なるIDEを使う環境で、一貫したコードスタイルを維持することにかなり苦労していた。IDEごとに設定ファイルを個別管理する手間もあり、フォーマットの違いによって、コードレビューでロジックとは無関係な指摘が飛び交うことも多かった。</p>
<p>そうした状況でESLintのフォーマット関連ルールがDeprecatedとなり、新たな選択肢を探す必要が生じた。<strong>Prettier + ESLint</strong>の組み合わせではツール間の競合を避けるための追加設定が必要で、<strong>@stylistic/eslint-plugin-ts</strong>はまだコミュニティの初期段階にあり、安定性の検証が十分ではなかった。そんなときにBiomeというツールに関心を持った。</p>
<p>では、Biomeとは正確にはどのようなツールで、本当にESLintとPrettierを置き換えられるのだろうか？</p>
<h2 id="biomeとは"><a class="anchor" href="#biomeとは">Biomeとは？</a></h2>
<p>BiomeはWebプロジェクト向けのオールインワン（All-in-One）ツールチェーンだ。JavaScript、TypeScript、JSX、CSS、JSON、GraphQLなどのコードフォーマットとLintを一つのツールで統合的に提供する。ESLintとPrettierがそれぞれ担ってきた役割を、単一のバイナリで解決することが中核となる思想だ。</p>
<p>Biomeの前身は<a href="https://github.com/rome/tools" target="_blank" rel="noopener noreferrer">Rome</a>である。<strong>Rome Tools Inc.</strong> は2021年に450万ドルのベンチャー投資を獲得して意欲的にスタートしたものの、2023年半ばに全社員が解雇され、リポジトリもアーカイブされた。その後、主要コントリビューターがプロジェクトをフォークし、2023年8月にBiomeとして再出発した。Rome時代の「大言壮語、成果不足」というイメージから脱し、実用的で継続的なリリースによって信頼を築いている。</p>
<p>最大の特徴はRustで書かれていることだ。それが性能にどのような違いをもたらすのかは、後ほど詳しく取り上げる。</p>
<h2 id="なぜbiomeを使うのか"><a class="anchor" href="#なぜbiomeを使うのか">なぜBiomeを使うのか？</a></h2>
<p>Biomeを選ぶ理由は、大きく三つにまとめられる。</p>
<p><strong>一つのツールでフォーマットとLintの両方を処理できる。</strong> ESLint + Prettierの組み合わせでは、二つのツール間でルールが競合しないよう、<code>eslint-config-prettier</code>のような追加設定が必要だった。Biomeはこの複雑さを根本から取り除く。</p>
<p><strong>圧倒的な性能を持つ。</strong> 公式ベンチマークによると、Prettierより約35倍、ESLintより約15倍高速だ。この数値が実際にどの程度なのかは、後ほど直接比較する。</p>
<p><picture><source type="image/webp" srcset="/content/241201/1.720w.webp 720w, /content/241201/1.1440w.webp 1440w, /content/241201/1.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/1.png" alt="Biome 公式サイトのフォーマット速度比較。2,104 ファイル 171,127 行で Biome 0.41 秒、Prettier 14.35 秒と約 35 倍の差" width="1680" height="736" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong>既存ツールとの互換性がある。</strong> Prettierと約97%のフォーマット互換性を提供し、ESLintの主要ルールをビルトインで備えている。<code>eslint-plugin-react-hooks</code>や<code>eslint-plugin-jsx-a11y</code>など、よく使われるプラグインのルールも内蔵されているため、移行の負担は比較的小さい。</p>
<h2 id="どう使うのか"><a class="anchor" href="#どう使うのか">どう使うのか？</a></h2>
<p>Biomeの設定は非常に簡単だ。<a href="https://biomejs.dev/guides/getting-started/" target="_blank" rel="noopener noreferrer">公式ドキュメント</a>にも分かりやすく説明されているので、参考にしてほしい。</p>
<p>まずBiomeをインストールする。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> install</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-dev</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-exact</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span></span></code></pre></figure>
<p>次に設定ファイルを生成する。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span></code></pre></figure>
<p>すると<code>biome.json</code>ファイルが作成される。ここにチームのフォーマットとLintのルールを定義すればよい。</p>
<p>IDE拡張機能もインストールする必要がある。VSCodeを使う場合は<a href="https://marketplace.visualstudio.com/items?itemName=biomejs.biome" target="_blank" rel="noopener noreferrer">VSCode Biome</a>、WebStormを使う場合は<a href="https://plugins.jetbrains.com/plugin/22761-biome" target="_blank" rel="noopener noreferrer">WebStorm Biome</a>プラグインをインストールしよう。</p>
<p>最後に、VSCodeの<code>settings.json</code>へ以下の設定を追加すると、保存時にフォーマットとLintが自動で適用される。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.defaultFormatter"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"biomejs.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.codeActionsOnSave"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "quickfix.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "source.organizeImports.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<h2 id="直接比較してみよう"><a class="anchor" href="#直接比較してみよう">直接比較してみよう</a></h2>
<p>速いと言葉で説明するだけでは実感しにくいため、同じプロジェクトでBiomeとESLint + Prettierを直接比較した。左がBiome、右がESLint + Prettierだ。</p>
<h3 id="viteプロジェクトのローカル実行時間"><a class="anchor" href="#viteプロジェクトのローカル実行時間">Viteプロジェクトのローカル実行時間</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome1.720w.webp 720w, /content/241201/biome1.798w.webp 798w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome1.png" alt="Biome を使った Vite 開発サーバーの起動結果。506ms で準備完了" width="798" height="330" loading="lazy" fetchpriority="low" decoding="async"></picture>  <picture><source type="image/webp" srcset="/content/241201/lint1.720w.webp 720w, /content/241201/lint1.766w.webp 766w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint1.png" alt="ESLint と Prettier を使った Vite 開発サーバーの起動結果。630ms で準備完了" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biomeは<strong>506ms</strong>、ESLint + Prettierは<strong>630ms</strong>で、約20%速い実行時間を示した。</p>
<h3 id="viteプロジェクトのビルド時間"><a class="anchor" href="#viteプロジェクトのビルド時間">Viteプロジェクトのビルド時間</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome2.720w.webp 720w, /content/241201/biome2.920w.webp 920w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome2.png" alt="Biome を使った Vite のビルド結果。117.13 秒" width="920" height="222" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint2.720w.webp 720w, /content/241201/lint2.1022w.webp 1022w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint2.png" alt="ESLint と Prettier を使った Vite のビルド結果。131.48 秒" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biomeは<strong>117.13s</strong>、ESLint + Prettierは<strong>131.48s</strong>で、約10%速いビルド時間を示した。</p>
<h3 id="lint処理"><a class="anchor" href="#lint処理">Lint処理</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome3.720w.webp 720w, /content/241201/biome3.894w.webp 894w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome3.png" alt="Biome の lint 実行結果。0.79 秒、CPU 0.470 秒" width="894" height="174" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint3.720w.webp 720w, /content/241201/lint3.974w.webp 974w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint3.png" alt="ESLint の lint 実行結果。16.32 秒、CPU 8.600 秒" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>最も大きな差が現れたのはLint処理だった。Biomeは<strong>0.79s</strong>（CPU 0.470s）、ESLintは<strong>16.32s</strong>（CPU 8.600s）で、<strong>Biomeが約20倍高速な性能</strong>を示した。CPU使用量もはるかに効率的だった。</p>
<p>開発環境でも体感差は大きいが、CI/CDパイプラインで数百のファイルを検査すると、その差はさらに劇的に広がる。Biomeはnpmでインストールせずにバイナリを直接実行できるため、CIのコールドスタート時間まで短縮できる。</p>
<p><picture><source type="image/webp" srcset="/content/241201/3.265w.webp 265w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/3.jpeg" alt="あごに手を当てて考え込むパトリックのキャラクター" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>うーん……（ここまで来ると、使わない理由を探すほうが難しい。）</p>
<h2 id="なぜこれほど速いのか"><a class="anchor" href="#なぜこれほど速いのか">なぜこれほど速いのか？</a></h2>
<p>「Rustで作られているから速い」というのは正しいが、それだけでは説明が足りない。Biomeの性能優位を生み出している具体的な技術要因を見ていこう。</p>
<h3 id="rustの低レベル性能"><a class="anchor" href="#rustの低レベル性能">Rustの低レベル性能</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="同時接続 10、100、300 での平均 CPU 使用率の比較。Node.js は 89〜91% でほぼ一定、Rust は 145〜206% と複数コアを使う" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="同じ条件での平均メモリ使用量の比較。Node.js は 64〜89MB、Rust は 6〜14MB" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
</tr>
</thead>
</table>
<p>Biomeはシステムプログラミング言語であるRustで書かれている。Rustはゼロコスト抽象化（Zero-cost Abstraction）を志向する言語で、高水準の抽象化を使っても、手動で最適化した低レベルコードと同等の性能を発揮する。また、ガベージコレクター（GC）を使わず、所有権（Ownership）システムによってメモリを管理するため、GCによるランタイムオーバーヘッドが発生しない。</p>
<p>一方、ESLintとPrettierはJavaScriptで書かれ、Node.jsランタイム上で動作する。V8エンジンのJIT（Just-In-Time）コンパイルがJavaScriptを最適化するものの、インタプリタ言語の根本的な制約とガベージコレクションのコストを完全には避けられない。</p>
<h3 id="単一パースアーキテクチャ"><a class="anchor" href="#単一パースアーキテクチャ">単一パースアーキテクチャ</a></h3>
<p>Biomeは一つのパーサー（Parser）でコードを一度だけパースし、AST（Abstract Syntax Tree、抽象構文木）を生成する。このASTをフォーマットとLintの両方で再利用する。</p>
<p>ESLint + Prettierの組み合わせを使うとどうなるだろうか。ESLintがコードをパースしてASTを作り、Lintを実行した後、Prettierが同じコードを再びパースして別のASTを作り、フォーマットを実行する。同じファイルに対してパースが二度発生するわけだ。Biomeの単一パースアーキテクチャは、この重複を根本から排除する。</p>
<h3 id="ネイティブ並列処理"><a class="anchor" href="#ネイティブ並列処理">ネイティブ並列処理</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/7.720w.webp 720w, /content/241201/7.800w.webp 800w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/7.png" alt="同期処理と非同期処理の比較図。同期はリクエストとレスポンスが交互に進み、非同期は続けて送って後から受け取る" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Rustの並行処理モデルを活用し、Biomeは複数のスレッドでファイルを並列処理する。作業を小さな単位に分割し、Work-stealingスケジューラーによってスレッド間の負荷を効率よく分散する。Rustの所有権システムはコンパイル時にデータ競合（Data Race）を根本から防ぐため、ランタイムでの同期コストも最小限に抑えられる。</p>
<p>Node.jsは基本的にイベントループベースのシングルスレッドモデルだ。Worker Threadsを使えば並列処理は可能だが、スレッド生成とメッセージパッシングによる追加のオーバーヘッドが発生する。BiomeはOSレベルのネイティブスレッドを直接活用するため、こうしたオーバーヘッドなしにCPUコアを最大限利用できる。</p>
<h3 id="メモリ効率に優れたast処理"><a class="anchor" href="#メモリ効率に優れたast処理">メモリ効率に優れたAST処理</a></h3>
<p><img src="/content/241201/4.svg" alt="構文木の例。左は単項演算子の -1、右は二項演算子の 1 + 2 を演算子ノードと整数ノードで表す" width="446" height="360" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>BiomeはCST（Concrete Syntax Tree、具象構文木）を使用する。Biomeの公式アーキテクチャドキュメントによると、このCSTはrowanライブラリの内部フォークを基盤に実装したGreen/Red Treeパターンで、コメントや空白など、元のコードに含まれるすべての情報を保持する。RowanのArena方式のメモリ割り当ては、ノードを連続したメモリ領域に配置してCPUのキャッシュ局所性（Cache Locality）を高め、不要なオブジェクト割り当てを最小限に抑える。</p>
<p>JavaScriptのオブジェクトベースのAST処理では、各ノードが独立したヒープオブジェクトとして存在するため、メモリが分散し、GCへの負荷が高くなる。Biomeの方式なら、より少ないメモリで、より高速なツリー走査が可能になる。</p>
<h2 id="ではbiomeを導入すべきか"><a class="anchor" href="#ではbiomeを導入すべきか">では、Biomeを導入すべきか？</a></h2>
<p>Biomeの性能と利便性は明らかに魅力的だ。しかし、すべてのプロジェクトに無条件で導入することが正解だとは思わない。現実的な検討事項をいくつか確認しよう。</p>
<h3 id="biomeが適している場合"><a class="anchor" href="#biomeが適している場合">Biomeが適している場合</a></h3>
<ul>
<li><strong>大規模なコードベース</strong>を運用しており、ビルドやLintの性能が重要な場合</li>
<li>CI/CDパイプラインでコード検査の時間を短縮したい場合</li>
<li>ESLint + Prettierの設定の複雑さに疲れた場合</li>
<li>新しいプロジェクトを始めるにあたり、簡潔なツール設定を求める場合</li>
</ul>
<p>筆者のチームも大規模プロジェクトを運用する中で、CIパイプラインのLintに多くの時間を費やしており、開発者も遅いLint速度に不便を感じていたため、Biomeの導入を決めた。</p>
<h3 id="注意すべき点"><a class="anchor" href="#注意すべき点">注意すべき点</a></h3>
<p><strong>プラグインエコシステムの制約が最も大きい。</strong> ESLintには数千のコミュニティプラグインが存在するが、Biomeはビルトインルールを中心に運営されている。<code>eslint-plugin-react</code>、<code>eslint-plugin-react-hooks</code>、<code>eslint-plugin-jsx-a11y</code>、<code>eslint-plugin-unicorn</code>、<code>typescript-eslint</code>など主要プラグインのルールは相当数が内蔵されているものの、各プラグインのすべてのルールが移植されたわけではない。Biome v2ではGritQLベースのプラグインシステムの導入が予告されているが、まだ実験的な段階だ。<code>@next/eslint-plugin-next</code>や<code>eslint-plugin-angular</code>のようなフレームワーク固有のルールが不可欠なプロジェクトでは、慎重に移行する必要がある。</p>
<p><strong>言語サポートの範囲も確認が必要だ。</strong> JavaScript、TypeScript、JSX、CSS、JSON、GraphQLは安定してサポートされているが、VueやSvelteのSFC（Single File Component）ファイルは<code>&#x3C;script></code>ブロックのみ部分的に対応している。HTML、YAML、Markdownはまだサポートされていない。</p>
<p><strong>ESLintも進化していることを忘れてはいけない。</strong> ESLint v9（2024年4月）で導入されたFlat Config（<code>eslint.config.js</code>）は、従来の<code>.eslintrc</code>方式の複雑さを大幅に軽減した。さらに<code>@eslint/json</code>（2024年10月）と<code>@eslint/css</code>（2025年2月）をリリースし、JavaScript以外の言語にもLintの範囲を広げている。ESLint Stylistic（<code>@stylistic/eslint-plugin</code>）プロジェクトは、PrettierなしでもESLintだけでフォーマットを処理できる選択肢を提供する。Biomeの「オールインワン」という利点が、ESLintエコシステムの進化によってやや薄まりつつある構図だ。</p>
<p>また、RomeからBiomeへ移行した歴史も覚えておく必要がある。Romeがアーカイブされた際に既存ユーザーが経験した不便は、ツール選びにおいてプロジェクトの持続可能性がいかに重要かを示す事例だ。幸いBiomeはOpenCollectiveとGitHub Sponsorsによる資金で運営され、安定したリリースサイクルを維持している。</p>
<p><picture><source type="image/webp" srcset="/content/241201/8.720w.webp 720w, /content/241201/8.1440w.webp 1440w, /content/241201/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/8.png" alt="npm trends の 1 年間の週間ダウンロード数。eslint と prettier は 3,000 万〜5,000 万で、biome は軸の底に張り付いている" width="1680" height="854" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>npm trendsを見ると、Biomeの週間ダウンロード数は約690万で、ESLintの約1億2,000万、Prettierの約8,200万とはまだ大きな差がある。しかし、Biomeの成長速度は注目に値する。わずか1年あまりで週間ダウンロード数が3〜4倍以上に増え、特に新規プロジェクトでの採用率が目に見えて高まっている。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>BiomeがESLintとPrettierを完全に置き換えられるかという問いに対する筆者の答えは、「<strong>まだだが、十分に有力な選択肢</strong>」である。</p>
<p>性能は圧倒的で、設定は簡潔、開発速度も速い。ただし、プラグインエコシステムの未成熟さと一部言語サポートの制約は、プロジェクトによっては障害になり得る。プロジェクトの技術スタックとチームの要件を綿密に検討した上で、導入の可否を判断するのが望ましい。</p>
<p>一つ確かなのは、フロントエンドツールのエコシステムが「より速く、より簡潔で、より統合された」方向へ進んでいることだ。Biomeがその流れの先頭に立っていることは否定できない。今後の成長が期待されるツールであることは間違いない。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>JavaScript</category>
        </item>
        <item>
            <title><![CDATA[Zustand、お前は何者で、なぜProviderLessなんだ？]]></title>
            <link>https://hooninedev.com/ja/240818</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/240818</guid>
            <pubDate>Sun, 18 Aug 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[今回の記事では、ZustandがどのようにProviderなしで状態管理を実現しているのかを取り上げる。 筆者はZustandを使いながら、Providerなしで状態を管理することをずっと当たり前に感じていた。ところが、ふと疑問が浮かんだ。Reactエコシステムの大半のライブラリでは、Providerでアプリをラップすることが、ほとんど儀式のように定着している。TanStack React Que...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、ZustandがどのようにProviderなしで状態管理を実現しているのかを取り上げる。</p>
<p>筆者はZustandを使いながら、Providerなしで状態を管理することをずっと当たり前に感じていた。ところが、ふと疑問が浮かんだ。Reactエコシステムの大半のライブラリでは、Providerでアプリをラップすることが、ほとんど儀式のように定着している。TanStack React Queryは<code>QueryClientProvider</code>でラップしなければ<code>useQuery</code>を使えず、tossのoverlay-kitも<code>OverlayProvider</code>なしでは<code>overlay.open()</code>を呼び出せない。ReactのContext APIも、必ずProviderでコンポーネントツリーをラップする必要がある。それなのに、Zustandはいったいどんな魔法を使って、この手順を不要にしているのだろうか？</p>
<p>気になってZustandのソースコードを直接読み解いてみると、思った以上に興味深い構造が隠れていた。その過程で分かったことを整理してみたい。</p>
<h2 id="reactでは状態がどのように流れるのか"><a class="anchor" href="#reactでは状態がどのように流れるのか">Reactでは状態がどのように流れるのか</a></h2>
<p>一般的なReactアプリケーションでは、状態は下図のように動作する。</p>
<p><picture><source type="image/webp" srcset="/content/240818/3.720w.webp 720w, /content/240818/3.880w.webp 880w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/3.png" alt="React のデータフロー図。データは props で下に流れ、イベントは上に伝わる" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>コンポーネント内部の状態は、Reactが提供する状態管理フック（<code>useState</code>、<code>useReducer</code>）を使って管理する。そして、子コンポーネントへの状態の受け渡しはpropsを通じて行われる。ここまでは単純な話だ。</p>
<p>問題は、離れたコンポーネント間で状態を共有しなければならないときに起きる。このときReactが提供する公式の解決策がContext APIだが、これは必ずProviderコンポーネントで配下のツリーをラップしなければならない。</p>
<h3 id="なぜcontext-apiにはproviderが必要なのか"><a class="anchor" href="#なぜcontext-apiにはproviderが必要なのか">なぜContext APIにはProviderが必要なのか？</a></h3>
<p>この問いに答えるには、Reactの内部動作を少し見る必要がある。</p>
<p>ReactはコンポーネントツリーをFiberという内部データ構造で管理している。各Fiberノードは親子関係で接続されており、Contextの値が変わると、ReactはこのFiberツリーを上から下へ走査し、そのContextを購読しているコンポーネントを探して再レンダリングをトリガーする。</p>
<p>要点はこれだ。<strong>Contextの値の伝播はFiberツリーの構造に依存する。</strong> Providerがツリーのどの位置にあるかによって値が伝わる範囲が決まり、<code>useContext</code>を呼び出したコンポーネントは、自身の上位Fiberツリーをさかのぼって最も近いProviderを探す。Providerがなければ？ <code>createContext</code>に渡したデフォルト値が使われるだけだ。</p>
<p>つまり、Context APIはReactのレンダリングシステムと密接に結合している。状態の保存、伝播、購読はすべてReactのコンポーネントツリー内部で行われる。</p>
<p>では、Zustandはこの構造をどのように回避しているのだろうか？</p>
<h2 id="zustandはreactの外側に存在する"><a class="anchor" href="#zustandはreactの外側に存在する">ZustandはReactの外側に存在する</a></h2>
<p><picture><source type="image/webp" srcset="/content/240818/4.720w.webp 720w, /content/240818/4.1300w.webp 1300w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/4.png" alt="Flux パターンの図。Action、Dispatcher、Store、View が一方向につながる" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>ZustandはFluxパターンに基づいて動作する。クロージャ内部の<code>state</code>がStore、ユーザー定義関数がAction、<code>set</code>関数がDispatcher、ReactコンポーネントがViewの役割を担う。ここに決定的な違いがある。</p>
<p><strong>ZustandのストアはReactコンポーネントツリーの外側、JavaScriptモジュールのスコープ内に存在する</strong>。コンポーネントツリーの外側という表現は、React内部の状態管理とは異なり、Zustandの状態がReactのFiberツリーとは無関係に独立して存在することを意味する。どのコンポーネントでも<code>import</code>さえすればストアにアクセスでき、Providerでアプリをラップする必要はない。（グローバル変数のようにどこからでもアクセスできる一方、クロージャによって適切に保護されているわけだ。）</p>
<p>どうしてこれが可能なのだろうか？ 次のコードを見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { create } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> useStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> create</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  increment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p>このコードで<code>create</code>が呼び出されるのは、モジュールがロードされる時点だ。つまり、Reactがレンダリングを始める前に、ストアはすでにメモリ上に存在している。これが<strong>モジュールレベルシングルトン（Module-level Singleton）パターン</strong>だ。</p>
<h3 id="モジュールレベルシングルトンとは"><a class="anchor" href="#モジュールレベルシングルトンとは">モジュールレベルシングルトンとは？</a></h3>
<p>JavaScriptのESモジュールシステムは、<strong>モジュールを最初の一度だけ評価（evaluate）し、その結果をキャッシュ</strong>する。その後、どこから同じモジュールを<code>import</code>しても、新たに実行するのではなく、キャッシュされた同一のオブジェクトを返す。つまり、コンポーネントAで<code>import { useStore } from './store'</code>を行っても、コンポーネントBで行っても、両方が<strong>まったく同じストアインスタンス</strong>を参照する。</p>
<p>別途シングルトンクラスを実装したり、グローバル変数（<code>window.store</code>）に紐付けたりする必要はない。モジュールシステム自体が、「一度だけ生成され、どこからでも同じインスタンスにアクセスする」というシングルトンの条件を自然に満たしてくれる。Zustandはこの言語レベルの保証をそのまま活用し、別途Providerを用意しなくても、すべてのコンポーネントが1つのストアを共有できるよう設計されている。</p>
<p>ここまで読むと、自然に1つの疑問が浮かぶ。それでは、Zustandの内部は具体的にどうなっているのだろうか？</p>
<h2 id="zustandの内部構造"><a class="anchor" href="#zustandの内部構造">Zustandの内部構造</a></h2>
<p><a href="https://github.com/pmndrs/zustand/tree/main/src" target="_blank" rel="noopener noreferrer">ZustandのGitHubリポジトリ</a>を見ると、コアロジックは驚くほど簡潔だ。大きく2つのファイルが中核を担っており、<code>vanilla.ts</code>がストア本体を、<code>react.ts</code>がReactとの橋渡しを担当する。</p>
<h3 id="vanillats"><a class="anchor" href="#vanillats">vanilla.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts" target="_blank" rel="noopener noreferrer">vanilla.ts</a>はZustandの心臓部だ。ストアがどのように生成され、状態がどのように管理されるかが、この1ファイルにすべて収められている。より簡単に言えば、クロージャに閉じ込められた状態と、その状態を操作する関数がこのファイルに定義されている。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStoreImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CreateStoreImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createState></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Listener</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prevState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> listeners</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'setState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">partial</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> nextState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> partial </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (partial </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)(state)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> partial</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">is</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(nextState, state)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> previousState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        (replace </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'object'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">          ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">          :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({}, state, nextState)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state, previousState))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'getState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getInitialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'getInitialState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    initialState</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> subscribe</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'subscribe'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(listener)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(listener)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { setState, getState, getInitialState, subscribe }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>このコードを1行ずつ読み解くと、Zustandの中核メカニズムが見えてくる。</p>
<h4 id="クロージャによる状態のカプセル化"><a class="anchor" href="#クロージャによる状態のカプセル化">クロージャによる状態のカプセル化</a></h4>
<ul>
<li>
<p><code>let state: TState</code>という変数が、<code>createStoreImpl</code>関数のローカル変数として宣言されている。この変数は関数の実行終了後も<code>setState</code>、<code>getState</code>などの内部関数から参照され続けるため、ガベージコレクションされない。これがクロージャの本質だ。</p>
</li>
<li>
<p>外部から<code>state</code>変数へ直接アクセスする方法はない。<code>getState()</code>で読み、<code>setState()</code>で書くことしかできない。（オブジェクト指向でいうprivateフィールドをクロージャで実装したようなものだ。）</p>
</li>
</ul>
<h4 id="objectisを利用した変更検出"><a class="anchor" href="#objectisを利用した変更検出"><code>Object.is</code>を利用した変更検出</a></h4>
<ul>
<li>
<p><code>setState</code>は新しい状態を計算した後、<code>Object.is(nextState, state)</code>で既存の状態と比較する。参照が同一なら何も起こらない。これが不要な再レンダリングを防ぐ最初の防衛線だ。</p>
</li>
<li>
<p>ただし、この<code>Object.is</code>による比較は<strong>厳密な参照同一性（strict reference equality</strong>）の検査なので、利用側が注意すべき点がある。プリミティブ値（数値や文字列など）を1つだけ取り出して使う場合は問題ない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> count</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.count);</span></span></code></pre></figure>
<p>しかし、selectorが<strong>新しいオブジェクトを返す</strong>場合は話が変わる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">count</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: state.count,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  name: state.name,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p><code>{ count, name }</code>オブジェクトは、値が同じでも呼び出すたびに新しい参照が作られる。<code>Object.is</code>は内部のプロパティを比較せず参照だけを比較するため、Zustandから見ると「状態が変わった」と判断され、毎回再レンダリングがトリガーされる。</p>
<p>この問題を解決するために、Zustandは<code>useShallow</code>フックを提供している。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { useShallow } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand/react/shallow'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">count</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count, name: state.name }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p><code>useShallow</code>は、返されたオブジェクトの<strong>トップレベルのプロパティを1つずつ比較</strong>し、実際に値が変わった場合にだけ再レンダリングを発生させる。Reduxの<code>useSelector</code>がデフォルトでは参照比較を使いながら、<code>shallowEqual</code>を第2引数として渡せるのと似た考え方だ。（ただし、<code>useShallow</code>は名前どおり「浅い」比較なので、ネストしたオブジェクトの内部までは追跡しないことを覚えておこう。）</p>
</li>
</ul>
<h4 id="pubsubパターンのリスナーシステム"><a class="anchor" href="#pubsubパターンのリスナーシステム">Pub/Subパターンのリスナーシステム</a></h4>
<ul>
<li><code>const listeners: Set&#x3C;Listener> = new Set()</code>という1行が、Zustandの購読システムのすべてだ。状態が変わると、<code>listeners.forEach</code>ですべての購読者に通知する。</li>
<li><code>subscribe</code>を呼び出すとリスナーが<code>Set</code>に追加され、返された関数を呼び出すと<code>Set</code>から削除される。</li>
<li>このパターンが重要なのは、<strong>ReactのFiberツリーから完全に独立した通知システム</strong>だからだ。Providerがツリーを走査して購読者を探すのではなく、ストアが購読者の一覧を直接管理する方式なのだ。</li>
</ul>
<h4 id="初期状態の生成"><a class="anchor" href="#初期状態の生成">初期状態の生成</a></h4>
<ul>
<li>
<p>初期状態を扱う最後の行を見てみよう。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span></code></pre></figure>
<p>1行に多くの処理が凝縮されている。JavaScriptでは、代入演算子（<code>=</code>）は<strong>代入された値そのものを返す</strong>式（expression）だ。つまり、括弧内の<code>state = createState(...)</code>が先に実行されて<code>state</code>へ初期状態が代入され、その戻り値が再び<code>const initialState</code>に代入される。結果として、<code>state</code>と<code>initialState</code>は<strong>同じオブジェクトを参照</strong>する。</p>
<p>では、なぜ同じ値をわざわざ2つの変数に分けて保持するのだろうか？ 要点は、2つの変数の役割が異なることにある。</p>
<ul>
<li><strong><code>state</code></strong> は<code>let</code>で宣言された変数だ。<code>setState</code>が呼び出されるたびに新しい値へ置き換わる。つまり、<strong>現時点で生きている状態</strong>を表す。</li>
<li><strong><code>initialState</code></strong> は<code>const</code>で宣言された変数だ。ストアが生成された時点の状態が永続的に保持される。その後どのような<code>setState</code>が呼び出されても、この値は変わらない。<strong>ストアの最初のスナップショット</strong>というわけだ。</li>
</ul>
<p>この<code>initialState</code>は<code>getInitialState()</code>メソッドを通じて外部へ公開され、<code>react.ts</code>で<code>useSyncExternalStore</code>の<strong>第3引数（サーバースナップショット</strong>）として渡される。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),       </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span></code></pre></figure>
<p>サーバーサイドレンダリング（SSR）環境にはブラウザAPIがなく、ユーザーインタラクションもないため、<code>setState</code>が呼び出されることはない。そのため、サーバーでは常に<code>initialState</code>（= 初期状態）がスナップショットとして使われる。クライアントでhydrationが始まるとき、ReactはサーバーでレンダリングされたHTMLとクライアントの初回レンダリング結果を比較するが、両方が同じ<code>initialState</code>を基準にレンダリングしているため、<strong>hydrationの不一致を防ぐ</strong>ことができる。</p>
</li>
</ul>
<h3 id="reactts"><a class="anchor" href="#reactts">react.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/react.ts" target="_blank" rel="noopener noreferrer">react.ts</a>は、先ほど作成した純粋なJavaScriptストアをReactのレンダリングシステムへ接続する役割を担う。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">StateSlice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReadonlyStoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateSlice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> identity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useDebugValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(slice)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> slice</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>ここで中核となるのは<code>useSyncExternalStore</code>だ。このフックはReact 18で導入されたもので、<strong>Reactの外部に存在する状態ストアをReactのレンダリングサイクルへ安全に統合</strong>するために設計されている。</p>
<p><code>useSyncExternalStore</code>が受け取る3つの引数を見ると、構造が明確になる。（先ほどvanilla.tsで扱った内容とほぼ同じだ。）</p>
<ul>
<li><strong><code>api.subscribe</code></strong>：ストアの変更を購読する関数だ。Reactはこの関数を通じて、「状態が変わったら知らせてほしい」と依頼する。</li>
<li><strong><code>() => selector(api.getState())</code></strong>：現在の状態のスナップショットを返す。Reactはレンダリングのたびにこの関数を呼び出し、最新の状態を取得する。</li>
<li><strong><code>() => selector(api.getInitialState())</code></strong>：サーバーサイドレンダリングで使う初期スナップショットだ。hydrationの過程でサーバーとクライアントの状態の不一致を防ぐ。</li>
</ul>
<p>特に<code>useSyncExternalStore</code>は、Reactの並行モード（Concurrent Mode）で起こり得る<strong>tearing問題</strong>を解決する。Tearingとは、同じレンダーパス内で異なるコンポーネントが、<strong>同じデータソースの異なるスナップショット</strong>を表示してしまう現象だ。</p>
<p>具体的なシナリオを見ると理解しやすい。コンポーネントAが<code>store.value</code>（= 10）を読み、レンダリングを開始する。このときReactが並行モードでレンダリングを<strong>一時停止（yield）<strong>し、ブラウザへ制御を渡す。その間にWebSocketメッセージが届き、<code>store.value</code>が11に変わる。Reactがレンダリングを再開すると、コンポーネントBは<code>store.value</code>（= 11）を読む。結果として、同じフレームでAは10、Bは11を表示する</strong>引き裂かれた（teared）UI</strong>が生まれる。React 18より前はレンダリングが常に同期的だったため、この問題は発生しなかった。</p>
<p><code>useSyncExternalStore</code>はレンダリング開始時点のスナップショット（<code>getSnapshot</code>）を記録し、レンダリング中に外部ストアが変更されてスナップショットが異なると、それを検出して<strong>レンダリングを最初からやり直す</strong>。これにより、すべてのコンポーネントが同じスナップショットを基にレンダリングされることを保証する。</p>
<p>そして、<code>createImpl</code>関数がこれらすべてを1つにまとめる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateCreator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, [], []>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(createState)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBoundStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api, selector)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useBoundStore, api)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> useBoundStore</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>createStore</code>でvanillaストアを生成し、<code>useBoundStore</code>というカスタムフックでラップした後、<code>Object.assign</code>でストアAPIのメソッド（<code>setState</code>、<code>getState</code>、<code>subscribe</code>など）をフック関数そのものに取り付ける。その結果、返される<code>useBoundStore</code>は<strong>Reactフックであると同時にストアAPIでもある</strong>という二重の性格を持つ。（関数なのにメソッドもある、いかにもJavaScriptらしいパターンだ。）</p>
<h2 id="ほかの状態管理ライブラリはどうだろうか"><a class="anchor" href="#ほかの状態管理ライブラリはどうだろうか">ほかの状態管理ライブラリはどうだろうか？</a></h2>
<p>ここまで理解すれば、自然とほかのライブラリとも比較したくなるだろう。</p>
<p>Jotai、Recoil、MobX、Xstate、Reduxなど、さまざまな状態管理ライブラリが存在するが、筆者が実際に使ったことのあるライブラリを中心に比較してみたい。</p>
<blockquote>
<p>なお、Jotaiとよく比較されていた<strong>Recoil</strong>（Meta）は、2025年1月にリポジトリがアーカイブされ、事実上開発が停止した。React 19への対応も行われていない。アトミックな状態モデルを求めるなら、現時点ではJotaiが唯一の現実的な選択肢だと言える。</p>
</blockquote>
<h3 id="redux"><a class="anchor" href="#redux">Redux</a></h3>
<p>Reduxも内部ではモジュールレベルのストアを使っている。それでは、なぜProviderが必要なのだろうか？</p>
<p>Reduxの<code>&#x3C;Provider store={store}></code>は、React Contextを通じてストアインスタンスをコンポーネントツリーへ<strong>注入（inject）<strong>する。<code>useSelector</code>や<code>useDispatch</code>は、内部で<code>useContext</code>を呼び出してProviderが提供するストアへアクセスする構造だ。ここで重要なのは、ReduxがContextを</strong>状態の伝播チャネルではなく、依存性注入（Dependency Injection）の手段</strong>として使っていることだ。Contextを通じて渡されるのは状態値そのものではなく、状態を管理する<strong>ストアオブジェクトへの参照</strong>である。実際の状態の購読と更新は、ストア内部のPub/Subで処理される。</p>
<p>この設計がもたらす利点は明確だ。テスト時に別のストアインスタンスをProviderでラップすれば完全に分離でき、1つのアプリ内で<code>context</code> propを使って複数の独立したストアツリーを構成することもできる。Mark Erikson（Reduxメンテナー）が強調するように、「Contextは転送メカニズム（transport mechanism）であり、状態管理ツールではない」。</p>
<h3 id="jotai"><a class="anchor" href="#jotai">Jotai</a></h3>
<p>JotaiはReduxやZustandとは根本的に異なる<strong>アトミック（atomic）状態モデル</strong>を採用している。1つの大きなストアオブジェクトに状態を集めるのではなく、<strong>各状態の断片を独立したatomに分割</strong>するアプローチだ。（Jotaiの公式ドキュメントでも、「ZustandがReduxに似ているなら、JotaiはRecoilに似ている」と説明されている。）</p>
<p>この構造の重要な違いは、<strong>レンダリングの最適化方法</strong>にある。Zustandは、1つのストアからselectorを通じて必要な部分だけを抽出する<strong>トップダウン（top-down</strong>）のアプローチだ。開発者が<code>useStore((state) => state.count)</code>のようにselectorを直接記述する必要があり、参照同一性（referential equality）を維持するため、場合によってはメモ化が必要になる。一方Jotaiは、atom間の<strong>依存関係グラフ（dependency graph</strong>）を自動的に構築し、特定のatomが変わると、そのatomに依存するコンポーネントだけを正確に再レンダリングする<strong>ボトムアップ（bottom-up</strong>）の伝播を行う。スプレッドシートやキャンバスエディターのように、数十の状態が互いに絡み合う場合、この自動依存関係追跡が大きな力を発揮する。</p>
<p>Providerの観点では、Jotaiは興味深い中間地点に位置する。デフォルトではグローバルストアを使ってProviderなしで動作するが、必要なら<code>&#x3C;Provider></code>でラップして分離されたストアスコープを作成できる。Jotaiの公式ドキュメントの表現を借りれば、Jotaiは「<strong>context first, module second</strong>」で、Zustandは「<strong>module first, context second</strong>」なのだ。</p>
<h3 id="zustandの選択"><a class="anchor" href="#zustandの選択">Zustandの選択</a></h3>
<p>Zustandは最も急進的な選択をした。デフォルトではモジュールレベルのシングルトンであり、Providerがまったく存在しない。この選択がもたらすものは、<strong>きわめてシンプルなAPI</strong>だ。<code>create</code>でストアを作り、コンポーネントでフックを呼び出せば終わりだ。</p>
<p>ただし、「Providerがまったく存在しない」という表現は、正確には<strong>デフォルト設計</strong>についての話だ。v4以降は、<code>createStore</code>（vanillaストア）とReactの<code>createContext</code>を組み合わせて、<strong>スコープ付きストア（Scoped Store）パターン</strong>を実装できる。</p>
<p><a href="https://tkdodo.eu/blog/zustand-and-react-context" target="_blank" rel="noopener noreferrer">TkDodo（React Queryメンテナー）のブログ</a>では、このパターンが詳しく扱われており、彼が示す中心的な主張は次のとおりだ。グローバルシングルトンストアには3つの制約がある。</p>
<ul>
<li><strong>Propsで初期化できない</strong>：モジュールのロード時にストアが生成されるため、サーバーから取得したデータや親コンポーネントのpropsを初期値として渡す方法がない。</li>
<li><strong>テストの分離が難しい</strong>：テストごとにストアを手動でリセットしなければならない。</li>
<li><strong>再利用できない</strong>：同じ構造のストアを必要とするコンポーネントをページに2つレンダリングすると、両者が状態を共有してしまう。</li>
</ul>
<p>この3つをすべて解決するのが、スコープ付きストアパターンだ。中心となるアイデアは、<strong>Contextで状態値を渡すのではなく、ストアインスタンスへの参照を渡す</strong>ことだ。（ReduxのProviderが行っていることとまったく同じ構造である。）</p>
<p>具体的な実装は次のようになる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createStore, useStore } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createContext, useContext, useState } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. 스토어 팩토리 함수 — props를 받아 스토어를 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    items: initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selected: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state.selected);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { selected: next };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. Context 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createSelectionStore>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SelectionContext</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3. Provider — useState로 스토어를 한 번만 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionProvider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">store</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(initialItems));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{store}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 4. 커스텀 훅 — Context에서 스토어를 꺼내 useStore로 구독</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,>(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> store</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(SelectionContext);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">store) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'SelectionProvider가 필요합니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(store, selector);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>これで、同じページに独立したマルチセレクトコンポーネントを必要な数だけレンダリングできる。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 SelectionProvider가 자신만의 스토어 인스턴스를 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'B'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'C'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'X'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Y'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />  {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위 컴포넌트와 상태가 완전히 독립 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>ここで注目すべきなのは、Contextを通じて渡されるものが<strong>状態値ではなくストアオブジェクト</strong>だという点だ。状態値が変わってもContextの<code>value</code>（= ストアへの参照）は変わらないため、<strong>Contextの値の変更による不要な再レンダリングは発生しない。</strong> 実際の再レンダリングは、<code>useStore</code>内部の<code>useSyncExternalStore</code>がselectorに基づいて処理する。Contextの転送役とZustandの購読役が明確に分離されるのだ。</p>
<p>TkDodoは、デザインシステムのマルチセレクトコンポーネントで、このパターンを実際に適用した事例を紹介している。従来の<code>useState</code> + Contextで内部状態を管理する構造は、50個を超える項目でパフォーマンス低下を見せたが、Zustandのselectorベースの購読へ移行することで解決したという。</p>
<p>このパターンは、v3で<code>zustand/context</code>として提供されていた<code>createContext</code>ヘルパーがv4で削除されて以降、<strong>Reactネイティブの<code>createContext</code> + Zustandの<code>createStore</code>/<code>useStore</code>を直接組み合わせる方法</strong>として定着した。v5でもこのAPIはそのまま維持されており、<a href="https://github.com/pmndrs/zustand/blob/main/docs/previous-versions/zustand-v3-create-context.md" target="_blank" rel="noopener noreferrer">Zustand公式ドキュメント</a>でもv4+の移行ガイドとしてこのパターンが案内されている。</p>
<h2 id="providerlessの影"><a class="anchor" href="#providerlessの影">ProviderLessの影</a></h2>
<p>もちろん、Providerがないことは利点ばかりではない。筆者が考える注意すべき点を整理してみよう。</p>
<h3 id="ssrでの状態共有問題"><a class="anchor" href="#ssrでの状態共有問題">SSRでの状態共有問題</a></h3>
<p>モジュールレベルシングルトンは、サーバー環境では危険になり得る。Node.jsサーバーは複数のリクエストを1つのプロセスで処理するが、モジュールはプロセス内で一度しかロードされない。これは、異なるユーザーのリクエストが<strong>同じストアインスタンスを共有</strong>する可能性があるということだ。</p>
<p>Zustandが<code>getInitialState</code>を提供し、<code>useSyncExternalStore</code>の第3引数にサーバースナップショットを渡す理由はここにある。ただし、これだけではリクエスト間の状態分離が完全ではない場合があるため、SSR環境では先に述べたスコープ付きストアパターン（<code>createStore</code> + React Context）を使い、リクエストごとに新しいストアを生成することが推奨される。</p>
<h3 id="テスト分離の難しさ"><a class="anchor" href="#テスト分離の難しさ">テスト分離の難しさ</a></h3>
<p>Providerベースのライブラリでは、テストごとに異なるProviderでラップすれば、ストアは自然に分離される。一方、Zustandのモジュールレベルシングルトンでは、テスト間で状態が漏れることがある。各テストの<code>beforeEach</code>で、ストアを明示的にリセットしなければならない。（筆者もこの問題で一度苦労したことがある。）</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 테스트 파일에서의 스토어 리셋 예시</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">beforeEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>ここでも、スコープ付きストアパターンが解決策になる。Providerでラップする方法なら、各テストで新しいストアを生成して注入できるため、リセットロジックなしで完全な分離が可能だ。</p>
<h3 id="複数インスタンスの欠如"><a class="anchor" href="#複数インスタンスの欠如">複数インスタンスの欠如</a></h3>
<p>1つのアプリケーション内で、同じ構造を持つ独立したストアが2つ必要な場合、Providerパターンなら、それぞれを異なるProviderでラップすればよい。しかし、モジュールレベルシングルトンでは、ストア生成関数を別途呼び出し、異なるストアインスタンスを作る必要がある。たとえば、同じページに独立したタブパネルが2つあり、それぞれの選択状態を個別に管理しなければならない場合、グローバルシングルトンでは自然に表現しづらい。</p>
<p>この場合も、<code>createStore</code> + Contextパターンが正解だ。各タブパネルコンポーネントが独自のProviderをレンダリングすれば、同じストア構造を持つ完全に独立したインスタンスが生成される。Zustandの公式ドキュメントでも、「再利用可能なコンポーネントにストアが必要な場合」にこのパターンを推奨している。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>ここまで見てきた内容をまとめると、ZustandのProviderLess設計は、次の4つのメカニズムの組み合わせによって実現されている。</p>
<ul>
<li><strong>モジュールレベルシングルトン</strong>：ストアがReactコンポーネントツリーの外側、JavaScriptモジュールのスコープ内に生成される。</li>
<li><strong>クロージャによる状態のカプセル化</strong>：<code>vanilla.ts</code>の<code>createStoreImpl</code>で、<code>state</code>変数と<code>listeners</code> Setがクロージャに閉じ込められ、外部からのアクセスが遮断される。</li>
<li><strong>独自のPub/Subシステム</strong>：Fiberツリーの走査ではなく、<code>Set&#x3C;Listener></code>を直接管理して、状態の変更を購読者へ通知する。</li>
<li><strong><code>useSyncExternalStore</code>によるReact統合</strong>：外部ストアの状態変更をReactのレンダリングサイクルへ安全に同期する。</li>
</ul>
<p>結局、Zustandが投げかける問いはこうだ。「状態は必ずReactの中に存在しなければならないのか？」Zustandの答えは明確だ。状態はReactの外に置き、必要なときに橋を架ければよい。その橋こそが<code>useSyncExternalStore</code>だ。</p>
<p>もちろん、このアプローチがあらゆる状況で最善とは限らない。SSR、テストの分離、複数インスタンスといった状況では、Providerベースの設計の方が適している場合がある。唯一の正解はないが、各ライブラリがどのような設計上のトレードオフを選んだのか理解していれば、状況に合ったツールを選べるだろう。</p>
<p>この記事を読んだ方にも、一度は利用しているライブラリのソースコードを直接開いてみることを勧めたい。公式ドキュメントにはない深みを発見できるはずだ。</p>
<p><picture><source type="image/webp" srcset="/content/240818/7.216w.webp 216w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/7.jpeg" alt="「ちょっと待って!!」と言う手描きのキャラクター" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h3 id="それから新しい知らせ"><a class="anchor" href="#それから新しい知らせ">それから、新しい知らせ</a></h3>
<p>上記の内容を調べている中で知ったことだが、<strong>Zustand v5.0.0が2024年10月に正式リリース</strong>された。</p>
<p>興味深いのは、v5には新機能がほとんどないことだ。v4.xですでに新機能を追加しながら既存APIをdeprecatedにしてきており、v5は<strong>整理（cleanup）リリース</strong>としての性格が強い。主な変更点は次のとおりだ。（詳細は<a href="https://github.com/pmndrs/zustand/releases" target="_blank" rel="noopener noreferrer">リリースページ</a>と<a href="https://zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5" target="_blank" rel="noopener noreferrer">移行ガイド</a>を参照してほしい。）</p>
<ul>
<li>最小要件が<strong>React 18、TypeScript 4.5以上</strong>へ引き上げられた。</li>
<li><strong><code>getServerState</code>が削除</strong>された。（<code>useSyncExternalStore</code>の第3引数で代替）</li>
<li><strong>ES5のサポートが終了</strong>した。</li>
<li><code>create</code>関数での<strong>カスタムequality関数の指定が削除</strong>された。</li>
<li>iterableオブジェクトをサポートするように、<strong><code>shallow</code>関数が改善</strong>された。</li>
</ul>
<p>v4からv5へ移行するときは、まずv4の最新バージョンへ更新することが推奨される。v4の最新バージョンではdeprecation警告が表示されるため、それらを先に解消してからv5へ上げれば、無理なく移行できる。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/useSyncExternalStore" target="_blank" rel="noopener noreferrer">React useSyncExternalStore</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://jotai.org/docs/basics/comparison" target="_blank" rel="noopener noreferrer">Jotai Comparison</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://interbolt.org/blog/react-ui-tearing/" target="_blank" rel="noopener noreferrer">InterBolt, Concurrent React, External Stores, and Tearing</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>フロントエンド</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[圧縮アルゴリズムを理解する]]></title>
            <link>https://hooninedev.com/ja/240706</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/240706</guid>
            <pubDate>Sat, 06 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、ソフトウェアの圧縮アルゴリズムについて話してみたい。 大容量のビルド成果物をサーバーやストレージにアップロードしながら、zip、tar.gz、tar.zstのどれを使うか迷っている開発者に向けた記事だ。最後まで読めば、主要な圧縮形式の速度と圧縮率がどこで分かれるのか、そしてtar.gzとtar.zstのどちらを選ぶかを判断する基準が得られる。 社内プロジェクトのデプロイプロセスを改善する仕...]]></description>
            <content:encoded><![CDATA[<p>今回は、ソフトウェアの圧縮アルゴリズムについて話してみたい。</p>
<p>大容量のビルド成果物をサーバーやストレージにアップロードしながら、zip、tar.gz、tar.zstのどれを使うか迷っている開発者に向けた記事だ。最後まで読めば、主要な圧縮形式の速度と圧縮率がどこで分かれるのか、そしてtar.gzとtar.zstのどちらを選ぶかを判断する基準が得られる。</p>
<p>社内プロジェクトのデプロイプロセスを改善する仕事を担当した。大容量のビルド成果物をS3へアップロードする構成だったため、ビルドフォルダーのサイズがアップロード時間とストレージコストに直結することを実感した。そこから自然に「どうすれば効率よく圧縮してアップロードできるだろうか」という疑問が生まれた。</p>
<p>いざ圧縮について調べると、zip、gzip、zstd、bzip2、xzなど、想像以上に多くの種類があった。名前はよく似ているのに、何が違い、どのような場面で何を選べばよいのかを一度に理解できる資料はなかなか見つからなかった。（圧縮ならどれも同じようなものだと思っていたが、世界は広く、圧縮方法も多かった。）</p>
<p>比較に入る前に、共通の根を一つだけ押さえておこう。今日の主流の圧縮ツールは、ほぼすべてが1977年に発表されたLZ77の子孫だ。LZ77は、すでに処理したデータの一定区間（スライディングウィンドウ）を辞書のように使い、繰り返されるパターンを「何文字戻って何文字コピーせよ」という短い参照に置き換える。ウィンドウが大きいほど遠く離れた繰り返しまで捉えられるが、そのぶん計算量とメモリが増える。ZIPとGZIPが使うDEFLATEは、これにハフマン符号化（出現頻度の高いシンボルに短いビット列を割り当てるエントロピー符号化）を組み合わせたものだ。LZ77が兄弟にあたるLZ78とどう分かれたのかは、<a href="/240701" target="_blank" rel="noopener noreferrer">LZ77とLZ78の違い</a>に別途まとめた。</p>
<p>圧縮性能を評価するときは、<strong>圧縮率</strong>、つまりどれだけ小さくなるかと、<strong>圧縮速度</strong>、つまりどれだけ早く終わるかという二つの軸を考える。一般に、高い圧縮率を求めるほど多くの計算が必要になり、圧縮時間も長くなる。この二つの折り合いをどこにつけるかが、現実的な圧縮戦略の要点だ。</p>
<p>それでは、さまざまな圧縮形式を順番に比較してみよう。</p>
<h2 id="zip"><a class="anchor" href="#zip">ZIP</a></h2>
<p>ZIPはPhil Katzが1989年に作ったファイル形式で、内部では一般に<strong>DEFLATEアルゴリズム</strong>、LZ77とHuffman codingの組み合わせを使って圧縮する。重要なのは、ZIPが「圧縮アルゴリズム」そのものではなく「ファイル形式、つまりコンテナ」だという点だ。ZIPという器の中にDEFLATEなどのアルゴリズムで圧縮したデータが入る。</p>
<p>ZIPの特徴は、<strong>各ファイルを個別に圧縮すること</strong>だ。このようにファイルごとに別々に圧縮する構造を非ソリッドアーカイブ（Non-solid Archive）と呼ぶ。すべてのファイルを一つのストリームにつなげて一度に圧縮する反対側はソリッドアーカイブだ。非ソリッド構造のおかげで、アーカイブ内の特定ファイルだけを取り出せる。一方でファイル間の重複データを利用できないため、後述するtar.gzより圧縮率が低くなる場合がある。</p>
<p>Windows、macOS、Linuxなど大半のOSが追加ソフトウェアなしで標準対応しているため、クロスプラットフォーム互換性が重要な場合には最も無難な選択だ。</p>
<h2 id="gzipgnu-zip"><a class="anchor" href="#gzipgnu-zip">GZIP（GNU Zip）</a></h2>
<p>GZIPもZIPと同様、内部では<strong>DEFLATEアルゴリズム</strong>を使う。同じアルゴリズムなのに、なぜ別の形式があるのだろうか。ZIPは複数ファイルを一つのアーカイブへ収めるコンテナの役割まで担うが、GZIPは<strong>単一ファイル、または単一ストリームの圧縮に特化</strong>している。</p>
<p>複数のファイルやディレクトリをGZIPで圧縮するには、先にTARで一つのアーカイブへまとめ、その後GZIPで圧縮する。この二段階の処理によって<code>.tar.gz</code>、または<code>.tgz</code>ファイルが生まれる。</p>
<p>GZIPのファイル構造はRFC 1952で定められており、かなり単純だ。<strong>固定10バイトのヘッダー</strong>、元のファイル名やコメントなどを含められる拡張ヘッダー、DEFLATE圧縮データ、そしてCRC-32チェックサムと元サイズを持つ<strong>8バイトのトレーラー</strong>で構成される。CRC-32は展開後のデータが元データと一致するかを検証する仕組みだ。GZIPはDEFLATEストリームを包む軽量なラッパーだと言える。</p>
<p>DEFLATE内部のスライディングウィンドウは<strong>最大32KB</strong>だ。このサイズはGZIPの圧縮率の上限を左右する。32KBより離れたパターンは参照できないからだ。また、GZIPは1から9までの圧縮レベルを指定できる。レベル1は高速だが圧縮率が低く、およそ60%で、レベル9は遅い代わりに最大およそ75%の圧縮率を達成する。既定値のレベル6は速度と圧縮率のバランスを取った値だ。</p>
<p>Unix/Linux環境では、ソースコード配布、ログファイル圧縮、ソフトウェアパッケージなどで標準のように使われている。WebサーバーのHTTP圧縮でも<code>Content-Encoding: gzip</code>として今なお広く使われているが、この用途では徐々にBrotliへ置き換わりつつある。</p>
<h2 id="zstdzstandard"><a class="anchor" href="#zstdzstandard">ZSTD（Zstandard）</a></h2>
<p>ZSTDはMeta、旧FacebookのYann Colletが開発し、2016年にオープンソースとして公開した圧縮アルゴリズムだ。GZIPと同程度の圧縮率を維持しながら、<strong>圧縮と展開が圧倒的に速い</strong>ことが最大の強みだ。</p>
<p>ZSTDの内部処理は大きく三段階に分かれる。まずLZ77系の<strong>マッチファインダー</strong>（Match Finder）が入力から繰り返しパターンを探す。次に、その一致情報、リテラル、一致長、オフセットを<strong>シーケンス</strong>として符号化する。最後にシーケンスを<strong>エントロピー符号化</strong>で圧縮する。ここではGZIPのハフマン符号化の代わりに、<strong>FSE</strong>（Finite State Entropy）という方式を使う。FSEはANS（Asymmetric Numeral Systems）理論に基づくエントロピーコーダーだ。ANSはシンボルを一つ処理するたびに整数一つで表される状態値を更新し、よく出るシンボルほど少ないビットを出力する。ハフマン符号化に近い速度で、算術符号化（Arithmetic Coding）に近い圧縮率を出すことが目標だ。ハフマン符号化はシンボルごとに整数ビットしか割り当てられないが、FSEは分数ビットに相当する確率を表現でき、理論上の最適値へ近づける。（名前は大げさだが、要点は「同じデータをより少ないビットで表す賢い方法」と考えればよい。）</p>
<p>マッチファインダーの戦略も圧縮レベルによって変わる。低レベルの1〜4では単純なハッシュテーブルで高速に検索し、中間の5〜12では複数候補を比較してより良い一致を選ぶLazy方式を使う。高レベルの13〜22では二分木と動的計画法を用いて、最適に近い一致を探す。1から22まで細かく調整できるため、リアルタイム転送には低レベル、アーカイブには高レベルという柔軟な運用ができる。</p>
<p>Silesia Corpusベンチマークでは、ZSTDの既定レベル3は圧縮約300MB/s、展開約1,200MB/sだ。一方、GZIPの既定レベル6は圧縮約34MB/s、展開約380MB/sにとどまる。<strong>圧縮は約8倍、展開は約3倍速い一方、圧縮率はZSTDの3.17がGZIPの3.09をむしろ上回る。</strong> この数値を見ると、ZSTDがトレードオフをどう改善したかが直感的に分かる。</p>
<p>採用も急速に広がっている。Linuxカーネルのモジュール圧縮やファイルシステムの透過圧縮で使われ、Arch Linux、Fedora、Debian、Ubuntuなど主要ディストリビューションがパッケージ圧縮の標準形式に採用した。2025年2月にリリースされたv1.5.7からは、最大4スレッドの<strong>マルチスレッド圧縮が既定で有効</strong>になり、シングルスレッドのGZIPとの実質的な速度差はさらに広がった。AWSも内部サービスをgzipからzstdへ切り替え、S3ストレージを約30%削減したと説明している。</p>
<h2 id="bzip2"><a class="anchor" href="#bzip2">BZIP2</a></h2>
<p>BZIP2は複数段階の変換を経てデータを圧縮する。中心となるパイプラインは次のとおりだ。</p>
<ol>
<li><strong>RLE（Run-Length Encoding）</strong>：最初のデータに連続する繰り返しを減らす</li>
<li><strong>BWT（Burrows-Wheeler Transform）</strong>：データを圧縮しやすい形へ並べ替える</li>
<li><strong>MTF（Move-to-Front Transform）</strong>：BWTの出力を数値列へ変換する</li>
<li><strong>RLE</strong>：MTF結果の繰り返しをもう一度減らす</li>
<li><strong>Huffman Coding</strong>：最後に頻度ベースの符号化を行う</li>
</ol>
<p>GZIPより高い圧縮率を得られるが、圧縮も展開も遅い。高い圧縮率が必要で、速度の優先度が低いアーカイブ用途に使われてきた。</p>
<p>ただし、最後のリリースは2019年のv1.0.8で、活発な開発は行われていない。圧縮率と速度の両面でZSTDがBZIP2を上回るベンチマークが増え、新規プロジェクトではZSTDを選ぶケースが増えている。</p>
<h2 id="xz"><a class="anchor" href="#xz">XZ</a></h2>
<p>XZは<strong>LZMA2</strong>を使う圧縮形式だ。LZMA（Lempel-Ziv-Markov chain Algorithm）はIgor Pavlovが開発し、LZ77ベースの辞書圧縮と範囲符号化（Range Encoding）を組み合わせている。LZMA2は単純な「LZMAの改良版」というより、LZMAストリームを包む<strong>コンテナ形式</strong>に近い。マルチスレッド圧縮・展開と、圧縮しにくいデータを効率よく扱う仕組みが加わっている。</p>
<p>この記事で扱う形式の中では、<strong>最も高い圧縮率</strong>を誇る。その代わり圧縮速度は非常に遅く、メモリ使用量も大きい。保存容量の削減を最優先するアーカイブに向いている。</p>
<p>しかし2024年3月、XZの中核ライブラリxz-utilsで<strong>バックドアが発見される深刻なサプライチェーン事件、CVE-2024-3094が起きた</strong>。2年にわたるソーシャルエンジニアリング攻撃でメンテナー権限が奪われ、CVSS 10.0という最高深刻度が付いた。主要ディストリビューションは直ちに安全なバージョンへロールバックしたが、この事件はオープンソースのサプライチェーンセキュリティに強い警鐘を鳴らした。（XZ自体の技術的価値は変わらないが、ツール選定ではこの背景も知っておきたい。）</p>
<h2 id="tar"><a class="anchor" href="#tar">TAR</a></h2>
<p>TAR（Tape Archive）は、それ自体が圧縮アルゴリズムではない。複数のファイルとディレクトリを<strong>一つのアーカイブファイルへまとめる</strong>ツール兼形式だ。名前のとおり、もともとは磁気テープへのバックアップ用に作られた。テープは順次アクセスする媒体なので、データを連続して並べる構造が自然だった。</p>
<p>TARの内部構造は意外に単純だ。すべてのデータを<strong>512バイトのブロック</strong>単位で扱う。各ファイルの前には512バイトのヘッダーブロックがあり、ファイル名、最大100バイト、ファイルモード、所有者UID/GID、サイズ、更新時刻、チェックサムなどのメタデータが入る。ファイルデータはヘッダーに続き、512バイトの倍数になるようパディングされる。アーカイブの終端は二つの512バイトゼロブロックで示す。現代の多くのTAR実装はPOSIXの<strong>UStar</strong>（Unix Standard TAR）形式に従い、最大256バイトの長いファイル名と追加メタデータフィールドを扱える。</p>
<p>重要なのは、TARが権限、所有者、タイムスタンプ、シンボリックリンクなどの<strong>Unixファイルシステムメタデータをそのまま保存すること</strong>だ。ZIPではUnix固有のメタデータを完全に保持できない場合があるため、サーバー環境へのデプロイにはTARのほうが適していることが多い。</p>
<p>TARだけではサイズは小さくならない。ヘッダーとパディングによって、むしろ元より少し大きくなる。実際の圧縮はGZIP、BZIP2、XZ、ZSTDなどと組み合わせて行う。<code>.tar.gz</code>、<code>.tar.bz2</code>、<code>.tar.xz</code>、<code>.tar.zst</code>という拡張子が存在するのはそのためだ。TARが「まとめる」役割を、圧縮ツールが「小さくする」役割を担う。これは「一つのことをうまくやれ」という<strong>Unix哲学</strong>の典型例だ。</p>
<p>Unix/Linux環境では標準的なアーカイブ手段だが、Windowsでは7-Zipなどの追加ソフトウェアが必要になる場合がある。</p>
<h2 id="brotliも知っておこう"><a class="anchor" href="#brotliも知っておこう">Brotliも知っておこう</a></h2>
<p>フロントエンド開発者なら、<strong>Brotli</strong>も押さえておく必要がある。BrotliはGoogleが開発した圧縮アルゴリズムで、2015年にHTTPストリーム圧縮仕様<code>Content-Encoding: br</code>として標準化された。</p>
<p>主要ブラウザーはすべてHTTPS環境で対応し、世界での対応率は96%以上だ。GZIPより<strong>約15〜25%高い圧縮率</strong>を示し、特にJavaScript、CSS、HTMLのようなテキストベースの静的ファイルに効果的だ。Cloudflareをはじめとする主要CDNで標準の圧縮方式として採用され、現代のWeb最適化は「Brotli優先、GZIPフォールバック」と整理できる。</p>
<p>ビルド成果物をS3へ置いてCDNから配信する構成なら、静的ファイルをあらかじめBrotliで圧縮することでネットワーク転送を大きく最適化できる。（当時のプロジェクトではすぐ導入できるほどの材料がなかったが、開発者として知っておき、後で再検討する価値はある。）</p>
<h2 id="targzがzipより高い圧縮率を得られる理由"><a class="anchor" href="#targzがzipより高い圧縮率を得られる理由">tar.gzがZIPより高い圧縮率を得られる理由</a></h2>
<p>これは<strong>ソリッドアーカイブ</strong>（Solid Archive）と<strong>非ソリッドアーカイブ</strong>（Non-solid Archive）の違いから生じる。</p>
<p>tar.gzは、TARですべてのファイルを一つの連続データストリームへまとめた後、GZIPがストリーム全体を一度に圧縮する。そのため、<strong>ファイルをまたぐ重複データ</strong>を認識して利用できる。これがソリッドアーカイブ方式だ。ビルドフォルダーに構造の似たJavaScriptバンドルが数十個あれば、ファイルAに現れたパターンをファイルBで再び参照できる。ファイルごとに独立したヘッダー、チェックサム、目次（Table of Contents）を持つ必要もないため、メタデータのオーバーヘッドも減る。</p>
<p>一方、ZIPはファイルごとに独立して圧縮する非ソリッド方式なので、ファイル間の重複を利用できない。ファイルAとBに同じコードブロックがあっても、別々のDEFLATEストリームは互いの存在を知らない。一般にtar.gzがZIPより5〜15%高い圧縮率を得るのはこのためだ。似た構造のファイルが大量に入るビルド成果物ほど差は大きくなる。</p>
<p>ただし、ソリッドアーカイブにも明確な欠点がある。</p>
<ul>
<li>特定のファイルを一つだけ取り出す場合でも、<strong>そのファイルより前にあるデータをすべて先に展開</strong>しなければならないことがある。全ファイルが一つのストリームでつながっており、中間へ直接移動できないからだ。ZIPは個別ファイルへランダムアクセスできるため、特定ファイルを頻繁に取り出す用途ではZIPのほうが適している場合がある。</li>
<li>アーカイブの一部が壊れると、<strong>破損箇所以降のすべてのデータを復旧できなくなる</strong>可能性がある。非ソリッド方式なら壊れた一ファイルだけを失い、残りを救える場合がある。</li>
</ul>
<h2 id="2024年にはtargzを選んだ今ならどうするか"><a class="anchor" href="#2024年にはtargzを選んだ今ならどうするか">2024年にはtar.gzを選んだ。今ならどうするか</a></h2>
<p>この節は2026年に追記した。当時tar.gzを選んだ理由は、互換性と安定性だった。S3へアップロードした後にさまざまな環境で展開する必要があったため、ほぼどこでも使えるtar.gzが安全な選択だった。</p>
<p>しかし同じ状況に今もう一度直面したなら、<strong>tar.zst</strong>（TAR + ZSTD）を真剣に検討する。先ほどのベンチマーク値を思い出してほしい。</p>
<p>GZIP既定レベルの圧縮速度は34MB/sだが、ZSTD既定レベルは300MB/sだ。2GBのビルドフォルダーなら、単純計算でGZIPは約60秒、ZSTDは約7秒で圧縮を終える。ZSTD v1.5.7以降に既定で有効となった最大4スレッドのマルチスレッド処理まで考慮すれば、体感差はさらに大きい。CI/CDパイプラインでは、この差がデプロイのたびに積み重なるため無視できない。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sh" data-theme="github-dark github-light"><code data-language="sh" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># tar.zst 생성 (멀티스레드 자동 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --zstd</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 또는 압축 레벨 지정 (-T0은 사용 가능한 모든 코어 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -I</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zstd -3 -T0'</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span></code></pre></figure>
<p>圧縮率でもZSTDはGZIPと同等か、それ以上だ。「速度のために圧縮率を諦める」というトレードオフは事実上なく、速いうえに成果物も小さい。</p>
<p>ただし受信側の環境でzstdを展開できるかは必ず確認しなければならない。主要Linuxディストリビューションにはすでに含まれ、macOSでもHomebrewの<code>brew install zstd</code>で簡単にインストールできる。一方、レガシーシステムや最小構成の環境では追加インストールが必要な場合があるため、チームが使う全環境を事前に点検することが重要だ。互換性が最優先なら、今でもtar.gzが最も無難な選択だ。</p>
<h2 id="一覧で比較する"><a class="anchor" href="#一覧で比較する">一覧で比較する</a></h2>
<table>
<thead>
<tr>
<th>形式</th>
<th>アルゴリズム</th>
<th>圧縮率</th>
<th>速度</th>
<th>特徴</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ZIP</strong></td>
<td>DEFLATE</td>
<td>中</td>
<td>速い</td>
<td>クロスプラットフォーム、非ソリッド</td>
</tr>
<tr>
<td><strong>GZIP</strong></td>
<td>DEFLATE</td>
<td>中</td>
<td>速い</td>
<td>単一ストリーム、TARと組み合わせる</td>
</tr>
<tr>
<td><strong>ZSTD</strong></td>
<td>Zstandard</td>
<td>高い</td>
<td>非常に速い</td>
<td>レベル調整、現代的な標準</td>
</tr>
<tr>
<td><strong>BZIP2</strong></td>
<td>BWT+MTF+Huffman</td>
<td>高い</td>
<td>遅い</td>
<td>開発がほぼ停止</td>
</tr>
<tr>
<td><strong>XZ</strong></td>
<td>LZMA2</td>
<td>非常に高い</td>
<td>非常に遅い</td>
<td>最高の圧縮率、セキュリティの背景</td>
</tr>
<tr>
<td><strong>Brotli</strong></td>
<td>Brotli</td>
<td>高い</td>
<td>中</td>
<td>Web最適化に特化</td>
</tr>
</tbody>
</table>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>圧縮を掘り下げる前は、正直「ただzipでまとめればよいのでは」と思っていた。しかし2GBを超えるビルドフォルダーを実際に扱うと、どのアルゴリズムを選ぶかによってアップロード時間とコストが大きく変わることを実感した。</p>
<p>それぞれの圧縮形式には異なる設計思想とトレードオフがある。ZIPの互換性、GZIPの汎用性、ZSTDの速度、XZの圧縮率まで、どれか一つを「最高」と断定することはできない。適した選択はプロジェクトの状況によって変わる。</p>
<p>普段何気なく使う道具の原理を調べておけば、次に似た問題へ出会ったとき、よりよい判断ができる。この文章が圧縮アルゴリズムを選ぶ誰かの小さな手がかりになればうれしい。</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>素朴な疑問</category>
            <category>ソフトウェア</category>
        </item>
        <item>
            <title><![CDATA[LZ77とLZ78の違い]]></title>
            <link>https://hooninedev.com/ja/240701</link>
            <guid isPermaLink="false">https://hooninedev.com/ja/240701</guid>
            <pubDate>Mon, 01 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[今回は、LZ77とLZ78がどう違うのかについて話してみたい。 zip、gzip、zstdのような圧縮ツールを使いながら、その中の辞書式圧縮がどう動くのか気になっていた開発者に向けた記事だ。最後まで読めば、二つのアルゴリズムが辞書を扱う方法の違いと、今日の主流の圧縮ツールがどちらの系統から来ているのかを説明できる。 ビルド成果物をどの形式で圧縮するか比べていくと、結局この二つのアルゴリズムまでさか...]]></description>
            <content:encoded><![CDATA[<p>今回は、<strong>LZ77とLZ78がどう違うのか</strong>について話してみたい。</p>
<p>zip、gzip、zstdのような圧縮ツールを使いながら、その中の辞書式圧縮がどう動くのか気になっていた開発者に向けた記事だ。最後まで読めば、二つのアルゴリズムが辞書を扱う方法の違いと、今日の主流の圧縮ツールがどちらの系統から来ているのかを説明できる。</p>
<p>ビルド成果物をどの形式で圧縮するか比べていくと、結局この二つのアルゴリズムまでさかのぼることになる。</p>
<h2 id="辞書ベース圧縮"><a class="anchor" href="#辞書ベース圧縮">辞書ベース圧縮</a></h2>
<p>可逆圧縮（Lossless Compression）は、元のデータを完全に復元できる圧縮方式だ。画像や音声で使われる非可逆圧縮（Lossy Compression）とは異なり、展開後のデータは元データと1ビットも違わない。ソースコードやビルド成果物のようにデータ整合性が重要な場合は、必ず可逆圧縮を使う必要がある。</p>
<p>可逆圧縮の中心的な考え方は、<strong>データに存在する統計的な冗長性を利用すること</strong>だ。繰り返されるパターンを短い表現に置き換えれば、全体のサイズを減らせる。</p>
<p>その中でも、<strong>辞書ベース（Dictionary-Based）方式</strong>は可逆圧縮で広く使われるアルゴリズム群だ。ここでいう辞書とは、前に見たデータをもう一度指し示せるようにしておく仕組みだ。その仕組みが通ってきたデータそのものなのか、別に積み上げたリストなのかが、LZ77とLZ78を分ける。Jacob ZivとAbraham Lempelが1977年にIEEE Transactions on Information Theoryで発表した<a href="https://doi.org/10.1109/TIT.1977.1055714" target="_blank" rel="noopener noreferrer">論文</a>で提案した<strong>LZ77</strong>と、二人が翌1978年に<a href="https://doi.org/10.1109/TIT.1978.1055934" target="_blank" rel="noopener noreferrer">続けて発表した論文</a>の<strong>LZ78</strong>がこの系譜の始祖だ。二人の姓から一文字ずつ取って「LZ」と呼ばれる。以後に登場した辞書ベース圧縮アルゴリズム（DEFLATE、LZMA、LZ4、Zstdなど）は、この二つにルーツを持つ。</p>
<p>簡単な例で考えてみよう。「Linux」という単語が100回繰り返されるなら、2回目からは原文の代わりに「前に見たあの断片」を指す短い参照を書く。参照が原文より短ければ、全体のサイズが小さくなる。</p>
<p>では、LZ77とLZ78は具体的に何が違うのだろうか。</p>
<h2 id="lz77スライディングウィンドウ方式"><a class="anchor" href="#lz77スライディングウィンドウ方式">LZ77：スライディングウィンドウ方式</a></h2>
<p>LZ77は<strong>明示的な辞書を別に作らず</strong>、入力ストリームの一定範囲そのものを辞書として使う。この範囲を<strong>スライディングウィンドウ</strong>と呼ぶ。処理した分だけウィンドウが前へずれていくことから付いた名前だ。</p>
<p>ウィンドウは二つの領域に分かれる。</p>
<ul>
<li><strong>検索バッファ（Search Buffer）</strong>：すでに処理された直前までのデータ。辞書の役割を担う。</li>
<li><strong>ルックアヘッドバッファ（Look-ahead Buffer）</strong>：まだ処理されておらず、これから圧縮するデータ。</li>
</ul>
<p>アルゴリズムは、ルックアヘッドバッファの先頭部分が検索バッファのどこかに現れたことがあるかを探す。同じパターンが見つかると、その一致を（<strong>距離、長さ、次の文字</strong>）というタプルで符号化する。距離は一致の開始位置まで何文字戻るかを、長さは一致が何文字続くかを表す。元の論文は最初の値をバッファ内の位置として記し、今の実装は同じ情報を距離として記す。</p>
<p>たとえば、<code>"banana_banana"</code>という文字列をこの記事の後半に載せた学習用エンコーダーで圧縮すると、LZ77のトークンが五つ出る。<code>(0,0,b)</code> <code>(0,0,a)</code> <code>(0,0,n)</code> <code>(2,3,_)</code> <code>(7,5,a)</code>だ。最初の三つは初めて見る文字なので、一致なしで次の文字だけを持つ。二つ目の<code>"banana"</code>は最後のトークン<code>(7,5,a)</code>一つで処理される。「7文字戻って5文字をコピーし、<code>a</code>を付けよ」という意味だ。6文字すべてをコピーしないのは、このエンコーダーが入力の最後の文字を常に次の文字の位置に残すからだ。</p>
<p><code>(2,3,_)</code>では、長さ3が距離2より長い。デコーダーは<code>ban</code>まで書いた状態で2文字前の<code>a</code>からコピーを始め、3文字目をコピーするときには今書いたばかりの<code>a</code>をもう一度読む。そのため<code>ana</code>が出て、<code>_</code>が付く。1文字ずつ追うと次のようになる。</p>
<table>
<thead>
<tr>
<th>段階</th>
<th>読んだ文字</th>
<th>出力</th>
</tr>
</thead>
<tbody>
<tr>
<td>開始</td>
<td>なし</td>
<td><code>ban</code></td>
</tr>
<tr>
<td>コピー1</td>
<td>2文字目の<code>a</code></td>
<td><code>bana</code></td>
</tr>
<tr>
<td>コピー2</td>
<td>3文字目の<code>n</code></td>
<td><code>banan</code></td>
</tr>
<tr>
<td>コピー3</td>
<td>4文字目の<code>a</code>（コピー1で書いたもの）</td>
<td><code>banana</code></td>
</tr>
<tr>
<td>次の文字</td>
<td>トークンの<code>_</code></td>
<td><code>banana_</code></td>
</tr>
</tbody>
</table>
<p><a href="https://www.rfc-editor.org/rfc/rfc1951" target="_blank" rel="noopener noreferrer">RFC 1951</a>も同じ動作を明記している。直前の2バイトがX、Yのとき、<code>&#x3C;length = 5, distance = 2></code>はX,Y,X,Y,Xを追加する。辞書がそのまま今復元したデータだからこそ成り立つ。</p>
<p>この方式では、<strong>辞書を別途保存したり送信したりしない。</strong> デコーダーは展開を進めながら検索バッファを自ら再構築するため、辞書はデータそのものへ暗黙的に組み込まれている。参照は前のデータを指すので、展開は基本的に先頭から順番に進む。ただし、参照が届く範囲はウィンドウまでだ。RFC 1951はDEFLATEの参照を最大32Kバイト前までに制限している。zlibの<code>Z_FULL_FLUSH</code>は圧縮状態をリセットし、前の圧縮データが壊れた場合やランダムアクセスが必要な場合に、その地点から展開をやり直せるようにする。<a href="https://github.com/madler/zlib/blob/v1.3.1/zlib.h" target="_blank" rel="noopener noreferrer">zlib.h</a>は、頻繁に使いすぎると圧縮率が大きく落ちると警告している。</p>
<p>ウィンドウサイズと圧縮率には直接的なトレードオフがある。ウィンドウが大きければ遠く離れたパターンも参照できるため圧縮率は高くなりやすいが、メモリ使用量が増え、調べる候補も多くなるので一致検索もたいてい遅くなる。</p>
<h2 id="lz78明示的な辞書方式"><a class="anchor" href="#lz78明示的な辞書方式">LZ78：明示的な辞書方式</a></h2>
<p>LZ78はLZ77と異なり、圧縮を進めながら<strong>明示的な辞書を構築する</strong>。スライディングウィンドウはない。代わりに、以前見たパターンをインデックス付きの辞書項目として保存し、同じパターンが再び現れたらインデックスへ置き換える。</p>
<p>LZ78が出力する単位は、（<strong>辞書インデックス、次の文字</strong>）というタグだ。エンコーダーは辞書内で最長一致する項目を探し、そのインデックスと一致を崩した次の文字を組み合わせて出力する。そして <strong>「今一致した項目＋新しい文字」</strong> を新たな辞書項目として追加する。こうして辞書が段階的に成長していく。</p>
<p>同じ<code>"banana_banana"</code>をLZ78で圧縮すると、トークンが八つ出て、辞書には項目が七つ積み上がる。トークンは<code>(0,b)</code> <code>(0,a)</code> <code>(0,n)</code> <code>(2,n)</code> <code>(2,_)</code> <code>(1,a)</code> <code>(3,a)</code> <code>(7,)</code>で、辞書は<code>1:b</code> <code>2:a</code> <code>3:n</code> <code>4:an</code> <code>5:a_</code> <code>6:ba</code> <code>7:na</code>だ。二つ目の<code>"banana"</code>は、<code>ba</code>、<code>na</code>、<code>na</code>を作る三つのトークンに分かれる。LZ77がトークン一つで済ませた部分だ。LZ78の辞書項目は一度に一文字ずつしか長くならないので、同じ単語を何度も見ないと長い項目はできない。最後の<code>(7,)</code>は、入力が終わって次の文字がない場合だ。このエンコーダーはトークンをビットに詰めないので、トークン数で圧縮率を比べることはできない。</p>
<p>二つのトークン列は下のコードで再現できる。2026-10-08にmacOS 26.6.2、Node v24.16.0でファイルに保存し、<code>node lz.mjs</code>で実行した結果だ。</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ77: (거리, 길이, 다음 문자). 가장 긴 일치를 고르고, 겹친 복사를 허용한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">win</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 4096</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, l: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> win); j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i; j</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 입력의 마지막 글자는 매치에 넣지 않고 다음 문자로 남긴다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l]) l</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l) best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j, l }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([best.d, best.l, s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l]])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> out</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ78: (사전 인덱스, 다음 문자). 인덱스 0 은 빈 문자열이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> c</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">continue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 입력이 끝나 다음 문자가 없다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      break</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, c])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c, dict.size </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { out, dict: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()] }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 디코더 모두 토큰만 받는다. 사전은 전달받지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l; k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o[o.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> d] </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 방금 쓴 글자도 다시 읽는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> entry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict[i] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> entry</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(entry) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 인코더와 같은 순서로 사전을 다시 쌓는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'banana_banana'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ77'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, a.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">d</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">l</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(a) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ78'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">i</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.out) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'사전'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">k</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}:${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>LZ77 (0,0,b) (0,0,a) (0,0,n) (2,3,_) (7,5,a) true</span></span>
<span data-line=""><span>LZ78 (0,b) (0,a) (0,n) (2,n) (2,_) (1,a) (3,a) (7,) true</span></span>
<span data-line=""><span>사전 1:b 2:a 3:n 4:an 5:a_ 6:ba 7:na</span></span></code></pre></figure>
<h2 id="二つの方式が分かれるところ"><a class="anchor" href="#二つの方式が分かれるところ">二つの方式が分かれるところ</a></h2>
<p>コードの二つのデコーダーはトークンだけを受け取る。つまり、<strong>LZ78も辞書を送らない</strong>。デコーダーはトークンを読みながらエンコーダーと同じ順序で項目を追加するので、同じ辞書が再び積み上がる。辞書を送信しない点は二つのアルゴリズムの共通点であり、両者が分かれるのは<strong>古い内容を忘れる方法</strong>だ。LZ77のウィンドウは前へ進みながら古いデータを自然に忘れる。LZ78の辞書は放っておくと大きくなり続ける。元の論文は決まった長さのブロックが終わるたびに辞書を丸ごと空にし、実際の実装は辞書の大きさに上限を設けて、いっぱいになると凍結するか、空にするか、一部の項目を再利用する。</p>
<p>LZ78で最も有名な派生が<strong>LZW</strong>（Lempel-Ziv-Welch）だ。Terry WelchがLZ78を改良して1984年に発表し、GIF画像形式とUnixの<code>compress</code>ユーティリティ（<code>.Z</code>拡張子）で使われた。どちらの実装も辞書に上限を設けた。<a href="https://www.w3.org/Graphics/GIF/spec-gif89a.txt" target="_blank" rel="noopener noreferrer">GIF89a仕様</a>はコードを最大12ビット（最大値4095）に抑え、辞書を初期状態に戻すClear codeを別に用意している。<a href="https://github.com/vapier/ncompress/blob/v5.0/compress.1" target="_blank" rel="noopener noreferrer">ncompressのmanページ</a>によれば、<code>compress</code>はコード長が<code>-b</code>の上限（既定値16ビット）に達した後は圧縮率を監視し、圧縮率が下がると辞書を捨てて最初から積み直す。</p>
<p>二つの論文は、それぞれのモデルで漸近的最適性を示した。1977年の論文は、LZ77の圧縮率が情報源を事前に知って設計した符号の下限に一様に近づく（"uniformly approaches the lower bounds"）ことを示し、1978年の論文は個別系列（individual sequence）を対象に、LZ78のincremental parsingが漸近的に最適であることを示した。モデルが異なるので、この結果だけで両者を比べることはできない。</p>
<h2 id="lz77系の子孫"><a class="anchor" href="#lz77系の子孫">LZ77系の子孫</a></h2>
<p>では、現代の圧縮アルゴリズムはどちらから続いているのだろうか。現在使われている主流の圧縮アルゴリズムは、大半が<strong>LZ77系の子孫</strong>だ。</p>
<p>StorerとSzymanskiの1982年の論文から続く<strong>LZSS</strong>はLZ77の派生だ。一致が短すぎてポインターが元のデータより長くなる場合は、ポインターの代わりにリテラル（元の文字）をそのまま出す。</p>
<p><strong>DEFLATE</strong>はPhil KatzがPKZIP 2のために設計し、1996年にRFC 1951として仕様がまとめられた。RFC 1951はDEFLATEを、LZ77と<strong>ハフマン符号化</strong>（頻度の高いシンボルへ短いビット列を割り当てるエントロピー符号）の組み合わせだと書いている。出力はリテラルと（長さ, 距離）の組が混ざった列で、リテラルと一致の長さを一つのアルファベット（0〜285）にまとめて、一つのハフマン符号で区別する。大半のZIPプログラムが既定で使う圧縮方式、GZIP、PNGはいずれもこのDEFLATEを使う。つまり、日常的に扱う<code>.zip</code>、<code>.gz</code>、<code>.png</code>ファイルの大半はLZ77の直系子孫だ。</p>
<p>LZ77側が主流になった理由を一つに絞ることはできないが、仕様自身が掲げた点の一つは特許だ。RFC 1951は、LZ77の変形にも特許が取られたものが多いと書きつつ、DEFLATE形式は特許にかからない方法で容易に実装できると明記している。<a href="https://www.w3.org/TR/png-3/" target="_blank" rel="noopener noreferrer">PNG仕様</a>は冒頭でPNGを、特許のないGIFの代替形式として紹介する。GIFは先に見たLZWを使う形式だ。</p>
<p>その後に登場した<strong>LZMA</strong>（7-Zip、XZ）、<strong>LZ4</strong>、<strong>Zstd</strong>も、LZ77のスライディングウィンドウの考え方から出発した。LZMAとZstdは一致検索とエントロピー符号化をともに発展させ、LZ4はエントロピー符号化をまったく使わず、バイト単位の形式で速度を選んだ。LZ78系は、LZWの形でGIFのような形式の中に残っている。</p>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>LZ77とLZ78は、繰り返されるパターンを短い参照に置き換えるという同じアイデアから出発した。どちらも辞書を送らずデコーダーが積み直すが、参照が指す先がすでに通ったデータの位置なのか、別に積み上げた辞書の番号なのかで分かれ、古いものを忘れる方法も違った。DEFLATEを経てLZMA、LZ4、Zstdへと続く主流の圧縮ツールはLZ77側の子孫であり、LZ78側はLZWの形でGIFのような形式に残った。</p>
<p>この系譜が実際の形式選びでどのような違いにつながるのか、つまりZIP、GZIP、ZSTD、XZの速度と圧縮率を比較し、ビルド成果物に何を選ぶかは<a href="/240706" target="_blank" rel="noopener noreferrer">圧縮アルゴリズムを理解する</a>で扱う。</p>
<p>次に<code>.zip</code>や<code>.gz</code>ファイルを展開するとき、その中で「何文字戻って何文字コピーせよ」という指示がやり取りされていることを一度思い出してもらえたらうれしい。</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1145/322344.322346" target="_blank" rel="noopener noreferrer">Storer, Szymanski, Data compression via textual substitution (1982)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1109/MC.1984.1659158" target="_blank" rel="noopener noreferrer">Welch, A Technique for High-Performance Data Compression (1984)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT" target="_blank" rel="noopener noreferrer">PKWARE, APPNOTE.TXT: .ZIP File Format Specification</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>素朴な疑問</category>
            <category>ソフトウェア</category>
        </item>
    </channel>
</rss>