<?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>Wed, 19 Aug 2026 00:40:52 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[状態管理]]></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>氏の記事をよく読んだ。そこでは状態を、**「UIに影響を与え得るすべてのデータ」**と説明している。いいね数、カートの中身、モーダルが開いているかどうか、入力値、ログイン中のユーザー情報、現在選択されているタブ、検索結果、ローディング中かどうか。これらはすべて状態である。</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>どちらの定義も、同じものを指している。**「レンダーに影響し、時間とともに変化する値」**が状態である。変化しない定数（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>では、この蜘蛛の巣をどう整理すればよいのだろうか。筆者が最初の一歩だと考えるのは、**「状態には種類がある」**と認識することである。</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>この程度なら馴染みがあるだろう。しかし、ローカル状態で本当に難しいのは、**「この状態をどこに置くべきか」**という配置の判断である。</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>の記事では、**人々は状態を「引き上げる（lift up）」ことには慣れているが、コードが変わったときに状態を再び「近くに置く（colocate）」ことはあまりしない。**と述べている。</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>「アプリのどこからでも、この名前でこの値を参照できる」**という契約をコード全体に公開する。その契約のコストが高いことこそ、グローバル状態の本質である。</p>
<p>グローバル状態を一つ作ることは、実のところ、<strong>アプリ全体にまたがる暗黙的な依存関係</strong>を一つ追加することだ。</p>
<h2 id="サーバー状態server-state"><a class="anchor" href="#サーバー状態server-state">サーバー状態（Server State）</a></h2>
<p>APIから受け取ったデータをClient Stateに入れ、ローディングとエラーをbooleanで直接管理し続けた末に、**「なぜ毎回、同じボイラープレートを書いているのだろう？」**という疑問へ行き着く。</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の追跡を毎回手作業で実装する必要があり、**並行処理に伴う危険（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には、**「靴カテゴリーを価格の降順で並べた2ページ目」**という完全な状態が含まれている。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>のようなライブラリは、*パーサー（parser）*という概念で、この二つの問題を解決する。<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>最も一般的な例は、**認証ガード（Auth Guard）**だ。</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>ガードロジックは、一か所に集中しやすい。一つのコンポーネントに、**「未ログインならログインページ、権限がなければ403、カートが空なら商品ページ、ユーザーが停止中なら停止案内」**がすべて入ってしまうことも珍しくない。ガードが肥大化するほど、どの条件によって、どこで止められたのかをデバッグしにくくなる。</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>最もよくあるバグは、**「ガードの非同期チェックが終わる前に、保護対象のコンテンツが一瞬表示される」**ことだ。認証トークンの検証や権限の取得は、ほとんどが非同期であり、その間、<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>だ。そうすれば、権限ポリシーの変更は<em>データの変更</em>だけで済む。</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はこれからも長く私たちのそばにいるだろう。コードを一行ずつ確認する時間は、ますます減っていく。しかし、だからこそ、**「この状態はどこにあるべきか？」**という小さな問いに答えられる感覚の価値は高まる。AIに「ここへuseStateをもう一つ追加して」と頼むのは簡単だ。だが、その一行がアプリの蜘蛛の巣にどのような糸を加えるのかを理解できるかどうかは、そのコードを読む人の設計眼にかかっている。</p>
<p>正解はない。それでも、**「状態が何かを理解せずに状態を作ること」<strong>と、</strong>「状態の種類と配置を意識して作ること」**の間には、明確な違いがある。この記事を読んだ方にも、次に<code>useState</code>を一行書く前に一度立ち止まり、「これはどの分類の状態だろう？」と問いかけてみてほしい。</p>
<h3 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h3>
<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[今回の記事では、ドメイン（Domain） について考えてみたい。 筆者は開発を続けるなかで、「ドメイン（Domain）」 という言葉をかなり頻繁に耳にしてきた。しかし、いざ「ドメインとは正確には何か？」と聞かれると、明快に答えるのは簡単ではない。（正直なところ、開発を始めたばかりの頃は、ドメインとはwwwのことだと思っていた。） ドメインについて調べると、自然と ドメインモデル、ドメインオブジェク...]]></description>
            <content:encoded><![CDATA[<p>今回の記事では、<strong>ドメイン（Domain）</strong> について考えてみたい。</p>
<p>筆者は開発を続けるなかで、<strong>「ドメイン（Domain）」</strong> という言葉をかなり頻繁に耳にしてきた。しかし、いざ「ドメインとは正確には何か？」と聞かれると、明快に答えるのは簡単ではない。（正直なところ、開発を始めたばかりの頃は、ドメインとはwwwのことだと思っていた。）</p>
<p>ドメインについて調べると、自然と <strong>ドメインモデル</strong>、<strong>ドメインオブジェクト</strong>、<strong>ドメインオブジェクトモデル</strong> といった概念に行き着く。しかし、それぞれがどう違うのか、また、これらの概念がバックエンドではなく <strong>フロントエンド</strong> でどのような意味を持つのかを整理した記事は、意外と多くない。この文章では、各概念の定義から始め、フロントエンドでドメインロジックをどのように分離し、抽象化するのが適切なのかまで、例を交えて整理してみたい。</p>
<p>最近は税金に関するドメインに関心がある。まもなく5月の総合所得税申告の時期を迎えるので、今回は税金を例に取り上げる。</p>
<hr>
<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><img src="/content/260418/1.png" alt="1.png" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></p>
<p>このパイプラインの各段階は、それぞれ固有のルールとデータを持つサブドメインである。「税金」という一つの大きなドメインの中で、所得（Income）、控除（Deduction）、税額（Tax）、申告結果（Filing）という細かなドメインが絡み合っている。これらをコード上でどう分けるかが、まさにドメインモデリングの中心的な問いである。</p>
<h2 id="ドメインモデルdomain-model"><a class="anchor" href="#ドメインモデルdomain-model">ドメインモデル（Domain Model）</a></h2>
<p>では、ドメインモデルとは何だろうか？ドメインと「ドメインモデル」はどう違うのだろう？</p>
<p>Martin FowlerとEric Evansは、ドメインモデルを次のように定義している。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>振る舞いとデータの両方を含む、ドメインのオブジェクトモデル。— Martin Fowler</p></div><div class="quote-original" lang="en"><p>An object model of the domain that incorporates both behavior and data.</p></div></blockquote>
<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>置くべきなのだろうか？</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>
<p><strong>1. Reactの状態管理との摩擦</strong></p>
<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>
<p><strong>2. イミュータビリティを保証する難しさ</strong></p>
<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>
<p><strong>1. モジュールの名前空間で凝集させる</strong></p>
<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>
<p><strong>2. 最初の引数をドメインの主体に統一する</strong></p>
<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>
<p><strong>3. ドメインオブジェクトの生成関数（Factory）で振る舞いをまとめる</strong></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">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>まとめると、<strong>ドメイン</strong>は私たちが解決しようとする問題領域であり、<strong>ドメインモデル</strong>はその問題を選択的に抽象化した概念体系、<strong>ドメインオブジェクトモデル</strong>はその概念体系をコードとして実装したもの、そして <strong>ドメインオブジェクト</strong>はその実装内の個々のオブジェクトである。</p>
<p>そして、これらの概念をフロントエンドで実践するとは、単にフォルダを分けることではなく、<strong>何層もの境界を意識的に判断すること</strong>である。「これはビジネスルールか、プレゼンテーションロジックか？」「このデータはドメインの状態か、UIの状態か？」「この関数は十分に凝集しているか？」。こうした問いを習慣的に投げかけるだけでも、コードの構造は自然と改善される。</p>
<p>もちろん、すべてのプロジェクトでClean Architectureの層をすべてそろえる必要はない。単純なCRUDアプリを4層に分け、すべてのドメインにFactoryパターンを適用するのは、本末転倒である。Classの優れた凝集性と関数型の実用的な柔軟性のどちらを選ぶかは、プロジェクトの複雑さとチームのコンテキストによって決まる。</p>
<p>唯一の正解はない。しかし少なくとも、<strong>「ドメインが何かを知らずにコードを書くこと」</strong> と <strong>「ドメインを認識し、境界を判断し、意識的に分離すること」</strong> の間には明確な違いがある。この記事を読んだ方にも、自分のプロジェクトで「ここでのドメインは何だろう。そして、このコードはどこに置くべきだろう？」と一度問いかけてみてほしい。</p>
<h3 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h3>
<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[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> は400行あまりのコンポーネントで、日付選択、タイムラインの可視化、予約詳細のツールチップ、自分の予約一覧、キャンセル機能がすべて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>分割の基準は、**「このコードが独立して意味を持つか」**だった。タイムラインの可視化は、日付に応じた予約データを受け取ってグリッドを描画する独立した関心事だ。自分の予約一覧は、ユーザーの予約データを取得してキャンセルする独立した関心事だ。それらが同じファイルにある理由はなかった。</p>
<p>分割後の<code>index.tsx</code>は、**オーケストレーター（orchestrator）**としての役割だけを担うようになった。状態管理、メッセージの表示、子コンポーネントの組み合わせを担当するだけで、実際のデータ取得やレンダリングの詳細は子コンポーネントへ委ねた。</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>もちろん、これが唯一の正解というわけではない。ただ、筆者の判断基準は**「この関数名だけを見て動作を予測できるか」**だった。予測できるなら抽象化し、できないならインラインのままにするほうが、かえって可読性を高められると考えた。</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>を導入し、フォームとして管理する方法を試した。しかし最終的にはこれを取り除き、**searchParamsを信頼できる唯一の情報源（Single Source of Truth）**として使う<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>この決定の核心にあったのは、**「状態が別々に動くのは適切ではない」**という判断だ。<code>useState</code>と<code>searchParams</code>がそれぞれ状態を持つと、同期するタイミングによって不整合が起きる可能性がある。一方、searchParamsだけを状態として使えば、URLそのものがアプリケーションの状態となり、同期の問題自体がなくなる。ユーザーがURLを共有すれば同じフィルター状態を再現できるのは、おまけの利点だ。</p>
<p>ほかの参加者の振り返りにも、似た悩みが見られた。<strong>「URLのsearchParamsを信頼できる唯一の情報源として統一した」「個別のフィルターpropsをひとつの<code>filter</code>オブジェクトへまとめる方法を選んだ」</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>ほかの参加者の振り返りを読み、興味深い共通点に気づいた。ほぼすべての記事に、**「コードは読むものではなく、予測するものだ」**という一文が登場していた。私たちの脳はコードを一行ずつ解釈するのではなく、経験から蓄積したパターンをもとに予測しながら読む。そして、その予測が外れたときに認知負荷が急激に高まる。</p>
<p>この観点から見れば、模擬試験が評価するのは単なるコーディング能力ではなく、**「同僚が読むコードを、どれだけ予測可能なものにできるか」**という協働の力なのだ。（出題者や同僚の心を読むことこそ、真のソフトウェアエンジニアの能力なのかもしれない）</p>
<p>ほかの参加者の振り返りを見ると、**「他人が書いたコードを理解するのは簡単ではない」「インターフェースを先に設計することは重要だが、膨大な既存コードを前にすると、そのアプローチが揺らぐこともある」<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はコードの整合性を検証できるが、**「あなたが見落としている観点はこれだ」**と指摘するのは、結局のところ同じ文脈を共有する同僚の役割だ。自分には見えなかった部分の発見、そしてその発見を通じたプロダクトの安定化。これこそが協働の本質ではないだろうか。</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>がある。フロントエンドエンジニアのキャリアを、**Web特化（Software Engineer）／プロダクト特化（Product Engineer）／運用特化（Full-Stack Engineer）**という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>**バイブコーディング（Vibe Coding）**とは、一言でいえば「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という言葉を**「agentic engineering」**に置き換えようと提案する。両者の違いは明確だ。</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年5月時点で最もよく言及されるコーディングツールは、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>要するに、**「みんな使ってはいるが、時間が経つほど信用できなくなっている」**ということだ。</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が追いついた。しかし、**「AIが書いたコードを正確に評価する力」**は、むしろ彼らがほぼ独占していると言ってよい。</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>を整理する中で、**「コーディングエージェントのオーケストレーション（Orchestrating Coding Agents）」**という概念が中心的なものとして挙げられた。これは、一つの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>といった役割を挙げていた。この道は今も有効だが、そこに**「AI開発インフラリード」<strong>、</strong>「開発者生産性（DevProd）エンジニア」**といった新しい役割が加わったと考えればよい。</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>は、この流れを**6つのwave（traditional → completions → chat → coding agents → agent clusters → agent fleets）**に整理している。</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の文章を見ると、彼が今強調している**「agentic engineering」**の核心も、結局は同じだ。システムを設計し、制約を仕様として定め、頭の中ですでに推論を終えた実装をAIで加速する。ツールが変わっても、方向を決めるハンドルは今も人の手にある、という話だ。</p>
<p>元記事が最後に伝えたメッセージも、結局は「与えられた仕事をうまく終わらせることに満足せず、前後の文脈に目を配り、大きな影響を生み出す人」がシニアになる、というものだった。AI時代には、その「インパクト」の定義が変わっただけだ。AIが1時間で作った画面を「動くからこれでいい」とマージする人と、その画面がアクセシビリティ、セキュリティ、パフォーマンス、システム整合性の面でどこまで妥当かを、さらに30分かけて確認する人がいる。1年後にシニアとして認められるのは後者だ。70%（動作）と30%（応用と活用）の境界で、30%側に立つ人が生き残る。</p>
<p>この記事を読むフロントエンドエンジニアの皆さんにも、「これから何をもっと勉強すべきか」という問いに、自分なりの答えを持ち帰ってもらえたらと思う。正解は誰にも分からない。それでも、AIがコードを書く時代であるほど、「コードの先にあるもの」を見る人が生き残るという点については、筆者はかなり確信している。1年後、この景色がさらにどう変わっているのか、そのとき改めて文章にまとめられることを願って、この記事を終える。</p>
<p><strong>（もしこの記事が1年後にはあまりにも当たり前、あるいは古い話に見えるなら、それだけ私たちがうまく対応できたということではないだろうか。）</strong></p>
<h2 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h2>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary></details>]]></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は<em>Clean Code</em>で、この概念を**「一つの関数につき一つの抽象化レベル（One Level of Abstraction per Function）」** という原則にまとめた。一つの関数内に高いレベルと低いレベルのコードが混在すると、読む人は「これは中心的なロジックなのか、それとも実装の詳細なのか」を一行ごとに判断しなければならないからだ。</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も<em>Smalltalk Best Practice Patterns</em>で、同じ原則を<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はこれを**「意図が伝わる名前（Intention-Revealing Name）」** パターンと呼び、簡潔な名前を付けられないなら、抽象化そのものを見直すべきだと述べた。</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は<em>Clean Code</em>で、これについて**「短く謎めいた名前より、長く説明的な名前のほうがよい」** と述べている。また、<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年に提唱した**「漏れのある抽象化の法則（The Law of Leaky Abstractions）」** も、この文脈で覚えておく価値がある。漏れのある抽象化の法則とは、抽象化は複雑な実装を隠そうとするが、その実装の詳細は結局、外へ漏れ出す（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 の管理方法を何度も作り直してきた。最初はコンポーネント内に ['user', userId] のような配列を直接書いていたが、無効化のたびに同じキーを複数箇所へ記述するうちにタイプミスが増え、QUERYKEYS のような定数オブジェクトへ移...]]></description>
            <content:encoded><![CDATA[<p>今回は、<strong>TanStack Query の queryKey</strong>について掘り下げてみたい。</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>
<p>単なるキャッシュの識別子にすぎない小さな配列をめぐって、なぜこれほど多くのパターンが生まれたのだろう。<strong>なぜ一つの queryKey に、これほど多くの進化の痕跡が残っているのか。</strong> そして、それぞれの段階は具体的にどの問題を解決しようとしていたのか。</p>
<p>本記事では、TanStack Query の公式ドキュメント、TkDodo のブログシリーズ、さらに v5 で導入された <code>queryOptions</code> の内部実装までたどりながら、queryKey がどのような仕組みで動作し、なぜ現在の形へ進化してきたのかを整理する。</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>この流れの本質は、結局のところ**「このリクエストが何のリクエストかを識別できて初めて、同じリクエストを繰り返さずに済む」**ということだ。そして、その「何のリクエストか」を示す識別子こそが 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>ここで一つ疑問が生じる。queryKey が「同じキー」であることを、どのように判定しているのだろうか。単純に <code>===</code> で比較すればオブジェクトの参照は異なるため、毎回キャッシュミスになるはずだ。</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/main/packages/query-core/src/queryCache.ts" target="_blank" rel="noopener noreferrer">公式実装</a>で使われているデータ構造は、プレーンオブジェクトではなく <code>Map&#x3C;string, Query></code> だ。クラス内で <code>#queries = new Map&#x3C;string, Query>()</code> と宣言され、すべての書き込みと読み込みは <code>#queries.set(query.queryHash, query)</code> と <code>#queries.get(queryHash)</code> を通じて行われる。キーは queryKey をシリアライズした形式（<code>queryHash</code>）、値は <code>Query</code> クラスのインスタンスである。</p>
<p>古いバージョンではプレーンオブジェクトが使われていた時期もあったが、v5 ではネイティブの <code>Map</code> へ移行した。（<code>Map</code> はキーの衝突やプロトタイプ汚染のリスクがなく、挿入順を保持し、文字列キーの検索が平均 O(1) であるため、キャッシュのデータ構造としては定石に近い選択だ。）</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> は、その変換を行う最も単純な手段にすぎない。（TanStack Query が v3 の時代に複数のシリアライズ方式を試した末、安定した <code>JSON.stringify</code> の変種へ落ち着いた理由でもある。）</p>
<p>ここで中心となるのが、ハッシュ値を作る関数 <code>hashKey</code> だ。<a href="https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts" 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>からシリアライズしている。</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> は両者を明確に区別して扱う。公式ガイドが queryKey を「汎用的なものから具体的なものの順に配置する」よう推奨しているのは、このためだ。配列の順序が意味を担う以上、その意味は作成者が自ら定める必要がある。</p>
<p>ここでもう一つ確認しておくべき点がある。キーの並べ替えが適用されるのは、<strong>プレーンオブジェクト</strong>だけだ。同じファイル内の <code>isPlainObject</code> は単に <code>typeof === 'object'</code> を見るのではなく、<code>Object.getPrototypeOf(o) === Object.prototype</code> まで検査し、<strong>純粋なオブジェクトリテラル</strong>と<strong>クラスのインスタンス</strong>を区別する。そのため、<code>{ foo: 1 }</code> のようなリテラルは並べ替えられる一方、<code>class User { ... }</code> で作成したインスタンスは並べ替えられず、そのまま処理される。（クラスのインスタンスをそのまま queryKey に含めると、<code>JSON.stringify</code> が列挙可能なプロパティだけを出力する挙動と相まって、意図しないハッシュが生成されることがある。）</p>
<p>この仕組みから、二つの重要な結果が導かれる。</p>
<p><strong>1. オブジェクトのキー順は問わない。</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">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>
<p><strong>2. 配列の要素順は重要である。</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">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>
<p>配列は順序そのものに意味があるデータ構造だからだ。<code>JSON.stringify</code> も配列の順序は維持する。</p>
<p>また、<code>undefined</code> 値はシリアライズの過程で消えることも覚えておくとよい。<code>{ a: 1, b: undefined }</code> と <code>{ a: 1 }</code> は同じハッシュ値になる。（筆者はこのことを知らず、「undefined を明示的に入れたのだから別のキャッシュだ」と考えてしまったことがある。）</p>
<p>もう一つ、queryKey に<strong>循環参照や関数</strong>を含めることはできない。<code>JSON.stringify</code> では処理できないためだ。<code>Date</code> オブジェクトや <code>Map/Set</code>、<code>BigInt</code> なども同様に、標準の挙動では推奨されない。シリアライズ可能な純粋なデータ構造である必要がある。</p>
<p>興味深いのは、この制約が完全に強制されているわけではない点だ。TanStack Query は <code>queryKeyHashFn</code> というオプションを通じて、<strong>ハッシュ関数自体を差し替えられる逃げ道</strong>を用意している。内部では <code>hashQueryKeyByOptions(queryKey, options)</code> が、オプションに <code>queryKeyHashFn</code> があればそれを呼び、なければ既定の <code>hashKey</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">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [{ id: userId, fetchedAt: </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">  queryFn,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // Date를 ISO 문자열로 바꿔서 해싱</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryKeyHashFn</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>
<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">(key, (</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"> (v </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">instanceof</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"> v.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>ただし、このオプションはクエリごとに個別に指定する必要があり、<code>queryClient.setQueryData</code> のようにオプションを知らないまま呼び出される命令型 API には適用されないという制約がある（<a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">課題 #1343</a>）。そのため実務では、回避手段を使うよりも、<strong>queryKey を作る時点でシリアライズ可能な形式へ変換してから渡す方</strong>がはるかに安全だ。（筆者も一度 <code>Date</code> をそのまま入れ、「同じ時刻なのに、なぜキャッシュが更新されないのか」と長時間悩んだことがある。結局、答えは「その <code>Date</code> は同じ時刻を表していても別のオブジェクトインスタンスなので、毎回異なるハッシュになっていた」だった。）</p>
<h2 id="querykey-の記述規則"><a class="anchor" href="#querykey-の記述規則">queryKey の記述規則</a></h2>
<p>ここまでの複雑な内部動作を理解すれば、記述規則も自然に見えてくる。公式ドキュメントが推奨する規則を整理すると、次のようになる。</p>
<p><strong>規則1. queryKey は必ず配列にする。</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:#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>
<p><strong>規則2. queryFn が依存するすべての変数を queryKey に含める。</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">// 잘못된 예: 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>
<p><strong>規則3. 最も汎用的なものから最も具体的なものの順に配置する。</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:#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="1-インライン配列"><a class="anchor" href="#1-インライン配列">1. インライン配列</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="2-定数オブジェクト"><a class="anchor" href="#2-定数オブジェクト">2. 定数オブジェクト</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="3-クエリキーファクトリー"><a class="anchor" href="#3-クエリキーファクトリー">3. クエリキーファクトリー</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="4-lukemoralesquery-key-factory"><a class="anchor" href="#4-lukemoralesquery-key-factory">4. @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="5-queryoptionsv5-公式"><a class="anchor" href="#5-queryoptionsv5-公式">5. 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>
<p><strong>1. 階層構造と型推論を同時に得られる。</strong></p>
<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>
<p><strong>2. コンポーネントでオプションを部分的に上書きできる。</strong></p>
<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>
<p><strong>3. <code>useQuery</code> をラップするカスタムフックが次第に不要になる。</strong></p>
<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>筆者の答えは、いつもどおり**「状況による」**だ。</p>
<p>覚えておきたいのは、<strong>抽象化が常によいとは限らないこと</strong>だ。一度しか使わないクエリをわざわざドメインファクトリーへ切り出すと、コードを読む人が二つのファイルを行き来するだけになる。queryKey 管理パターンの進化は、「常により精巧な道具を使うべきだ」という意味ではない。**「必要になったとき、はしごを一段ずつ上る選択肢がある」**と捉えるのがよい。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、queryKey は、<strong>TanStack Query が非同期データを識別してキャッシュするための最も基本的な単位</strong>だ。その小さな配列には、キャッシュスロットの識別子、依存配列、無効化の範囲、そして v5 ではデータ型の情報までが凝縮されている。一点にこれほど多くの責務が集まっているからこそ、どのように記述し、どのように管理するかが、コードベース全体の認知負荷に直結する。</p>
<p>各段階は、その時点で誰かが直面した現実の問題に対する答えだった。だから、単に「今は v5 だから、常に <code>queryOptions</code> だけを使えばよい」のではなく、**「自分のコードベースは今、どの段階の問題に直面しているのか」**を先に見極めるのが正しい順序だ。インライン配列で十分なプロジェクトへドメインファクトリーを導入することは、それ自体が過剰設計になり得る。</p>
<p>読者の皆さんも、自分のプロジェクトで一度確認してみてほしい。queryKey がコード全体にどのように散らばっているか、無効化がどのように行われているか、そしてその構造が現在のチーム規模とドメインの複雑さに合っているかを。</p>
<h2 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h2>
<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[エラーハンドリング]]></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[今回は、フロントエンドでエラーをどう捉えるかについて考えてみたい。 筆者は実務でエラーハンドリングを書くたび、どこか釈然としない感覚を抱くことが多かった。あるエラーは try/catch で捉え、別のエラーは ErrorBoundary が捉え、さらに別のエラーは TanStack Query の onError が捉える。それぞれの守備範囲は微妙に重なったり、ずれたりする。その結果、エラーが漏れ...]]></description>
            <content:encoded><![CDATA[<p>今回は、<strong>フロントエンドでエラーをどう捉えるか</strong>について考えてみたい。</p>
<p>筆者は実務でエラーハンドリングを書くたび、どこか釈然としない感覚を抱くことが多かった。あるエラーは <code>try/catch</code> で捉え、別のエラーは <code>ErrorBoundary</code> が捉え、さらに別のエラーは TanStack Query の <code>onError</code> が捉える。それぞれの守備範囲は微妙に重なったり、ずれたりする。その結果、エラーが漏れる日もあれば、意図しない場所まで伝播する日もあった。</p>
<p>問題は、こうした道具の挙動をまとめて整理する機会がほとんどなかったことだ。「Error Boundary はレンダー段階のエラーだけを捉える」とは知っていても、それが実際に何を意味するのか、<code>reset</code> を呼ぶと内部で何が起きるのか、<code>throwOnError</code> を有効にしたとき TanStack Query がいつエラーを再送出するのかを正確に説明しろと言われると、答えに詰まってしまう。</p>
<p>この記事では、React の公式ガイド、<code>react-error-boundary</code> ライブラリ、TanStack Query v5 の公式ドキュメントをもとに、フロントエンドのエラーハンドリングに使う各ツールが<strong>どこまでを担うのか</strong>、そして<strong>どう組み合わせるのか</strong>を整理する。</p>
<h2 id="react-が捉えられるエラー捉えられないエラー"><a class="anchor" href="#react-が捉えられるエラー捉えられないエラー">React が捉えられるエラー、捉えられないエラー</a></h2>
<p>最も基本的な問いから始めよう。<strong>React はどのようなエラーを捉えるのか。</strong></p>
<p>React の公式ドキュメントでは、Error Boundary が捉えられるエラーと、捉えられないエラーを明確に区別している。</p>
<p><strong>Error Boundary が捉える範囲</strong></p>
<ul>
<li>子コンポーネントの<strong>レンダー中</strong>に発生したエラー</li>
<li><strong>ライフサイクルメソッド</strong>内で発生したエラー</li>
<li><strong>コンストラクター</strong>で発生したエラー</li>
</ul>
<p><strong>Error Boundary が捉えられない範囲</strong></p>
<ul>
<li><strong>イベントハンドラー</strong>内のエラー</li>
<li><code>setTimeout</code>、<code>requestAnimationFrame</code>、<strong>Promise などの非同期コード</strong>のエラー</li>
<li>**サーバーサイドレンダリング（SSR）**中のエラー</li>
<li><strong>Error Boundary 自身</strong>で発生したエラー</li>
</ul>
<p>なぜこの区別が重要なのだろうか。普段扱うエラーの大半は、実は**後者に属する。**ボタンのクリックからミューテーションを実行したところサーバーが 500 を返した、<code>useEffect</code> 内のデータ取得が失敗した、フォーム送信中に検証ロジックが例外を送出した、といったケースだ。これらのエラーを React が自動で捉えることはない。開発者が明示的に捕捉して処理する必要がある。</p>
<p>したがって、フロントエンドのエラーハンドリングは二つに分かれる。<strong>レンダー段階のエラーは Error Boundary で</strong>、<strong>それ以外のエラーは try/catch やライブラリのコールバックで</strong>扱う。この二つが交わる地点で、TanStack Query のような非同期状態管理ライブラリが橋渡しの役割を果たす。</p>
<h2 id="error-boundary-の正体"><a class="anchor" href="#error-boundary-の正体">Error Boundary の正体</a></h2>
<p>Error Boundary は、結局のところ二つのライフサイクルメソッドを持つ<strong>クラスコンポーネント</strong>だ。React の公式ドキュメントによると、Error Boundary になるには、次の二つのメソッドのいずれか（通常は両方）を実装する必要がある。</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">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ErrorBoundary</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> extends</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">Component</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:#FFAB70;--shiki-light:#E36209">props</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    super</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(props);</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hasError: </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:#6A737D;--shiki-light:#6A737D">  // 에러 발생 시 state를 업데이트해 다음 렌더에서 fallback UI를 보여준다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  static</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getDerivedStateFromError</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>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hasError: </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">  // 에러가 발생한 직후에 호출. 로깅 같은 사이드이펙트는 여기서 처리한다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  componentDidCatch</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">info</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    logErrorToMyService</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error, info.componentStack);</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">  render</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:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state.hasError) {</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">.props.fallback;</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:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.props.children;</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>getDerivedStateFromError</code> は<strong>純粋関数</strong>でなければならない。副作用を起こさず、新しい状態だけを返す役割だ。一方、<code>componentDidCatch</code> は副作用を扱う場所である。Sentry へのエラー送信や、コンソールへのコンポーネントスタック出力はここで行う。</p>
<p>ここで重要な点が一つある。この二つのメソッドは**クラスコンポーネントにしか存在しない。**関数コンポーネントで Error Boundary を作る公式な方法は、今のところない。<a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React 公式ドキュメント</a>にも明記されている。</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>現在、Error Boundary を関数コンポーネントとして記述する方法はありません。</p></div><div class="quote-original" lang="en"><p>There is currently no way to write an Error Boundary as a function component.</p></div></blockquote>
<p>毎回クラスコンポーネントを自分で書くのは煩雑なので、通常は <code>react-error-boundary</code> ライブラリを使うことになる。（React のメンテナーの一人だった Brian Vaughn が開発したライブラリで、事実上の標準として利用されている。）</p>
<h2 id="react-error-boundary-が提供する三つのフォールバック"><a class="anchor" href="#react-error-boundary-が提供する三つのフォールバック">react-error-boundary が提供する三つのフォールバック</a></h2>
<p><code>react-error-boundary</code> の <code>ErrorBoundary</code> コンポーネントでは、フォールバック UI を指定するプロパティが<strong>三つの形式</strong>で用意されている。それぞれの使い方を簡単に見てみよう。</p>
<h3 id="フォールバック"><a class="anchor" href="#フォールバック">フォールバック</a></h3>
<p>最も単純な形式で、静的な JSX をそのまま渡す。</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 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:#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">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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>
<p>エラーオブジェクトやリセット関数にアクセスする必要がない場合に使う。実務ではエラーメッセージや再試行の操作が必要になることが多く、筆者はこれまで使ったことがない。</p>
<h3 id="fallbackcomponent"><a class="anchor" href="#fallbackcomponent">FallbackComponent</a></h3>
<p>フォールバック UI を別のコンポーネントに切り出し、その<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">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ErrorFallback</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>
<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">pre</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{error.message}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">pre</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">{resetErrorBoundary}>다시 시도&#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:#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">Page</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>
<p>エラーオブジェクトと <code>resetErrorBoundary</code> 関数が props として自動的に注入される。フォールバック UI をほかの場所でも再利用する可能性があるなら、この形式がすっきりしている。</p>
<h3 id="fallbackrender"><a class="anchor" href="#fallbackrender">fallbackRender</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:#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">  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:#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">>오류가 발생했습니다: {error.message}&#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">{resetErrorBoundary}>다시 시도&#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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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>
<p><code>FallbackComponent</code> と本質的には同じ役割だが、<strong>別のコンポーネントを作らずインラインで処理</strong>できる。外側のクロージャー（親の状態やハンドラーなど）へアクセスする必要があるときに便利だ。</p>
<p>三つのうち、どれか一つが正解というわけではない。筆者が実務でよく使うのは、<strong>共通の ErrorFallback コンポーネントを一つ用意し、<code>FallbackComponent</code> で注入する</strong>パターンだ。デザインシステムとトーンの一貫性を保つためである。ページごとに異なるフォールバックが必要な場合だけ、<code>fallbackRender</code> でインラインに記述する。</p>
<h2 id="リセットは実際に何をするのか"><a class="anchor" href="#リセットは実際に何をするのか">リセットは実際に何をするのか</a></h2>
<p><code>react-error-boundary</code> を使っていると、自然と <code>resetErrorBoundary</code> という関数に出会う。フォールバックの「もう一度試す」ボタンから呼ばれる、あの関数だ。この関数が実際に何をするのかを見てみよう。</p>
<p>結論から言うと、<code>resetErrorBoundary</code> は ErrorBoundary コンポーネントに対して、<strong>自身の状態を初期化し、子要素を再レンダーするよう通知する</strong>だけだ。TanStack Query のキャッシュなど、外部の状態を自動的に変更することはない。</p>
<p>内部で起きることを順に整理すると、次のようになる。</p>
<ol>
<li><code>resetErrorBoundary()</code> が呼ばれる。</li>
<li>ErrorBoundary 内部の <code>hasError</code> 状態が <code>false</code> に戻る。</li>
<li>（任意）<code>onReset</code> コールバックが実行される。ユーザー定義の副作用はここで起きる。</li>
<li>子要素が再レンダーされる。エラーの原因となった状態やキャッシュなどが残っていれば、<strong>同じエラーが再び送出される。</strong></li>
</ol>
<p>最後の 4 番目が重要だ。**リセットは「エラーを忘れてもう一度描画してみる」という意味にすぎず、「エラーを引き起こした原因を直す」という意味ではない。**そのため、リセットするだけでは同じエラーが無限に繰り返される可能性がある。</p>
<p>この問題に対処するため、さらに二つの仕組みが用意されている。</p>
<h3 id="onreset"><a class="anchor" href="#onreset">onReset</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:#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">  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:#B392F0;--shiki-light:#6F42C1">  onReset</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">    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">'user'</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:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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="resetkeys"><a class="anchor" href="#resetkeys">resetKeys</a></h3>
<p>配列に含まれる値が変わると、ErrorBoundary が自動的にリセットされる。URL パラメーター、検索語、選択中のタブなど、「この値が変わったなら再試行する意味がある」と判断できるキーを渡す。</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">  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:#B392F0;--shiki-light:#6F42C1">  resetKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</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:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">UserProfile</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{userId} /></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>userId</code> が変わると自動的にリセットされ、子要素が再レンダーされる。ユーザーが別のプロフィールへ移動すれば、以前のエラーは自然に消える。</p>
<h2 id="イベントハンドラーと非同期エラーはどう捉えるのか"><a class="anchor" href="#イベントハンドラーと非同期エラーはどう捉えるのか">イベントハンドラーと非同期エラーはどう捉えるのか</a></h2>
<p>前述のとおり、Error Boundary はイベントハンドラーや非同期コードのエラーを捉えられない。しかし、扱うエラーの大半はそこで発生する。では、どうすればよいのだろうか。</p>
<p><code>react-error-boundary</code> は、この問題に対処するための <strong><code>useErrorBoundary</code> フック</strong>を提供している。このフックは <code>showBoundary</code> という関数を返す。この関数を呼ぶと、最も近い ErrorBoundary へ強制的にエラーを送ることができる。</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"> { useErrorBoundary } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> MyComponent</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">showBoundary</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"> useErrorBoundary</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"> handleClick</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">    try</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"> someAsyncOperation</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">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (error) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      showBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error);</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"> &#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">{handleClick}>실행&#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>重要なのは、<strong>開発者が明示的に引き上げる必要がある</strong>という点だ。React が自動で行うわけではない。非同期エラーを ErrorBoundary の領域へ移したいなら、<code>try/catch</code> で捉えて <code>showBoundary</code> に渡さなければならない。</p>
<p>このパターンを理解すれば、「ErrorBoundary が捉えるエラーと捉えられないエラーがあるのはなぜか」という疑問は明快に解ける。答えは単純だ。**「レンダー段階まで引き上げたかどうか」**である。</p>
<h2 id="tanstack-query-はエラーをどう扱うのか"><a class="anchor" href="#tanstack-query-はエラーをどう扱うのか">TanStack Query はエラーをどう扱うのか</a></h2>
<p>ここまで整理すると、自然に次の疑問が浮かぶ。日々使っている <code>useQuery</code> は非同期リクエストを扱うが、そこで発生したエラーはどのように処理されるのだろうか。</p>
<p>TanStack Query は、デフォルトでは<strong>エラーを <code>error</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:#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">error</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:#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">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryFn: fetchTodos,</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"> (isError) {</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">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>에러: {error.message}&#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>error</code> フィールドへ値が入るだけである。ErrorBoundary は関与しない。</p>
<p>ここで重要な事実を確認しておこう。**TanStack Query のデフォルトの挙動は「エラーを送出しない」ことだ。**クエリ関数が例外を送出しても Promise を reject しても、そのエラーは <code>error</code> フィールドに格納されるだけで、React のレンダーフローを中断しない。そのため、特別な設定をしない限り ErrorBoundary が動作することはない。</p>
<p>もう一つ、TanStack Query は<strong>デフォルトでエラー時に自動で 3 回再試行する。</strong></p>
<p>デフォルトの <code>retryDelay</code> は指数バックオフ方式で、最大 30 秒まで延びる。つまり、最初に失敗してもユーザーへすぐエラーが表示されるわけではない。1 秒、2 秒、4 秒の間隔で再試行し、それでも失敗すると、ようやく <code>error</code> フィールドへ値が入る。（開発中に「なぜエラーが表示されるまで時間がかかるのだろう」と疑問に思ったことがあるなら、十中八九これが原因だ。）</p>
<h3 id="throwonerror-で-errorboundary-と接続する"><a class="anchor" href="#throwonerror-で-errorboundary-と接続する">throwOnError で ErrorBoundary と接続する</a></h3>
<p>では、TanStack Query のエラーを ErrorBoundary へ流すにはどうすればよいのか。答えは <strong><code>throwOnError</code></strong> オプションだ。（v4 までは <code>useErrorBoundary</code> という名前だったが、v5 で <code>throwOnError</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:#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">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryFn: fetchTodos,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  throwOnError: </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>このオプションを有効にすると、TanStack Query はエラーを**次のレンダーサイクルで再送出する。**すると、その例外送出はレンダー段階のエラーとなり、ErrorBoundary が捉えられるようになる。</p>
<p><code>throwOnError</code> には関数も渡せる。あるエラーは ErrorBoundary へ送り、別のエラーはコンポーネント自身で処理する、といった分岐が可能だ。</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">({</span></span>
<span data-line=""><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:#E1E4E8;--shiki-light:#24292E">  queryFn: fetchTodos,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 5xx 서버 에러만 ErrorBoundary로 보낸다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  throwOnError</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:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.response?.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</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:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>このパターンが実用的なのは、**4xx のようなクライアントエラー（入力検証の失敗や権限不足など）**はその場でメッセージを表示するのが自然であり、<strong>5xx のようなサーバーエラー</strong>はページ全体を覆って「しばらくしてからもう一度お試しください」と表示するのが適切だからだ。</p>
<h3 id="usesuspensequery"><a class="anchor" href="#usesuspensequery">useSuspenseQuery</a></h3>
<p><code>useSuspenseQuery</code> を使っている場合、<code>throwOnError</code> を意識する必要はない。Suspense モードでは、<strong>常にエラーを送出するのがデフォルトの挙動</strong>である。</p>
<p>つまり、<code>useSuspenseQuery</code> を使うことは、<strong>ローディングは Suspense が、エラーは ErrorBoundary が</strong>処理するということだ。コンポーネント内で <code>if (isError)</code> や <code>if (isLoading)</code> といった分岐を書く必要がなくなり、代わりに外側を二つの境界で囲む必要がある。</p>
<h2 id="queryerrorresetboundary"><a class="anchor" href="#queryerrorresetboundary">QueryErrorResetBoundary</a></h2>
<p>ここまで読むと、さらに一つ疑問が浮かぶ。ユーザーがフォールバックの「もう一度試す」ボタンを押すと、どうなるのだろうか。</p>
<p>先ほど見たように、<code>resetErrorBoundary</code> が初期化するのは ErrorBoundary の <code>hasError</code> 状態だけだ。しかし、TanStack Query のキャッシュには、依然として<strong>エラー状態のまま固まったクエリ</strong>が残っている。子要素が再レンダーされると、TanStack Query はキャッシュを見て「このクエリはすでにエラーだ」と判断し、すぐに同じエラーを再送出する。（恐ろしい無限ループだ。）</p>
<p>この問題を解決するため、TanStack Query は <strong><code>useQueryErrorResetBoundary</code></strong> フックと <strong><code>QueryErrorResetBoundary</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:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { useQueryErrorResetBoundary } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ErrorBoundary } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> App</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">reset</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"> useQueryErrorResetBoundary</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"> (</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">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:#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">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">{resetErrorBoundary}>다시 시도&#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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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">}</span></span></code></pre></figure>
<p>ここで起きることを時系列で整理しよう。</p>
<ol>
<li>ユーザーが「もう一度試す」ボタンをクリック → <code>resetErrorBoundary()</code> が呼ばれる</li>
<li>ErrorBoundary が <code>onReset</code> コールバックを実行 → <code>reset()</code> が呼ばれる（TanStack Query のエラー状態を初期化）</li>
<li>ErrorBoundary が自身の状態を初期化し、子要素を再レンダー</li>
<li>子要素内の <code>useQuery</code> が動作 → エラー状態が消えているため、再びデータ取得を試みる</li>
</ol>
<p>重要なのは、<code>onReset</code> に <code>reset</code> を接続した部分だ。この一行によって、ErrorBoundary と TanStack Query の状態が同期される。</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">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryErrorResetBoundary } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ErrorBoundary } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> App</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:#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">>에러가 발생했습니다: {error.message}&#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">{resetErrorBoundary}>다시 시도&#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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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>フック版との最大の違いは、<strong>レンダープロップパターン</strong>で <code>reset</code> 関数を子へ渡すことだ。<code>QueryErrorResetBoundary</code> は子要素として関数を受け取り、その引数として <code>{ reset }</code> を渡し、関数の戻り値をレンダーする。そのため、内側ですぐ <code>onReset={reset}</code> と接続できる。</p>
<p>フック版では、最も近い <code>QueryErrorResetBoundary</code> がなければ**グローバルキャッシュのエラーをリセットする。**コンポーネント版では、リセットのスコープを自身の子領域に限定する。範囲を狭く制御したいなら、コンポーネント版のほうが安全だ。</p>
<p>ここで一つ確認しておこう。**リセットはキャッシュを削除しない。**データを丸ごと消すのではなく、「エラーとマークされたクエリのエラー状態を解除する」ことに近い。実際にデータを無効化したい場合は、<code>queryClient.invalidateQueries()</code> を別途呼ぶ必要がある。</p>
<h2 id="ミューテーションのエラー"><a class="anchor" href="#ミューテーションのエラー">ミューテーションのエラー</a></h2>
<p>ここまで説明したパターンは、ほぼすべて <code>useQuery</code> を前提としていた。しかし、<strong><code>useMutation</code> では事情が少し異なる。</strong></p>
<p>最大の違いは、ミューテーションは通常、**ユーザーの明示的な操作（クリックや送信）**によって開始されることだ。そのため、エラーもその操作に近い場所で処理するのが自然である。ページ全体をフォールバックで覆うより、トーストやフォーム脇のエラーテキストで「決済に失敗しました。カード情報をもう一度確認してください」のように表示するほうが適切だ。</p>
<p>TkDodo の <a href="https://tkdodo.eu/blog/mastering-mutations-in-react-query" target="_blank" rel="noopener noreferrer">React Query におけるミューテーションの使いこなし</a>では、この違いの本質を一言でまとめている。**クエリは宣言的で、ミューテーションは命令的である。**クエリはコンポーネントがマウントされると自動的に実行され、同じキーを持つほかのコンポーネントも共同で購読し、キャッシュして再利用される。一方、ミューテーションはユーザーがボタンを押して初めて実行され、キャッシュもされず、呼び出したコンポーネントのインスタンスと一対一で結び付く。この本質的な違いが、エラー処理の方法を二つに分ける。</p>
<p><code>useQuery</code> のデフォルトの <code>retry</code> は <code>3</code> だが、<strong><code>useMutation</code> のデフォルトの <code>retry</code> は <code>0</code> である。<strong>理由は単純で、ミューテーションは</strong>副作用</strong>を引き起こすからだ。決済リクエストがネットワークのタイムアウトで失敗したとき、ライブラリが自動的にさらに 2 回呼び出せば、ユーザーのカードへ 3 回請求されるかもしれない。</p>
<p>したがって、ミューテーションの再試行は、その処理が<strong>冪等であると開発者が確信できる場合に限り</strong>明示的に有効にするのが原則だ。同じリクエストを 2 回送っても結果が変わらないことが保証される GET 系の安全な取得処理や、サーバーが冪等性キーを受け取って重複を防ぐ場合に限られる。</p>
<p><code>useQuery</code> のエラーは**キャッシュに保持される。**そのため、同じ <code>queryKey</code> を購読するほかのコンポーネントにもすぐ伝播し、<code>QueryErrorResetBoundary</code> のような仕組みで一括してリセットする必要があった。</p>
<p>ミューテーションは異なる。あるコンポーネントのミューテーションインスタンスで発生したエラーは、**そのインスタンスの状態にだけ残る。**同じ <code>mutationFn</code> を使う別のコンポーネントのミューテーションには影響しない。そのため、TanStack Query に <code>MutationErrorResetBoundary</code> のようなものは存在しない。<strong>必要がないから</strong>だ。</p>
<p>この違いは実務にも一つ影響する。同じ <code>useMutation</code> を呼ぶコンポーネントが二つあっても、一方で発生したエラーはもう一方からは見えない。「このミューテーションのエラーをアプリケーション全体で把握したい」のであれば、コンポーネント単位の <code>onError</code> では不十分で、<code>MutationCache.onError</code> まで引き上げる必要がある。</p>
<h3 id="mutate-と-mutateasync"><a class="anchor" href="#mutate-と-mutateasync">mutate と mutateAsync</a></h3>
<p><code>useMutation</code> は二つの実行関数を返す。この違いによって、エラーハンドリングの方法が分かれる。</p>
<p>mutate の戻り値の型は <code>void</code> であり、Promise を返さない。そのため await で結果を待つことはできず、呼び出し結果は <code>onSuccess/onError</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"> mutation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMutation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  mutationFn: createPost,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onError</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:#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">    toast.</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">`등록 실패: ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">error</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">message</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:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">mutation.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(newPost);</span></span></code></pre></figure>
<p>一方、<code>mutateAsync</code> は Promise を返す。エラーは <code>try/catch</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"> mutation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMutation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ mutationFn: createPost });</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"> 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:#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">  try</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"> mutation.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutateAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(newPost);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    router.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">result</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:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (error) {</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:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>どちらをいつ使えばよいのか。筆者は次の基準で使い分けている。</p>
<ul>
<li><strong>ミューテーションの完了後に後続処理が必要</strong>（成功時のルーティングや結果の利用など）→ <code>mutateAsync</code></li>
<li><strong>単に呼び出し、副作用はコールバックへ任せる</strong>（「いいね」の切り替えや、トーストを表示するだけの場合など）→ <code>mutate</code> + <code>onError</code></li>
</ul>
<p>ここで、よくある間違いが一つある。**<code>mutateAsync</code> を使いながら <code>try/catch</code> を置かないと、未処理の Promise rejection が発生する。**コールバックベースの <code>mutate</code> は内部でエラーを吸収するが、<code>mutateAsync</code> は呼び出し元へエラーを送出するのがデフォルトの挙動だ。この違いを知らずに混在させると、コンソールが赤い警告で埋め尽くされる。</p>
<h3 id="onerror"><a class="anchor" href="#onerror">onError</a></h3>
<p>もう一つ見落としやすい点がある。<code>useMutation</code> の <code>onError</code> は<strong>二か所</strong>（フックと mutate）で定義できる。</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"> mutation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMutation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  mutationFn: createPost,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onError</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:#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">    Sentry.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">captureException</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error);</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>フックレベルでは常に実行されるが、mutate レベルでは呼び出し時にのみ実行される。</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">mutation.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(newPost, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onError</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:#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">    setFormError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error.message);</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>フックレベル → mutate レベル</strong>である。両方のコールバックが定義されている場合、まずフックレベル、続いて mutate レベルが実行される。</p>
<h2 id="グローバルなエラーハンドリング"><a class="anchor" href="#グローバルなエラーハンドリング">グローバルなエラーハンドリング</a></h2>
<p>ここまでのパターンは、すべてコンポーネントレベルのものだった。しかし、「すべてのクエリエラーを一か所で記録したい」「401 エラーは必ずログアウトとして処理したい」といった要件もあり得る。このような横断的関心事には、<strong>QueryClient の作成時に <code>QueryCache</code>/<code>MutationCache</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:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryClient, QueryCache, MutationCache } </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">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">  queryCache: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryCache</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onError</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"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</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:#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">        toast.</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">`데이터 갱신 실패: ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">error</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">message</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:#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">  mutationCache: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MutationCache</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onError</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:#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"> (error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 401</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        redirectToLogin</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:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>重要なのは、<code>QueryCache.onError</code> が<strong>クエリごとに一度だけ</strong>呼ばれることだ。同じクエリを複数のコンポーネントが購読していても、コールバックは一度しか実行されないため、トーストが重複するような問題は起きない。</p>
<p>上の例のように、<code>query.state.data !== undefined</code> を確認する方法もある。<strong>すでにキャッシュ済みのデータがある状態で再取得に失敗した</strong>のであれば、ユーザーはひとまず画面上でデータを見られている。このとき ErrorBoundary でページを覆うのは過剰だ。更新に失敗したことだけを知らせるのが適切である。反対に、キャッシュデータがない初回ロードで失敗した場合は、ErrorBoundary が捉えてフォールバックを表示するのが妥当だ。</p>
<p>この二つの流れを組み合わせれば、「初回ロードの失敗は ErrorBoundary、バックグラウンドでの再取得の失敗はトースト」という明快な方針を設計できる。</p>
<h2 id="共通コンポーネント"><a class="anchor" href="#共通コンポーネント">共通コンポーネント</a></h2>
<p>ここまで読むと、一つ欲が出てくる。毎回 <code>QueryErrorResetBoundary</code>、<code>ErrorBoundary</code>、<code>Suspense</code> の三重構造で囲むのは面倒なので、<strong>一つのコンポーネントにまとめて再利用</strong>できないだろうか。</p>
<p>自然な発想だ。実際、筆者も以前は次のような <code>AsyncBoundary</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">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryErrorResetBoundary } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Suspense, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ComponentType, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ReactNode } </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 style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ErrorBoundary, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FallbackProps } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react-error-boundary'</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"> { ErrorFallback } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> './ErrorFallback'</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"> { Spinner } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> './Spinner'</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"> Props</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"> ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</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">  rejectedFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</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:#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"> AsyncBoundary</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:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  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:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  rejectedFallback</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">}</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 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">{rejectedFallback}></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>ページでは、次の一行だけで済む。</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">AsyncBoundary</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">Content</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">AsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>きれいに見える。しかし、同僚から次のようなフィードバックを受けた。</p>
<blockquote>
<p>AsyncBoundary という名前は、それほど決まった意味で使われているわけではないので、中に何が入っていても大きな違和感はなさそうです。ただ、<strong>React Query の ResetBoundary まで入っていることは、少し予想しにくいかもしれません。</strong></p>
</blockquote>
<blockquote>
<p>それから、<code>pendingFallback</code> と <code>rejectedFallback</code> にデフォルト値が入っている点も少し気になります。<code>&#x3C;AsyncBoundary></code> の一行だけでは中でどのフォールバックが使われるのか分からないので、<strong>それが props のデフォルト値だという事実自体に気付かないと思います。</strong></p>
</blockquote>
<h3 id="名前が依存関係を隠す"><a class="anchor" href="#名前が依存関係を隠す">名前が依存関係を隠す</a></h3>
<p>このコンポーネントの名前は <code>AsyncBoundary</code> であり、非同期処理の境界という意味しか伝わらない。しかし、その実装は <strong>TanStack Query に強く結合している。</strong><code>QueryErrorResetBoundary</code> が含まれ、<code>onReset</code> に <code>reset</code> が接続されている。つまり、このコンポーネントは実際には**「React Query を使う非同期領域のための境界」**なのに、名前からはまったく読み取れない。</p>
<p>なぜこれが問題なのか。<strong>読み手の予測を裏切る</strong>からだ。コードは一行ずつ解釈するものではなく、経験から蓄積されたパターンをもとに<strong>予測しながら</strong>読む。予測が外れたとき、認知負荷は急激に高まる。</p>
<p><code>AsyncBoundary</code> という名前を初めて見た同僚が思い浮かべるのは、「非同期処理に使う汎用的な境界」だろう。SWR を使うときも、fetch を直接使うときも利用できそうに見える。しかし実際には <code>QueryErrorResetBoundary</code> が組み込まれており、<strong>TanStack Query を使わないコンテキストでも意味のない結合</strong>が付いてくる。名前と実装の間に亀裂があるのだ。</p>
<p>これは、抽象化の漏れ（leaky abstraction）とは逆向きの問題と捉えられる。一般的な漏れは「抽象化の背後に隠すべき詳細が表へ漏れ出すこと」だが、ここでは**表に出すべき依存関係が名前の背後へ隠れすぎている。**こちらのほうが悪質かもしれない。（知らずに使ってしまうからだ。）</p>
<h3 id="名前に依存関係を表す"><a class="anchor" href="#名前に依存関係を表す">名前に依存関係を表す</a></h3>
<p>最も単純な処方は、名前を変えることだ。<code>AsyncBoundary</code> ではなく、<strong><code>QueryAsyncBoundary</code></strong> のように依存関係を名前へ明示する。Toss が開発した <a href="https://suspensive.org/" target="_blank" rel="noopener noreferrer">Suspensive</a> ライブラリを見ると、依存関係が明示されている。<code>@suspensive/react</code> には汎用的な <code>ErrorBoundary</code> と <code>Suspense</code> だけがあり、TanStack Query と組み合わせたコンポーネントは、別パッケージ <code>@suspensive/react-query</code> の <code>QueryAsyncBoundary</code> として分離されている。</p>
<p>この一語の違いが読み手へ伝える情報は大きい。<code>Query</code> という接頭辞が付いた瞬間、**「これは TanStack Query 環境専用なのだ」**とすぐに分かる。誤ったコンテキストで使うミスを未然に防げる。</p>
<h3 id="合成可能な単位へ分解する"><a class="anchor" href="#合成可能な単位へ分解する">合成可能な単位へ分解する</a></h3>
<p>もう少し根本的な方法は、<strong>まとめないこと</strong>だ。</p>
<p>ErrorBoundary と Suspense は本質的に<strong>異なる関心事</strong>であり、一つのコンポーネントにまとめると、合成の柔軟性が失われかねない。あるページでは ErrorBoundary だけが必要かもしれず、別のページでは Suspense だけが必要かもしれない。また、二つの Suspense を一つの ErrorBoundary 内に置きたいページもあるだろう。<code>AsyncBoundary</code> としてまとめてしまうと、こうした変形が不自然になる。分離しておけば自由に合成できる。</p>
<p>このパターンはコードが一行長くなるものの、<strong>各境界が何を担うのかをコードからそのまま読み取れる</strong>という利点がある。また、<code>useSuspenseQuery</code> を使う場合、一度に処理したい単位とエラーを捉えたい単位は異なることが多いため、分離されているほうが自然だ。</p>
<p>筆者の結論はこうだ。**繰り返される合成パターンが本当に同一ならまとめ、変形が必要なら分離する。**そして、まとめる場合も名前で依存関係を明らかにする。この二つの原則を守るだけでも、「AsyncBoundary の中に何があるのか分からない」というレビューを受けることは減るだろう。</p>
<h3 id="デフォルト-props"><a class="anchor" href="#デフォルト-props">デフォルト Props</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:#E1E4E8;--shiki-light:#24292E">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:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">rejectedFallback </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ErrorFallback,</span></span></code></pre></figure>
<p><code>&#x3C;QueryAsyncBoundary>...&#x3C;/QueryAsyncBoundary></code> と一行書くだけで動作するのは、内部で <code>Spinner</code> と <code>ErrorFallback</code> が自動的に設定されるからだ。<strong>これは名前から予測できる情報ではない。</strong></p>
<p>これは、先ほど批判した「名前が依存関係を隠す」という問題の別バージョンだ。<code>Query</code> という接頭辞で依存関係を表すよう名前を直しても、<code>Spinner</code> と <code>ErrorFallback</code> という UI の依存関係はデフォルト prop の背後に隠れたままである。<strong>隠れる場所が一段内側へ移ったにすぎない。</strong></p>
<p>解決策は単純だ。<strong>二つのフォールバックを必須 prop とし、呼び出し元で毎回注入する。</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">interface</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:#FFAB70;--shiki-light:#E36209">  children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</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">  pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</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">  rejectedFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</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:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<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">QueryAsyncBoundary</span></span>
<span data-line=""><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:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  rejectedFallback</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">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Content</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:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>コードは二行長くなる。それでもこのコストを受け入れる理由は明確だ。**書き手の負担を増やす代わりに、すべての読み手が追跡に費やすコストを減らせる。**呼び出し元を見れば、どのフォールバックが表示されるのかがその場で分かる。「このコンポーネントのデフォルト値は何だっただろう」と別のファイルを開いて確認する必要がない。コードは書かれる回数より読まれる回数のほうがはるかに多い、というおなじみの命題は、ここでもそのまま当てはまる。</p>
<h2 id="errorfallback"><a class="anchor" href="#errorfallback">ErrorFallback</a></h2>
<p>もう一つ確認したい点がある。通常、<code>ErrorFallback</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"> DEFAULT_ERROR_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>
<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"> ErrorFallback</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">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getErrorMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DEFAULT_ERROR_MESSAGE</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"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Flex</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> direction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"column"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> alignItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"center"</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:#B392F0;--shiki-light:#6F42C1"> aria-live</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"assertive"</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">Text</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{message}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Text</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">Spacing</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> size</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">16</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">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">{resetErrorBoundary}>다시 시도&#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">Flex</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>role="alert"</code> と <code>aria-live="assertive"</code> まで配慮された、整った実装だ。しかし、一つ問いかけてみよう。<strong>「401、404、500、ネットワーク切断のいずれであっても、同じ画面を表示してよいのだろうか。」</strong></p>
<p>ほとんどの場合、答えは<strong>否</strong>だ。エラーの種類によって、ユーザーが取るべき行動が異なるからである。</p>
<table>
<thead>
<tr>
<th>エラーの種類</th>
<th>ユーザーの行動</th>
<th>「もう一度試す」に意味があるか</th>
</tr>
</thead>
<tbody>
<tr>
<td>ネットワーク切断</td>
<td>接続を確認して再試行</td>
<td>O</td>
</tr>
<tr>
<td>5xx サーバーエラー</td>
<td>しばらくしてから再試行</td>
<td>O</td>
</tr>
<tr>
<td>401 認証エラー</td>
<td>ログイン画面へ移動</td>
<td>X</td>
</tr>
<tr>
<td>403 権限不足</td>
<td>別の画面へ移動</td>
<td>X</td>
</tr>
<tr>
<td>404 リソースなし</td>
<td>一覧へ戻る</td>
<td>△</td>
</tr>
<tr>
<td>422 検証エラー</td>
<td>入力値を修正</td>
<td>X</td>
</tr>
</tbody>
</table>
<p>すべてのケースで「もう一度試す」ボタンを表示するのは、<strong>「そのエラーを解決できる行動」をユーザーへ誤って案内する</strong>ことになる。401 エラーで「もう一度試す」を押しても、同じ 401 が再び表示されるだけだ。ユーザーが本当に行うべきなのはログインである。</p>
<p>したがって、エラーのフォールバックは<strong>エラーの種類に応じて描き分けるべき</strong>だ。最初から巨大な <code>if/else</code> で処理する必要はなく、小さなコンポーネントを用意して分岐すればよい。</p>
<p>各フォールバックコンポーネントは、そのエラーに適したメッセージと操作だけを提示する。ユーザーが実際に取れる行動だけを画面に残すのだ。</p>
<h3 id="shouldcatch"><a class="anchor" href="#shouldcatch">shouldCatch</a></h3>
<p>さらに一歩進めると、<strong>「捉えるエラー」と「上位へ流すエラー」をコンポーネントレベルで区別する</strong>パターンもある。Suspensive の <code>ErrorBoundary</code> は <code>shouldCatch</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:#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">  shouldCatch</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:#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">&#x26;&#x26;</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 style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><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">{ServerErrorFallback}</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">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldCatch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{NetworkError} </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">{NetworkErrorFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Page</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">&#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>内側の ErrorBoundary はネットワークエラーだけを捉え、5xx エラーは捉えない。捉えられなかったエラーは React のデフォルトの挙動に従って**上位の ErrorBoundary へ伝播する。**そこで外側の ErrorBoundary が 5xx を捉える仕組みだ。同じエラー処理を if/else で書くより、<strong>境界そのものに意味を持たせられる</strong>点が魅力的である。</p>
<p><code>react-error-boundary</code> にはこの prop がないが、フォールバック内で分岐すれば同じ効果を実現できる。重要なのはパターンそのものであって、ライブラリではない。</p>
<h2 id="まとめ"><a class="anchor" href="#まとめ">まとめ</a></h2>
<p>まとめると、フロントエンドのエラーハンドリングは**一つの道具だけでは完結しない。**レンダー段階のエラーは Error Boundary、イベントハンドラーのエラーは <code>try/catch</code> や <code>showBoundary</code>、非同期データ取得のエラーは TanStack Query の <code>throwOnError</code> と <code>useQueryErrorResetBoundary</code>、ミューテーションのエラーは <code>mutateAsync</code> や <code>onError</code>、横断的関心事は <code>QueryCache</code>/<code>MutationCache</code> がそれぞれ担う。さらに、<strong>共通コンポーネントの名前と合成単位</strong>、<strong>エラー型そのもののドメインモデリング</strong>まで含めて設計して初めて、一貫したエラー方針が完成する。</p>
<p>各ツールが何を担うのかを理解すれば、ようやく**「このエラーはここで捉え、別のエラーはあちらへ流す」**という判断を明確に下せる。そして、その判断の積み重ねが、最終的にユーザー体験の安定性をつくる。真っ白な画面を見せないこと、同じトーストを 5 回表示しないこと、一時的なネットワークエラーでページ全体を停止させないこと、401 エラーでは「もう一度試す」ではなくログイン画面を表示すること。こうした細部が積み重なって、「よくできたサービス」という印象を生む。</p>
<p>もちろん、すべてのプロジェクトで、すべてのパターンが必要なわけではない。単純な管理ツールなら ErrorBoundary 一つとトーストだけで十分かもしれない。一度のミスがそのまま金銭に関わる決済のようなドメインなら、ミューテーションの一つひとつにきめ細かなエラー処理を設ける必要があるだろう。正解はドメインが決める。</p>
<p>この記事を読んだ方も、自分のプロジェクトで「今、自分たちのサービスは、どのエラーを、どこで、どんな名前のコンポーネントによって捉えているのか」を一度点検してみてほしい。正しく捉えられていると思っていても、実は漏れていたり、誤ったフォールバックに到達していたりするエラーは、意外に多いかもしれない。（筆者も毎回そうだった。）</p>
<h2 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h2>
<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://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React、Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <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">[ドキュメント] <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">[ドキュメント] <a href="https://tanstack.com/query/v5/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">TanStack Query、重要なデフォルト設定</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[記事] <a href="https://tkdodo.eu/blog/react-query-error-handling" target="_blank" rel="noopener noreferrer">TkDodo、React Query のエラーハンドリング</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[記事] <a href="https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose" target="_blank" rel="noopener noreferrer">TkDodo、意図的に React Query の API を壊す</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[リポジトリ] <a href="https://github.com/toss/suspensive" target="_blank" rel="noopener noreferrer">toss/suspensive、@suspensive/react-query</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[ドキュメント] <a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router、Error Boundaries</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」という言葉は、面接の定番質問くらいにしか認識していなかった。「Reactのレンダリング処理を作業単位に分割して実行する」という一行の定義を覚え、それがすべてだと思っていた。しかし実際にReactのソースコードを読み始めると、Fiberは単なる概念ではなく、Reactレンダ...]]></description>
            <content:encoded><![CDATA[<p>今回は、Reactの心臓部ともいえる<strong>Fiberアーキテクチャ</strong>について話したい。</p>
<p>筆者が初めてReactに触れた頃、<strong>「Fiber」<strong>という言葉は、面接の定番質問くらいにしか認識していなかった。「Reactのレンダリング処理を作業単位に分割して実行する」という一行の定義を覚え、それがすべてだと思っていた。しかし実際にReactのソースコードを読み始めると、Fiberは単なる概念ではなく、Reactレンダリングの</strong>すべて</strong>を司る実行アーキテクチャだと気づいた。</p>
<blockquote>
<p>初めてReactのソースコードを開いたときの衝撃は、今でも忘れられない。「これは……いったい何なんだ？」と思った。</p>
</blockquote>
<p>この記事では、「Fiberとは何ですか？」という質問に「作業単位に分けて処理するものです」と答えるレベルを超えて、Fiberが<strong>なぜ</strong>誕生し、<strong>どのように</strong>設計され、その構造がReactのConcurrent Featuresを<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はこれを使っていない。その理由は後で詳しく扱う。）</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>Fiber方式では、レンダリング中でもユーザーイベント（ボタンクリック、入力など）にすぐ反応できる。作業を細かく分割して実行するため、ブラウザーが息をつく余地が生まれる。</p>
<p>二つの方式の違いを直接体験したければ、**<a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">こちら</a>**をクリックしてほしい。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を理解するうえで欠かせない重要な概念が、**ダブルバッファリング（Double Buffering）**だ。</p>
<p>この概念を理解するために、ゲームグラフィックスを思い浮かべてみよう。ゲームで画面を描くとき、現在の画面にピクセルを直接描くと、描画途中のフレームがユーザーに見えてしまう<strong>ティアリング</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も同じだ。各更新に**一つのビット（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エンジンの**SMI（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">// 동시성 렌더링: 시간 제한 내에서 작업을 나누어 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopConcurrent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">nonIdle</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"> (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:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> yieldAfter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</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"> (nonIdle </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 25</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">    do</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 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:#B392F0;--shiki-light:#6F42C1"> now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> yieldAfter);</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>workLoopConcurrent</code>には<strong>時間制限</strong>があり、時間を超えるとループを抜ける。</p>
<p>ここで興味深いのは、処理を譲る間隔の違いだ。TransitionやRetryのような<strong>非アイドル作業（ユーザーが知覚できる更新）<strong>は</strong>25ms</strong>間隔で制御を譲り、<strong>アイドル作業（ユーザーが何もしていないときに処理してもよい低優先度の作業）<strong>は</strong>5ms</strong>間隔で譲る。非アイドル作業に25msを与える理由は、意図的にアニメーションを約30fpsに制限し、トランジションのレンダリングがほかの作業を飢餓状態に陥らせるのを防ぐためだ。</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="requestidlecallbackを捨てた理由"><a class="anchor" href="#requestidlecallbackを捨てた理由">requestIdleCallbackを捨てた理由</a></h3>
<p>先ほどFiberの概念モデルでは<code>requestIdleCallback</code>を使うコードを示したが、実際のReactはこれを使っていない。その理由は明確だ。</p>
<ul>
<li><strong>呼び出し頻度が低すぎる</strong>：本当に「アイドル時間（ブラウザーにすることがない時間）」にしか呼ばれないため、負荷の高いページではReactの作業がいつまでも遅延する可能性がある。Dan Abramovも「requestIdleCallback is called too infrequently to be useful for scheduling React work」と述べている。</li>
<li><strong>ブラウザー互換性の問題</strong>：Safariは長い間これを実装しておらず、ブラウザーごとに動作も異なっていた。</li>
<li><strong>20msの上限</strong>：アイドル時間の期限には上限があり、Reactが求めるレベルの予測可能なタイミング制御ができなかった。</li>
</ul>
<p>次に<code>requestAnimationFrame</code>とフレーム予算の推定を組み合わせる方式も試したが、Reactの作業を垂直同期（モニターが垂直走査を完了する時点に合わせてフレーム出力を同期する技術）の周期に合わせる必要はないとの判断から、これも廃止された。</p>
<h3 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h3>
<p>最終的にReactは<strong>MessageChannel</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"> (</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:#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:#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"> channel.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>
<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">  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:#B392F0;--shiki-light:#6F42C1"> setTimeout</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></code></pre></figure>
<p>なぜ<code>setTimeout</code>ではなく<code>MessageChannel</code>なのか。HTML仕様により、<code>setTimeout</code>は5回以上ネストすると<strong>最低4msの遅延</strong>が強制される。一方、<code>MessageChannel</code>はこの制限なしに、イベントループの次のティックで即座にマクロタスクとして実行される。5ms単位で作業を分割するFiberにとって、4msの人為的な遅延は致命的だからだ。</p>
<p>（5msのうち4msが待ち時間なら、実際に働く時間は1msしかない。これはワークライフバランスではなく、ただのライフだ。）</p>
<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>
<p>Schedulerの<code>shouldYieldToHost()</code>は、作業開始後の経過時間が<code>frameInterval</code>（既定値は<strong>5ms</strong>、<code>SchedulerFeatureFlags.js</code>で定義）を超えたか確認し、メインスレッドへ制御を返すかどうかを判断する。</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"><img src="/content/250520/2.png" alt="2.png" width="2486" height="1778" loading="eager" fetchpriority="high" decoding="async"></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>**beginWork(fiber)**では、各Fiberのtype（FunctionComponent、ClassComponent、HostComponentなど）に応じて適切な処理を実行する。そして子Fiberノードを生成し、接続する。propsが以前と同じなら、メモ化によってスキップできる（bailout）。</p>
<p>**completeWork(fiber)**では、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><strong>変更前フェーズ</strong>：<code>commitBeforeMutationEffects()</code>
<ul>
<li>DOMが変更される前に、現在のDOMの状態を読み取る。<code>getSnapshotBeforeUpdate</code>のライフサイクルはここで実行される。この時点では<code>current</code>ツリーがまだ画面の状態を表しているため、DOMのスクロール位置やサイズなどの情報を安全に取得できる。</li>
</ul>
</li>
<li><strong>変更フェーズ</strong>：<code>commitMutationEffects()</code>
<ul>
<li><strong>実際のDOM操作</strong>が行われる段階だ。新しいノードの挿入、既存ノードの変更、不要なノードの削除がすべてここで発生する。<code>componentWillUnmount</code>もこの時点で実行される。まだ<code>current</code>が以前のツリーを指しているため、以前の状態を読めるからだ。</li>
</ul>
</li>
<li><strong>ツリーの切り替え</strong>：<code>root.current = finishedWork</code>
<ul>
<li>ダブルバッファリングの核心だ。workInProgressツリーがcurrentツリーへ昇格する。この切り替えが変更フェーズ後、レイアウトフェーズ前に行われる理由は重要だ。<code>componentWillUnmount</code>は<strong>以前のツリー</strong>を読む必要があるため変更フェーズで実行しなければならず、<code>componentDidMount</code>/<code>componentDidUpdate</code>は<strong>新しいツリー</strong>を読む必要があるためレイアウトフェーズで実行しなければならない。</li>
</ul>
</li>
<li><strong>レイアウトフェーズ</strong>：<code>commitLayoutEffects()</code>
<ul>
<li>DOMの変更が完了した後、新しいDOMの状態を基にする処理が実行される。
<ul>
<li><code>componentDidMount</code>、<code>componentDidUpdate</code>を実行</li>
<li><code>useLayoutEffect</code>のコールバックを実行</li>
<li>この時点では<code>current</code>がすでに新しいツリーを指しているため、DOMを読むと更新後の値を取得できる</li>
</ul>
</li>
</ul>
</li>
<li><strong>パッシブエフェクト</strong>（非同期）
<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>
<h2 id="出典"><a class="anchor" href="#出典">出典</a></h2>
<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[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>
<hr>
<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>
<hr>
<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より約25倍、ESLintより約15倍高速だ。この数値が実際にどの程度なのかは、後ほど直接比較する。</p>
<p><img src="/content/241201/1.png" alt="1.png" width="2250" height="986" loading="eager" fetchpriority="high" decoding="async"></p>
<p><strong>既存ツールとの互換性がある。</strong> Prettierと約97%のフォーマット互換性を提供し、ESLintの主要ルールをビルトインで備えている。<code>eslint-plugin-react-hooks</code>や<code>eslint-plugin-jsx-a11y</code>など、よく使われるプラグインのルールも内蔵されているため、移行の負担は比較的小さい。</p>
<hr>
<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>
<hr>
<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><img src="/content/241201/biome1.png" alt="biome1.png" width="798" height="330" loading="lazy" fetchpriority="low" decoding="async">  <img src="/content/241201/lint1.png" alt="lint1.png" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biomeは<strong>506ms</strong>、ESLint + Prettierは<strong>630ms</strong>で、約20%速い実行時間を示した。</p>
<hr>
<h3 id="viteプロジェクトのビルド時間"><a class="anchor" href="#viteプロジェクトのビルド時間">Viteプロジェクトのビルド時間</a></h3>
<p><img src="/content/241201/biome2.png" alt="biome2.png" width="920" height="222" loading="lazy" fetchpriority="low" decoding="async"> <img src="/content/241201/lint2.png" alt="lint2.png" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biomeは<strong>117.13s</strong>、ESLint + Prettierは<strong>131.48s</strong>で、約10%速いビルド時間を示した。</p>
<hr>
<h3 id="lint処理"><a class="anchor" href="#lint処理">Lint処理</a></h3>
<p><img src="/content/241201/biome3.png" alt="biome3.png" width="894" height="174" loading="lazy" fetchpriority="low" decoding="async"> <img src="/content/241201/lint3.png" alt="lint3.png" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></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>
<hr>
<p><img src="/content/241201/3.jpeg" alt="3.jpeg" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>うーん……（ここまで来ると、使わない理由を探すほうが難しい。）</p>
<hr>
<h2 id="なぜこれほど速いのか"><a class="anchor" href="#なぜこれほど速いのか">なぜこれほど速いのか？</a></h2>
<p>「Rustで作られているから速い」というのは正しいが、それだけでは説明が足りない。Biomeの性能優位を生み出している具体的な技術要因を見ていこう。</p>
<hr>
<h3 id="rustの低レベル性能"><a class="anchor" href="#rustの低レベル性能">Rustの低レベル性能</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="5.webp" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="6.webp" 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>
<hr>
<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>
<hr>
<h3 id="ネイティブ並列処理"><a class="anchor" href="#ネイティブ並列処理">ネイティブ並列処理</a></h3>
<p><img src="/content/241201/7.png" alt="7.png" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Rustの並行処理モデルを活用し、Biomeは複数のスレッドでファイルを並列処理する。作業を小さな単位に分割し、Work-stealingスケジューラーによってスレッド間の負荷を効率よく分散する。Rustの所有権システムはコンパイル時にデータ競合（Data Race）を根本から防ぐため、ランタイムでの同期コストも最小限に抑えられる。</p>
<p>Node.jsは基本的にイベントループベースのシングルスレッドモデルだ。Worker Threadsを使えば並列処理は可能だが、スレッド生成とメッセージパッシングによる追加のオーバーヘッドが発生する。BiomeはOSレベルのネイティブスレッドを直接活用するため、こうしたオーバーヘッドなしにCPUコアを最大限利用できる。</p>
<hr>
<h3 id="メモリ効率に優れたast処理"><a class="anchor" href="#メモリ効率に優れたast処理">メモリ効率に優れたAST処理</a></h3>
<p><img src="/content/241201/4.svg" alt="4.svg" 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>
<hr>
<h2 id="ではbiomeを導入すべきか"><a class="anchor" href="#ではbiomeを導入すべきか">では、Biomeを導入すべきか？</a></h2>
<p>Biomeの性能と利便性は明らかに魅力的だ。しかし、すべてのプロジェクトに無条件で導入することが正解だとは思わない。現実的な検討事項をいくつか確認しよう。</p>
<hr>
<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>
<hr>
<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><img src="/content/241201/8.png" alt="8.png" width="2722" height="1384" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>npm trendsを見ると、Biomeの週間ダウンロード数は約690万で、ESLintの約1億2,000万、Prettierの約8,200万とはまだ大きな差がある。しかし、Biomeの成長速度は注目に値する。わずか1年あまりで週間ダウンロード数が3〜4倍以上に増え、特に新規プロジェクトでの採用率が目に見えて高まっている。</p>
<hr>
<h2 id="おわりに"><a class="anchor" href="#おわりに">おわりに</a></h2>
<p>BiomeがESLintとPrettierを完全に置き換えられるかという問いに対する筆者の答えは、**「まだだが、十分に有力な選択肢」**である。</p>
<p>性能は圧倒的で、設定は簡潔、開発速度も速い。ただし、プラグインエコシステムの未成熟さと一部言語サポートの制約は、プロジェクトによっては障害になり得る。プロジェクトの技術スタックとチームの要件を綿密に検討した上で、導入の可否を判断するのが望ましい。</p>
<p>一つ確かなのは、フロントエンドツールのエコシステムが「より速く、より簡潔で、より統合された」方向へ進んでいることだ。Biomeがその流れの先頭に立っていることは否定できない。今後の成長が期待されるツールであることは間違いない。</p>
<h2 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h2>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>자바스크립트</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>
<hr>
<h2 id="reactでは状態がどのように流れるのか"><a class="anchor" href="#reactでは状態がどのように流れるのか">Reactでは状態がどのように流れるのか</a></h2>
<p>一般的なReactアプリケーションでは、状態は下図のように動作する。</p>
<p><img src="/content/240818/3.png" alt="3.png" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></p>
<p>コンポーネント内部の状態は、Reactが提供する状態管理フック（<code>useState</code>、<code>useReducer</code>）を使って管理する。そして、子コンポーネントへの状態の受け渡しはpropsを通じて行われる。ここまでは単純な話だ。</p>
<p>問題は、離れたコンポーネント間で状態を共有しなければならないときに起きる。このときReactが提供する公式の解決策がContext APIだが、これは必ずProviderコンポーネントで配下のツリーをラップしなければならない。</p>
<hr>
<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>
<hr>
<h2 id="zustandはreactの外側に存在する"><a class="anchor" href="#zustandはreactの外側に存在する">ZustandはReactの外側に存在する</a></h2>
<p><img src="/content/240818/4.png" alt="4.png" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>ZustandはFluxパターンに基づいて動作する。クロージャ内部の<code>state</code>がStore、ユーザー定義関数がAction、<code>set</code>関数がDispatcher、ReactコンポーネントがViewの役割を担う。ここに決定的な違いがある。</p>
<p><strong>ZustandのストアはReactコンポーネントツリーの外側、JavaScriptモジュールのスコープ内に存在する。</strong></p>
<p>コンポーネントツリーの外側という表現は、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>
<hr>
<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>
<hr>
<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>
<hr>
<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>
<ul>
<li>
<p><strong>クロージャによる状態のカプセル化</strong></p>
<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>
</li>
<li>
<p><strong><code>Object.is</code>を利用した変更検出</strong></p>
<ul>
<li>
<p><code>setState</code>は新しい状態を計算した後、<code>Object.is(nextState, state)</code>で既存の状態と比較する。参照が同一なら何も起こらない。これが不要な再レンダリングを防ぐ最初の防衛線だ。</p>
</li>
<li>
<p>ただし、この<code>Object.is</code>による比較は**厳密な参照同一性（strict reference equality）**の検査なので、利用側が注意すべき点がある。プリミティブ値（数値や文字列など）を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>
</li>
<li>
<p><strong>Pub/Subパターンのリスナーシステム</strong></p>
<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>
</li>
<li>
<p><strong>初期状態の生成</strong></p>
<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>の**第3引数（サーバースナップショット）**として渡される。</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>
</li>
</ul>
<hr>
<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>
<hr>
<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>
<hr>
<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>
<hr>
<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を通じて必要な部分だけを抽出する**トップダウン（top-down）<strong>のアプローチだ。開発者が<code>useStore((state) => state.count)</code>のようにselectorを直接記述する必要があり、参照同一性（referential equality）を維持するため、場合によってはメモ化が必要になる。一方Jotaiは、atom間の</strong>依存関係グラフ（dependency graph）<strong>を自動的に構築し、特定のatomが変わると、そのatomに依存するコンポーネントだけを正確に再レンダリングする</strong>ボトムアップ（bottom-up）**の伝播を行う。スプレッドシートやキャンバスエディターのように、数十の状態が互いに絡み合う場合、この自動依存関係追跡が大きな力を発揮する。</p>
<p>Providerの観点では、Jotaiは興味深い中間地点に位置する。デフォルトではグローバルストアを使ってProviderなしで動作するが、必要なら<code>&#x3C;Provider></code>でラップして分離されたストアスコープを作成できる。Jotaiの公式ドキュメントの表現を借りれば、Jotaiは**「context first, module second」<strong>で、Zustandは</strong>「module first, context second」**なのだ。</p>
<hr>
<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>を組み合わせて、**スコープ付きストア（Scoped Store）**パターンを実装できる。</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>
<hr>
<h2 id="providerlessの影"><a class="anchor" href="#providerlessの影">ProviderLessの影</a></h2>
<p>もちろん、Providerがないことは利点ばかりではない。筆者が考える注意すべき点を整理してみよう。</p>
<hr>
<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>
<hr>
<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>
<hr>
<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>
<hr>
<p><img src="/content/240818/7.jpeg" alt="7.jpeg" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></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><strong>と</strong><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>
<hr>
<h3 id="参考資料"><a class="anchor" href="#参考資料">参考資料</a></h3>
<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[今回は、ソフトウェアの圧縮アルゴリズムについて話してみたい。 社内プロジェクトのデプロイプロセスを改善する仕事を担当した。大容量のビルド成果物をS3へアップロードする構成だったため、ビルドフォルダーのサイズがアップロード時間とストレージコストに直結することを実感した。そこから自然に「どうすれば効率よく圧縮してアップロードできるだろうか」という疑問が生まれた。 いざ圧縮について調べると、zip、gz...]]></description>
            <content:encoded><![CDATA[<p>今回は、ソフトウェアの圧縮アルゴリズムについて話してみたい。</p>
<p>社内プロジェクトのデプロイプロセスを改善する仕事を担当した。大容量のビルド成果物をS3へアップロードする構成だったため、ビルドフォルダーのサイズがアップロード時間とストレージコストに直結することを実感した。そこから自然に「どうすれば効率よく圧縮してアップロードできるだろうか」という疑問が生まれた。</p>
<p>いざ圧縮について調べると、zip、gzip、zstd、bzip2、xzなど、想像以上に多くの種類があった。名前はよく似ているのに、何が違い、どのような場面で何を選べばよいのかを一度に理解できる資料はなかなか見つからなかった。（圧縮ならどれも同じようなものだと思っていたが、世界は広く、圧縮方法も多かった。）</p>
<p>そこでこの機会に、各圧縮形式の原理と特徴を比較し、筆者がなぜ特定の方式を選んだのかを整理してみる。</p>
<hr>
<h2 id="可逆圧縮とは何か"><a class="anchor" href="#可逆圧縮とは何か">可逆圧縮とは何か</a></h2>
<p>可逆圧縮（Lossless Compression）は、元のデータを完全に復元できる圧縮方式だ。画像や音声で使われる非可逆圧縮（Lossy Compression）とは異なり、展開後のデータは元データと1ビットも違わない。ソースコードやビルド成果物のようにデータ整合性が重要な場合は、必ず可逆圧縮を使う必要がある。</p>
<p>可逆圧縮の中心的な考え方は、<strong>データに存在する統計的な冗長性を利用すること</strong>だ。繰り返されるパターンを短い表現に置き換えれば、全体のサイズを減らせる。</p>
<p>その中でも、<strong>辞書ベース（Dictionary-Based）方式</strong>は可逆圧縮で最も広く使われるアルゴリズム群の一つだ。ここでいう辞書とは言葉の意味を調べる辞書ではなく、以前に現れたデータ片を短いコードへ対応付ける参照テーブルを指す。Abraham LempelとJacob Zivが1977年の論文 <em>"A Universal Algorithm for Sequential Data Compression"</em>（IEEE Transactions on Information Theory）で提案した<strong>LZ77</strong>と、翌1978年に発表した<strong>LZ78</strong>がこの系譜の始祖だ。二人の姓から一文字ずつ取って「LZ」と呼ばれる。以後に登場した辞書ベース圧縮アルゴリズムのほぼすべて、DEFLATE、LZMA、LZ4、Zstdなどは、この二つにルーツを持つ。（圧縮アルゴリズムの系図の大半が、この二人へ収束すると言っても大げさではない。）</p>
<p>簡単な例で考えてみよう。テキストに「Linux」という単語が100回現れるなら、最初の登場時に辞書へ登録し、それ以降は「辞書の1番目」という短い参照、つまりポインターに置き換える。「Linux」は5バイトだが、ポインターはそれより少ないバイトで表せるため、全体のサイズが小さくなる。</p>
<p>では、LZ77とLZ78は具体的に何が違うのだろうか。</p>
<hr>
<h3 id="lz77スライディングウィンドウ方式"><a class="anchor" href="#lz77スライディングウィンドウ方式">LZ77：スライディングウィンドウ方式</a></h3>
<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>アルゴリズムは、ルックアヘッドバッファの先頭部分が検索バッファのどこかに現れたことがあるかを探す。同じパターンが見つかると、その一致を**（距離、長さ、次の文字）**というタプルで符号化する。距離は一致の開始位置まで何文字戻るかを、長さは一致が何文字続くかを表す。</p>
<p>たとえば、<code>"banana_banana"</code>という文字列をLZ77で圧縮するとしよう。二つ目の<code>"banana"</code>に到達した時点で、アルゴリズムは実質的に <em>「7文字戻って6文字をそのままコピーせよ」</em> と指示する。6バイトの文字列を、わずか二つの数値で表現できる。</p>
<p>この方式の要点は、<strong>辞書を別途保存したり送信したりする必要がないこと</strong>だ。デコーダーは展開を進めながら検索バッファを自然に再構築するため、辞書はデータそのものへ暗黙的に組み込まれている。ただし、その代わりに展開は常にデータの先頭から順番に進めなければならない。原理上、途中の任意位置から展開を始めることはできない。</p>
<p>ウィンドウサイズと圧縮率には直接的なトレードオフがある。ウィンドウが大きければ遠く離れたパターンも参照できるため圧縮率は高くなりやすいが、一致検索に必要な計算量とメモリ使用量も増える。</p>
<hr>
<h3 id="lz78明示的な辞書方式"><a class="anchor" href="#lz78明示的な辞書方式">LZ78：明示的な辞書方式</a></h3>
<p>LZ78はLZ77と異なり、圧縮を進めながら<strong>明示的な辞書を構築する</strong>。スライディングウィンドウはない。代わりに、以前見たパターンをインデックス付きの辞書項目として保存し、同じパターンが再び現れたらインデックスへ置き換える。</p>
<p>LZ78が出力する単位は、**（辞書インデックス、次の文字）**というタグだ。エンコーダーは辞書内で最長一致する項目を探し、そのインデックスと一致を崩した次の文字を組み合わせて出力する。そして <em>「今一致した項目＋新しい文字」</em> を新たな辞書項目として追加する。こうして辞書が段階的に成長していく。</p>
<p>LZ78で最も有名な派生が<strong>LZW</strong>（Lempel-Ziv-Welch）だ。Terry WelchがLZ78を改良して1984年に発表し、GIF画像形式とUnixの<code>compress</code>ユーティリティ（<code>.Z</code>拡張子）で使われた。（かつてLZWは特許紛争の中心にもなり、この出来事はPNG形式誕生のきっかけの一つとなった。）</p>
<hr>
<h3 id="現代の圧縮アルゴリズムはどちらの子孫か"><a class="anchor" href="#現代の圧縮アルゴリズムはどちらの子孫か">現代の圧縮アルゴリズムはどちらの子孫か</a></h3>
<p>興味深いことに、現在使われている主流の圧縮アルゴリズムは、ほぼすべて<strong>LZ77系の子孫</strong>だ。</p>
<p>StorerとSzymanskiが1982年に発表した<strong>LZSS</strong>はLZ77の改良版で、1ビットのフラグを導入し、各出力が「リテラル、つまり元の文字」なのか「長さと距離の組」なのかを区別した。一致が短すぎて参照のほうが損になる場合は、そのまま元の文字を出力できる。</p>
<p>1993年、Phil KatzはLZSSに<strong>ハフマン符号化</strong>、つまり頻度の高いシンボルへ短いビット列を割り当てるエントロピー符号を組み合わせて<strong>DEFLATE</strong>を作った。ZIP、GZIP、PNGはいずれもDEFLATEを使う。つまり、日常的に扱う<code>.zip</code>、<code>.gz</code>、<code>.png</code>ファイルはすべてLZ77の直系子孫だ。</p>
<p>その後に登場した<strong>LZMA</strong>（7-Zip、XZ）、<strong>LZ4</strong>、<strong>Zstd</strong>も、LZ77のスライディングウィンドウを出発点とし、一致検索のデータ構造やエントロピー符号化を進化させてきた。一方、LZ78系はLZWを最後に、主流の舞台からほぼ退いたと見てよい。</p>
<p>理論上、二つのアルゴリズムは <em>「データ全体を展開する場合」</em> に同等の能力を持つことが証明されている。それでもLZ77が生き残った理由は、<strong>辞書をデータへ組み込む設計が、実装と拡張の両面でより柔軟だったから</strong>だ。スライディングウィンドウの大きさ、一致検索アルゴリズム、後段のエントロピーコーダーなどを自由に組み合わせられ、時代の要求に合わせて進化する余地があった。</p>
<p>圧縮性能を評価するときは、<strong>圧縮率</strong>、つまりどれだけ小さくなるかと、<strong>圧縮速度</strong>、つまりどれだけ早く終わるかという二つの軸を考える。一般に、高い圧縮率を求めるほど多くの計算が必要になり、圧縮時間も長くなる。この二つの折り合いをどこにつけるかが、現実的な圧縮戦略の要点だ。</p>
<p>それでは、さまざまな圧縮形式を順番に比較してみよう。</p>
<hr>
<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>
<hr>
<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>
<hr>
<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>マッチファインダー（Match Finder）<strong>が入力から繰り返しパターンを探す。次に、その一致情報、リテラル、一致長、オフセットを</strong>シーケンス</strong>として符号化する。最後にシーケンスを<strong>エントロピー符号化</strong>で圧縮する。ここではGZIPのハフマン符号化だけでなく、**FSE（Finite State Entropy）**という方式を使う。FSEはANS（Asymmetric Numeral Systems）理論に基づくエントロピーコーダーで、ハフマン符号化と算術符号化（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>
<hr>
<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>
<hr>
<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>
<hr>
<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の**UStar（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>
<hr>
<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>
<hr>
<h2 id="targzがzipより高い圧縮率を得られる理由"><a class="anchor" href="#targzがzipより高い圧縮率を得られる理由">tar.gzがZIPより高い圧縮率を得られる理由</a></h2>
<p>これは**ソリッドアーカイブ（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>
<hr>
<p><strong>2026年追記</strong></p>
<h2 id="2024年にはtargzを選んだ今ならどうするか"><a class="anchor" href="#2024年にはtargzを選んだ今ならどうするか">2024年にはtar.gzを選んだ。今ならどうするか</a></h2>
<p>当時tar.gzを選んだ理由は、互換性と安定性だった。S3へアップロードした後にさまざまな環境で展開する必要があったため、ほぼどこでも使えるtar.gzが安全な選択だった。</p>
<p>しかし同じ状況に今もう一度直面したなら、**tar.zst（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>
<hr>
<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>
<hr>
<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>
    </channel>
</rss>