<?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/</link>
        <description>프론트엔드 개발자 이지훈(후니)의 기술 블로그. React, TypeScript, Next.js 로 만들면서 겪은 설계 판단과 문제 해결 과정, 성능과 관측 실측, 학습 노트를 남깁니다.</description>
        <lastBuildDate>Thu, 15 Oct 2026 19:19:16 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>ko</language>
        <copyright>All rights reserved 2026, 이지훈</copyright>
        <item>
            <title><![CDATA[React 앱의 layer]]></title>
            <link>https://hooninedev.com/261001</link>
            <guid isPermaLink="false">https://hooninedev.com/261001</guid>
            <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 React 앱의 코드를 화면, 업무 규칙, 데이터 접근으로 나누는 방법에 대한 이야기를 해보려고 한다. component 하나에 fetch와 계산과 render가 같이 들어 있어서 고칠 때마다 전부를 읽어야 하는 개발자를 위한 글이다. 끝까지 읽으면 hook, pure component, domain object, Strategy, net...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 React 앱의 코드를 화면, 업무 규칙, 데이터 접근으로 나누는 방법에 대한 이야기를 해보려고 한다. component 하나에 fetch와 계산과 render가 같이 들어 있어서 고칠 때마다 전부를 읽어야 하는 개발자를 위한 글이다. 끝까지 읽으면 hook, pure component, domain object, Strategy, network client를 어떤 신호를 보고 어떤 순서로 꺼내는지와, 그 구조를 그대로 옮길 때 어디서 깨지는지를 얻을 수 있다.</p>
<p>뼈대는 Thoughtworks의 Juntao Qiu가 2023년 2월 martinfowler.com에 연재한 <a href="https://martinfowler.com/articles/modularizing-react-apps.html" target="_blank" rel="noopener noreferrer">Modularizing React Applications with Established UI Patterns</a>다. 원문을 그대로 옮기지 않고 필자의 말로 다시 풀었고, 본문의 그림은 모두 이 원문에서 가져왔다. 그 위에 저자의 <a href="https://github.com/abruzzi/payment-round-up-refactoring" target="_blank" rel="noopener noreferrer">예제 저장소</a>를 받아 타입 검사와 테스트를 돌린 결과와, 같은 문제를 다룬 다른 자료를 덧붙였다.</p>
<h2 id="react는-view-library"><a class="anchor" href="#react는-view-library">React는 view library</a></h2>
<p>React를 처음 배울 때 가장 매력적인 생각은 UI가 데이터를 DOM으로 바꾸는 순수 함수라는 것이다. 어느 정도는 맞다. 그런데 서버에 요청을 보내거나 페이지를 이동하는 순간 component는 더 이상 순수하지 않다. 여기에 전역 state와 지역 state가 얽히면 코드는 금방 복잡해진다.</p>
<p>저자의 답은 관점을 바꾸는 것이다. "React 애플리케이션"이라는 별종 소프트웨어는 없다. React는 UI를 그리는 library이고, 계산이나 업무 규칙을 어디에 둘지는 처음부터 관심사가 아니다. 그 위에 올라간 것은 평범한 JavaScript 앱이다. 그렇다면 데스크톱 GUI 시절부터 써 온 설계, 특히 코드를 presentation, domain, data 세 layer로 나누는 방식을 그대로 쓸 수 있다.</p>
<p>실제 프론트엔드 앱에는 view 말고도 router, local storage, 여러 수준의 캐시, 네트워크 요청, 외부 서비스 연동과 로그인, 보안, 로깅, 성능 조정이 있다. 이것을 전부 component와 hook에 밀어 넣으면 한 파일 안에서 주문 상태를 요청하는 줄 다음에 문자열 앞 공백을 자르는 줄이 오고, 그다음에 다른 화면으로 이동하는 줄이 온다. 읽는 사람은 추상화 수준을 계속 바꿔 가며 앞뒤로 오가야 한다.</p>
<p>나누는 이유는 두 가지로 정리된다. 화면은 업무 규칙보다 자주 바뀐다. 그리고 둘을 떼어 두면 한 번에 한 가지만 생각하면 된다. 두 번째 이유는 Martin Fowler가 <a href="https://martinfowler.com/bliki/PresentationDomainDataLayering.html" target="_blank" rel="noopener noreferrer">PresentationDomainDataLayering</a>에서 이 구분의 가장 큰 이점으로 든 것이다. 세 주제를 비교적 따로 생각할 수 있어서 주의를 기울일 범위가 줄어든다.</p>
<h2 id="앱이-자라는-다섯-단계"><a class="anchor" href="#앱이-자라는-다섯-단계">앱이 자라는 다섯 단계</a></h2>
<p>저자는 리팩토링에 들어가기 전에 앱이 자라면서 구조가 어떻게 바뀌는지를 다섯 장의 그림으로 보여 준다. 중요한 것은 다음 단계로 넘어가는 계기다. 정해진 시점이 아니라 그 단계에서 생기는 불편이 다음 단계를 부른다. 그림의 색은 다섯 장 모두 같은 뜻이다. 연두는 state를 가진 container component, 진한 초록은 그리기만 하는 presentational component, 보라는 hook, 파랑은 domain object, 주황은 네트워크 같은 infrastructure다.</p>
<p>처음에는 component 하나가 네트워크 요청, state, 계산, render를 모두 갖는다. 작은 앱이나 일회성 프로젝트에서는 이것으로 충분하고, 코드가 HTML과 비슷해서 오히려 읽기 쉽다. 목록을 돌며 항목을 만드는 코드와 외부 component를 설정하는 코드가 섞여 무엇이 일어나는지 읽는 데 시간이 들기 시작하면 다음 단계로 간다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/1.720w.webp 720w, /content/261001/1.1440w.webp 1440w, /content/261001/1.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/1.png" alt="component 하나가 네트워크 요청, state 관리, domain logic, render를 모두 갖고 API를 직접 호출하는 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="661" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>화면이 커지면 결과 HTML의 모양을 따라 component를 나눈다. 그리기만 하는 component가 떨어져 나오지만, 맨 위 component에는 네트워크 요청, 응답을 화면용 모양으로 바꾸는 코드, 서버로 보낼 데이터를 모으는 코드가 그대로 남는다. 셋 다 UI가 아닌데 component 안에 있다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/2.720w.webp 720w, /content/261001/2.1440w.webp 1440w, /content/261001/2.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/2.png" alt="그리기만 하는 component가 분리됐지만 위쪽 component에 네트워크 요청과 domain logic이 남아 있는 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="984" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>그다음 state와 그 변화를 custom hook으로 뺀다. 그러자 hook 안에 side effect도 state도 아닌 순수 계산이 남는다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/3.720w.webp 720w, /content/261001/3.1440w.webp 1440w, /content/261001/3.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/3.png" alt="네트워크 요청과 domain logic이 hook으로 옮겨 갔지만 hook 안에서 둘이 섞여 있는 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="872" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>그 계산을 React와 무관한 객체로 빼면 domain object가 생긴다. 데이터 형식 변환, null 검사, 대체값이 여기로 온다. 객체가 늘어나면 상속이나 다형성이 필요해지기 시작한다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/4.720w.webp 720w, /content/261001/4.1440w.webp 1440w, /content/261001/4.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/4.png" alt="hook에는 state만 남고 domain logic은 Domain 객체로, 네트워크 요청은 Fetcher로 갈라진 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="790" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>마지막으로 UI에도 속하지 않고 데이터가 서버에서 오는지 local storage나 캐시에서 오는지도 상관하지 않는 객체들이 보이면, 그것을 따로 model layer로 묶는다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/5.720w.webp 720w, /content/261001/5.1440w.webp 1440w, /content/261001/5.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/5.png" alt="가운데 띠가 model layer이고 component와 hook은 위에, Fetcher와 Adaptor 같은 infrastructure는 아래에 놓인 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="917" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>그림만 보면 추상적이다. 이 다섯 단계를 실제 코드로 한 번 지나가 보자. 저자가 고른 예제는 온라인 주문의 결제 화면이다.</p>
<h2 id="결제-화면-하나에서-출발"><a class="anchor" href="#결제-화면-하나에서-출발">결제 화면 하나에서 출발</a></h2>
<p>결제 수단은 서버 설정에서 오고 나라마다 다르다. 서버가 하나라도 돌려주면 그 끝에 현금 결제를 붙여 기본으로 선택해 두고, 하나도 없으면 아무것도 보여 주지 않는다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/6.720w.webp 720w, /content/261001/6.1440w.webp 1440w, /content/261001/6.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/6.png" alt="주문 상세 아래 결제 영역에 apple, google, 현금 결제 라디오가 있고 현금 결제가 선택된 화면" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>처음 코드는 튜토리얼에서 흔히 보는 모양이다. <code>useEffect</code> 안에서 fetch하고, 응답을 화면용 모양으로 바꾸고, 현금 결제를 붙이고, 라디오 목록을 그린다. 아래는 필자가 줄여 옮긴 것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(url)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> extended</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ provider: m.name, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`Pay with ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">name</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        extended.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ provider: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(extended);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {paymentMethods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">method</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"radio"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultChecked</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          {method.label}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      ))}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>이 크기에서는 문제가 아니다. 그러나 이 component를 고치려면 네 가지를 함께 이해해야 한다. 네트워크 요청을 어떻게 시작하는지, 서버 데이터를 화면이 아는 모양으로 어떻게 바꾸는지, 결제 수단 하나를 어떻게 그리는지, 결제 영역 전체를 어떻게 그리는지다. 코드가 커지면 읽는 사람은 이 넷 사이를 계속 오가야 한다.</p>
<h2 id="hook과-pure-component"><a class="anchor" href="#hook과-pure-component">hook과 pure component</a></h2>
<p>첫 번째로 꺼내는 것은 state와 fetch다. 판단 신호는 단순하다. <strong>render와 상관없는 코드가 component 본문의 절반을 넘으면</strong> hook으로 뺀다. <code>usePaymentMethods</code>가 fetch와 변환을 맡고, component는 hook이 준 배열을 받아 그리기만 한다.</p>
<p>두 번째는 목록을 그리는 JSX다. <strong>props만 받아서 그릴 수 있는 블록이 보이면</strong> component로 뗀다. <code>PaymentMethods</code>는 결제 수단 배열 하나만 받는 pure component라서 테스트와 재사용이 쉽다. 나중에 선택을 알리는 <code>onSelect</code> callback이 붙어도 바깥 state를 건드리지 않으니 여전히 pure하다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위의 fetch와 변환 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">PaymentMethods</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> paymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{paymentMethods} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>저자는 두 작업 모두 리팩토링 카탈로그의 <a href="https://refactoring.com/catalog/extractFunction.html" target="_blank" rel="noopener noreferrer">Extract Function</a>이라고 부른다. React에서는 component도 함수이기 때문이다. 새 개념을 들여온 것이 아니라 함수 하나를 둘로 나눴을 뿐이라는 점이 중요하다.</p>
<p>이 정도면 끝난 것처럼 보이지만 아직 두 군데가 남아 있다. view 안에는 "현금이면 기본 선택"이라는 판정이 있고, hook 안에는 응답을 화면용 모양으로 바꾸는 익명 함수가 있다. 저자는 이렇게 업무 규칙이 view와 hook으로 새어 나온 것을 logic leak이라고 부른다.</p>
<h2 id="domain-object로-모은-규칙"><a class="anchor" href="#domain-object로-모은-규칙">domain object로 모은 규칙</a></h2>
<p>세 번째 신호는 <strong>같은 데이터에 대한 판정이 view와 hook에 흩어져 있을 때</strong>다. 새어 나온 판정과 변환을 <code>PaymentMethod</code> class 하나로 모은다. 이 블로그의 <a href="/260418" target="_blank" rel="noopener noreferrer">도메인 모델</a>에서 다룬 것처럼, 규칙을 데이터 옆에 두는 것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> remote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.remote.name; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "Pay in cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `Pay with ${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">provider</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDefaultMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m)), </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })];</span></span></code></pre></figure>
<p>view의 라디오는 이제 <code>defaultChecked={method.isDefaultMethod}</code>로 객체에게 묻는다. 현금 결제 기본값도 같은 class의 인스턴스 하나로 표현된다. 서버가 준 것과 앱이 붙인 것이 같은 모양이 되니 view는 둘을 구분할 필요가 없다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/7.720w.webp 720w, /content/261001/7.1440w.webp 1440w, /content/261001/7.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/7.png" alt="Payment가 usePaymentMethods를 부르고, hook이 PaymentMethod를 만들고, PaymentMethods는 그리기만 하는 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="662" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>저자는 이 구조의 이점으로 테스트가 쉬워진다는 것을 든다. 원문은 그 말에서 멈추지만, 얼마나 쉬워지는지는 코드로 보는 편이 빠르다. 아래는 필자가 쓴 테스트다. React도, 렌더러도, fetch mock도 없다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 준 결제 수단 끝에 현금 결제를 기본값으로 붙인다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([{ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.label)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.isDefaultMethod).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.provider)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 아무것도 주지 않으면 현금 결제도 보여 주지 않는다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([])).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>같은 규칙을 처음 코드에서 확인하려면 component를 render하고, fetch를 가로채고, 라디오가 그려질 때까지 기다려야 했다. 규칙이 객체로 나온 순간 테스트는 함수 호출 한 줄이 된다.</p>
<p>또 하나의 이점은 새 요구가 왔을 때 갈 곳이 정해져 있다는 것이다. 이 시점의 파일 구조는 이렇다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>src</span></span>
<span data-line=""><span>├── components</span></span>
<span data-line=""><span>│   ├── Payment.tsx</span></span>
<span data-line=""><span>│   └── PaymentMethods.tsx</span></span>
<span data-line=""><span>├── hooks</span></span>
<span data-line=""><span>│   └── usePaymentMethods.ts</span></span>
<span data-line=""><span>└── models</span></span>
<span data-line=""><span>    └── PaymentMethod.ts</span></span></code></pre></figure>
<p>구조가 정말 버티는지는 새 요구가 와야 안다. 저자는 여기서 기능을 하나 더한다.</p>
<h2 id="기부-기능과-state-machine으로서의-hook"><a class="anchor" href="#기부-기능과-state-machine으로서의-hook">기부 기능과 state machine으로서의 hook</a></h2>
<p>새 요구는 주문 금액을 올림해 그 차액을 자선단체에 기부하게 하는 것이다. 19.80달러 주문이면 0.20달러 기부를 묻고, 동의하면 버튼에 20달러를 보인다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/8.720w.webp 720w, /content/261001/8.1440w.webp 1440w, /content/261001/8.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/8.png" alt="결제 수단 아래에 0.2달러 기부 체크박스가 추가된 결제 화면" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>저자는 일부러 처음에는 동의 여부 state와 계산을 결제 component 안에 넣는다. 체크박스 마크업, 문구 분기, 합계 계산이 한꺼번에 들어오면서 component가 다시 무거워진다. 정리하는 순서는 앞에서 본 그대로다. state와 계산은 <code>useRoundUp</code> hook으로, 문구 조립은 helper 함수로, 체크박스 마크업은 <code>DonationCheckbox</code> component로 간다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">agreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total: agreeToDonate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    tip: </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [amount, agreeToDonate]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateAgreeToDonate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">v);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { total, tip, agreeToDonate, updateAgreeToDonate };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p><code>toPrecision(10)</code>은 원문이 이유를 설명하지 않는 부분이다. 필자가 Node 24에서 돌려 보니 <code>20 - 19.8</code>은 <code>0.1999999999999993</code>이 나왔다. 부동소수 오차를 걷어 내는 장치다.</p>
<p>저자는 이런 hook을 "view 뒤의 state machine"으로 본다. UI에서 이벤트가 오면 새 state를 만들고, 새 state가 다시 render를 일으킨다. hook은 원래 여러 component가 logic을 나눠 쓰려고 만든 것이지만, 쓰는 곳이 하나여도 component를 render에 집중하게 해 주므로 뺄 가치가 있다는 것이 저자의 입장이다. 여기에 하나를 보태면, React 공식 문서의 <a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">Reusing Logic with Custom Hooks</a>가 짚듯 custom hook이 나누는 것은 state를 다루는 logic이지 state 자체가 아니다. 이 state machine은 hook을 부르는 component마다 하나씩 따로 생긴다.</p>
<p>정리가 끝나면 결제 component는 hook 둘을 부르고 하위 component 둘과 버튼 하나를 늘어놓는 모양이 된다. 기능이 하나 늘었는데 결제 component의 역할은 그대로다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/9.720w.webp 720w, /content/261001/9.1440w.webp 1440w, /content/261001/9.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/9.png" alt="Payment 아래에 useRoundUp과 usePaymentMethods 두 hook, PaymentMethods와 DonationCheckbox 두 하위 component가 놓인 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="775" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h2 id="나라별-규칙과-shotgun-surgery"><a class="anchor" href="#나라별-규칙과-shotgun-surgery">나라별 규칙과 shotgun surgery</a></h2>
<p>다음 요구는 나라마다 올림 단위를 다르게 하라는 것이다. 일본은 100엔 단위, 덴마크는 10크로네 단위다. 저자는 "쉬운 고침 같다"며 나라 코드를 prop으로 받아 분기를 넣는 길을 일부러 먼저 보여 준다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useRoundUp 안</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: agreeToDonate</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 체크박스 문구 helper 안</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 버튼 JSX 안</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}{total}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>같은 <code>countryCode === "JP"</code> 판정이 hook, helper, 버튼 세 자리에 하나씩 들어갔다. 덴마크를 더하면 세 자리를 모두 다시 고쳐야 하고, 삼항 연산자가 감당이 안 되면 나라 코드로 통화 기호를 찾는 표가 생긴다. 그 표도 결국 여러 곳에서 따로 참조된다. 변경 하나에 여러 모듈을 동시에 고쳐야 하는 이 냄새를 shotgun surgery라고 부른다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/10.720w.webp 720w, /content/261001/10.1440w.webp 1440w, /content/261001/10.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/10.png" alt="나라별 규칙을 뜻하는 색 막대가 여러 component와 hook, domain object에 흩어져 있는 그림" style="max-width:min(720px, 100%);height:auto;" width="1600" height="902" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>이 단계가 앞의 세 단계와 다른 점은 판단 신호가 코드가 아니라 <strong>변경 요청</strong>에서 온다는 것이다. 지금 코드만 보면 분기 세 개는 읽기 어렵지 않다. 나라를 하나 더할 때 몇 군데를 고쳐야 하는지 세어 봐야 문제가 보인다. 그렇다면 흩어진 분기를 어디로 모아야 할까.</p>
<h2 id="strategy로-모은-나라별-차이"><a class="anchor" href="#strategy로-모은-나라별-차이">Strategy로 모은 나라별 차이</a></h2>
<p>저자의 답은 나라별 차이 자체를 객체로 만드는 것이다. 먼저 <a href="https://refactoring.com/catalog/extractClass.html" target="_blank" rel="noopener noreferrer">Extract Class</a>와 <a href="https://refactoring.com/catalog/replaceConditionalWithPolymorphism.html" target="_blank" rel="noopener noreferrer">Replace Conditional with Polymorphism</a>으로 interface 하나와 나라별 구현을 만든다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyAU</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 1달러 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyJP</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 100엔 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>만들고 보니 구현마다 다른 것은 통화 기호와 올림 함수 둘뿐이고, <code>getTip</code> 같은 나머지는 똑같다. 그래서 저자는 <a href="https://refactoring.com/catalog/inlineClass.html" target="_blank" rel="noopener noreferrer">Inline Class</a>로 구현들을 class 하나에 합치고, 다른 부분만 생성자로 받는다. 나라마다 subclass를 만들지 않고 올림 알고리즘을 함수로 받을 수 있는 것은 JavaScript에서 함수가 값이기 때문이다. 아래는 그 결과를 필자가 줄여 쓴 것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> roundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">roundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n); }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> n).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)); }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> japan</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3400</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 88</span></span></code></pre></figure>
<p>hook과 component는 이제 나라를 모른다. 이 객체 하나를 받아 <code>strategy.getRoundUpAmount(amount)</code>와 <code>strategy.currencySign</code>을 쓸 뿐이다. 나라를 하나 더하면 객체를 하나 더 만들면 끝나고, 고칠 파일은 없다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/11.720w.webp 720w, /content/261001/11.1440w.webp 1440w, /content/261001/11.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/11.png" alt="component가 PaymentStrategy 하나만 바라보고 나라별 구현 셋이 그 뒤에 모인 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="847" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>interface를 거쳤다가 다시 합친 과정이 군더더기처럼 보일 수 있다. 필자는 이 부분이 원문에서 가장 배울 만한 대목이라고 본다. 다형성은 목적이 아니라 공통점과 차이점을 드러내는 도구였고, 차이가 함수 하나로 줄어든 것이 보이자 class 계층을 걷어 냈다. 패턴을 들여오는 것보다 그 패턴을 언제 다시 걷어 내는지가 더 어려운 판단이다.</p>
<h2 id="fetch를-hook-밖으로"><a class="anchor" href="#fetch를-hook-밖으로">fetch를 hook 밖으로</a></h2>
<p>마지막으로 꺼내는 것은 <code>usePaymentMethods</code> 안의 fetch와 변환이다. hook은 React 개념이라 view 쪽에 남는다. error handling과 retry를 넣으면 hook이 금방 부풀고, 다른 view library로 옮기면 hook은 쓸 수 없다. 반면 일반 함수는 어디서든 쓸 수 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">API</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/payment-methods?countryCode=AU`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setPaymentMethods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>저자는 이 함수가 Anti-Corruption Layer 또는 Gateway처럼 동작한다고 쓴다. 서버 응답 구조가 바뀌어도 고칠 곳이 이 함수 하나에 머문다는 뜻이다. 다만 두 이름은 같은 것이 아니다. Fowler의 <a href="https://martinfowler.com/articles/gateway-pattern.html" target="_blank" rel="noopener noreferrer">Gateway</a>는 외부 시스템 접근을 한곳에 감싸는 객체이고, Anti-Corruption Layer는 Eric Evans의 DDD에서 온 말로 의미 체계가 다른 외부 모델이 내 모델을 오염시키지 않게 번역하는 경계다. 이 예제의 서버 응답은 <code>{ name }</code> 하나라 번역할 의미 차이가 거의 없으므로, 필자는 Gateway가 더 정확한 이름이라고 본다. 서버가 다른 팀 소유의 레거시라 결제 수단의 의미 자체가 다를 때 같은 자리가 Anti-Corruption Layer가 된다.</p>
<p>최종 구조는 이렇다. render는 component에, state는 hook에, 규칙은 domain object에, 네트워크 요청은 함수에 있다.</p>
<p><picture><source type="image/webp" srcset="/content/261001/12.720w.webp 720w, /content/261001/12.1440w.webp 1440w, /content/261001/12.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/12.png" alt="Payment와 하위 component, useRoundUp과 usePaymentMethods hook, PaymentStrategy와 PaymentMethod domain object, Fetcher가 각자의 칸에 놓인 최종 구조" style="max-width:min(720px, 100%);height:auto;" width="1600" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>저자는 이렇게 나눈 결과로 다섯 가지 이점을 든다. 결함이 있는 자리를 찾기 쉬워진다. 코드를 재사용하고 조합하기 쉬워진다. 읽기 쉬워진다. 기능을 더해도 전체가 흔들리지 않는다. 그리고 domain logic이 view를 모르므로 view layer만 갈아 끼울 수 있다. 마지막 것에는 저자 스스로 대부분의 프로젝트에서는 매우 드문 일이라는 단서를 단다. 다섯 모두 측정치는 없다는 점도 같이 기억해 둘 만하다.</p>
<p>여기까지가 원문의 줄거리다. 그런데 이 코드를 실제로 돌려 보면 글에서는 보이지 않던 것이 나온다.</p>
<h2 id="예제를-돌려-보면-보이는-것"><a class="anchor" href="#예제를-돌려-보면-보이는-것">예제를 돌려 보면 보이는 것</a></h2>
<p>필자는 저자의 예제 저장소(커밋 16개, 마지막 커밋 <code>de186c5</code>)를 받아 <code>tsc --noEmit</code>과 테스트 7개를 돌렸다. 둘 다 통과했고, 원문에 정의가 빠진 helper도 저장소의 <code>src/utils.ts</code>에 있었다. 그런데 구조를 들여다보니 옮겨 쓰기 전에 알아야 할 것이 셋 있었다.</p>
<h3 id="매-render마다-새로-만드는-strategy"><a class="anchor" href="#매-render마다-새로-만드는-strategy">매 render마다 새로 만드는 Strategy</a></h3>
<p>결제 component는 Strategy를 prop으로 받고, 넘기지 않으면 기본값을 <code>new</code>로 만든다. 그리고 <code>useRoundUp</code>은 그 Strategy를 <code>useMemo</code>의 dependency에 넣는다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  strategy </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, roundUpToNearestInteger),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount, strategy); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안에서 [agreeToDonate, amount, strategy]</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>JavaScript의 default parameter는 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters" target="_blank" rel="noopener noreferrer">호출할 때마다 평가된다</a>. 그리고 <code>useMemo</code>는 dependency를 <code>Object.is</code>로 비교한다. 둘이 만나면 render마다 새 인스턴스가 생기고 memo는 매번 다시 계산한다. React 18에서 같은 구조를 네 번 render했더니 기본값을 쓸 때 계산이 4번, 같은 인스턴스를 넘길 때 1번 돌았다. 저장소의 <code>App.tsx</code>는 <code>&#x3C;Payment amount={19.9} /></code>로 Strategy를 넘기지 않으므로 앱의 실행 경로가 정확히 기본값 쪽이다.</p>
<p>이 예제의 계산은 덧셈 몇 번이라 비용은 없다. 문제는 구조다. Strategy를 누가 어디서 만들어 넘기는지가 글에도 저장소에도 정해져 있지 않고, 테스트만 일본과 덴마크 인스턴스를 만든다. 필자라면 인스턴스를 module 수준 상수로 두고 나라 코드로 꺼내 넘기겠다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> strategies</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  AU: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  JP: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Payment amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{amount} strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{strategies[countryCode]} /></span></span></code></pre></figure>
<h3 id="저장소의-fetch에는-dependency-배열이-없다"><a class="anchor" href="#저장소의-fetch에는-dependency-배열이-없다">저장소의 fetch에는 dependency 배열이 없다</a></h3>
<p>원문 코드의 <code>useEffect</code>에는 <code>[]</code>가 있지만, 저장소의 <code>usePaymentMethods</code>에는 첫 커밋부터 마지막 커밋까지 dependency 배열이 없다. 배열이 없는 effect는 render마다 다시 돈다. 응답이 오면 새 배열로 state를 바꾸고, 그것이 render를 일으키고, effect가 다시 fetch한다. <code>convertPaymentMethods</code>가 매번 새 배열을 돌려주므로 멈추지 않는다.</p>
<p>fetch를 바로 응답하는 mock으로 바꾸고 Jest에서 300ms 동안 호출 수를 세면 네 번 재서 152, 225, 231, 233번이 나왔다. <code>[]</code>를 넣으면 1번이다. 저장소의 테스트는 화면에 문구가 뜨는지만 보므로 이 반복을 잡지 못하고 전부 통과한다. 원문을 따라 친 독자에게는 이 문제가 없고, 저장소를 그대로 받아 쓸 때만 걸린다.</p>
<h3 id="국가-코드를-prop으로-받는-순간-생기는-race"><a class="anchor" href="#국가-코드를-prop으로-받는-순간-생기는-race">국가 코드를 prop으로 받는 순간 생기는 race</a></h3>
<p>원문 코드의 fetch effect에도 cleanup이 없다. React 공식 문서의 <a href="https://react.dev/learn/synchronizing-with-effects" target="_blank" rel="noopener noreferrer">Synchronizing with Effects</a>는 effect 안에서 fetch하면 늦게 도착한 이전 응답이 최신 응답을 덮을 수 있으므로 <code>ignore</code> 플래그로 이전 응답을 버리라고 한다. 원문은 mount 때 한 번만 fetch하므로 지금은 문제가 없다. 하지만 URL의 <code>countryCode=AU</code>를 prop으로 받는 순간 dependency가 생기고 race가 열린다. 필자는 이 구조를 옮길 때 처음부터 이렇게 쓰겠다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(countryCode).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ignore) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [countryCode]);</span></span></code></pre></figure>
<p>하나 더 덧붙이면, 원문의 올림은 <code>Math.floor(amount + 1)</code>이라 금액이 이미 정수면 1을 더 올린다. 20달러 주문은 21달러가 된다. 원문은 19.80달러와 3312엔만 예로 들어 이것이 의도인지 알 수 없다. 실제 서비스에 옮긴다면 <code>Math.ceil</code>과 무엇이 다른지부터 정해야 한다.</p>
<h2 id="이-구조는-언제-쓰는가"><a class="anchor" href="#이-구조는-언제-쓰는가">이 구조는 언제 쓰는가</a></h2>
<p>저자 스스로 나누는 것은 고정 규칙이 아니라고 말한다. 작고 응집된 component는 한 파일에 두는 편이 동작을 이해하기 쉽고, 경계할 것은 파일이 이해할 수 없을 만큼 커지는 것이다. Todo 앱이나 폼 하나짜리 앱은 전부 component에 넣어도 괜찮고, 예제는 패턴을 많이 보여 주려고 일부러 복잡하게 만들었다고 밝힌다. 다른 자료들도 같은 방향의 단서를 단다.</p>
<p>Fowler는 같은 layering 글에서 이 구분을 비교적 작은 단위에만 쓰라고 한다. view, model, data를 최상위 폴더로 두는 것은 작은 시스템에서는 괜찮지만, 어느 하나가 너무 커지면 최상위를 도메인 단위로 나누고 그 안을 layer로 나누라는 것이다. 앞에서 본 <code>components/</code>, <code>hooks/</code>, <code>models/</code> 폴더를 큰 앱의 최상위 구조로 그대로 옮기면 Fowler가 피하라고 한 모양이 된다.</p>
<p>Dan Abramov도 비슷한 이력을 가지고 있다. 2015년에 <a href="https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0" target="_blank" rel="noopener noreferrer">presentational과 container component</a>로 나누자고 했다가, 2019년에 글머리에 이제는 그렇게 나누기를 권하지 않는다는 갱신문을 붙였다. Hooks가 같은 일을 임의의 구분 없이 해 준다는 이유였고, 필요도 없이 교조적으로 강제되는 것을 너무 많이 봤다고 덧붙였다. 원문의 hook 추출은 이 갱신문이 말하는 방식에 가깝다. container라는 이름의 component를 따로 두지 않고, hook을 부르는 <code>Payment</code>가 그 역할을 겸한다.</p>
<p>fetch에 대해서는 React 공식 문서가 한 걸음 더 간다. <a href="https://react.dev/learn/you-might-not-need-an-effect" target="_blank" rel="noopener noreferrer">You Might Not Need an Effect</a>는 fetch logic을 custom hook으로 빼 두면 나중에 더 나은 전략으로 옮기기 쉽다고 하면서도, 1순위로는 framework의 내장 fetching이나 TanStack Query 같은 client cache를 권한다. 원문의 마지막 단계처럼 fetch를 일반 함수로 빼 두면 그 함수를 그대로 TanStack Query의 <code>queryFn</code>에 넘길 수 있고, 앞 절의 race와 반복 호출은 library가 맡는다. 필자는 이 점이 원문이 한 문장으로만 언급한 가장 실용적인 이득이라고 본다. 키와 함수를 묶는 방식은 <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a>에서 따로 다뤘다.</p>
<p>정리하면 필자의 판단은 이렇다. 원문의 순서(hook, pure component, domain object, Strategy, network client)는 그대로 따를 만하다. 단계마다 그 단계의 불편이 다음 단계의 이유가 되기 때문이다. 다만 끝까지 가야 하는 것은 아니고, 지금 겪는 불편이 어느 단계의 것인지 보고 거기서 멈추면 된다. render와 무관한 코드가 많으면 hook까지, 같은 판정이 흩어져 있으면 domain object까지, 변경 하나에 여러 파일을 고치고 있으면 Strategy까지다. 그리고 Strategy를 쓴다면 인스턴스를 만드는 자리를 먼저 정하고, fetch를 effect에 둔다면 cleanup과 dependency부터 쓴다. 원문이 생략한 이 두 가지가 실제로 옮겨 쓸 때 가장 먼저 깨지는 곳이었다.</p>
<p>정답은 없지만, 이 글을 읽는 독자 분들도 자기 코드가 지금 몇 번째 단계의 불편을 겪고 있는지 한 번 짚어 보기를 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer" target="_blank" rel="noopener noreferrer">Microsoft Azure Architecture Center, Anti-corruption Layer pattern</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/headless-component.html" target="_blank" rel="noopener noreferrer">Juntao Qiu, Headless Component</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/data-fetch-spa.html" target="_blank" rel="noopener noreferrer">Juntao Qiu, Data Fetching Patterns in Single-Page Applications</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>아키텍처</category>
        </item>
        <item>
            <title><![CDATA[결정 모델, Jev와 Kev]]></title>
            <link>https://hooninedev.com/260922</link>
            <guid isPermaLink="false">https://hooninedev.com/260922</guid>
            <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 지난주 TypeSafe AI가 공개한, 텍스트 대신 확률을 돌려주는 모델 Jev에 대한 이야기를 해보려고 한다. 모델이 돌려준 확률에 임계값을 걸어 사람 확인 없이 자동 처리하려는 개발자를 위한 글이다. 끝까지 읽으면 Jev의 confidence가 어떻게 계산되는지, calibration이 분포에 따라 얼마나 움직이는지, 그리고 임계값을 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 지난주 TypeSafe AI가 공개한, 텍스트 대신 확률을 돌려주는 모델 Jev에 대한 이야기를 해보려고 한다. 모델이 돌려준 확률에 임계값을 걸어 사람 확인 없이 자동 처리하려는 개발자를 위한 글이다. 끝까지 읽으면 Jev의 <code>confidence</code>가 어떻게 계산되는지, calibration이 분포에 따라 얼마나 움직이는지, 그리고 임계값을 자기 데이터로 정하는 절차를 얻을 수 있다.</p>
<p>가장 먼저 눈에 들어온 사용처는 브라우저 자동화였다. Browserbase가 Stagehand의 <code>act()</code>에 Jev를 붙이는 <a href="https://github.com/browserbase/stagehand/pull/2953" target="_blank" rel="noopener noreferrer">PR</a>을 올렸다. 페이지의 <span class="glossary-term-source" data-glossary-key="accessibility-tree">접근성 트리</span>를 <code>state</code>로 보내고 "다음에 클릭할 요소는 어느 것인가"를 선택지로 묻는 구조였다. 40개 과제에서 <code>act()</code> 중앙값이 1.97초에서 0.46초로 줄었고, 147번의 동작 중 LLM으로 되돌아간 것은 4번이었다. Playwright 스크립트가 selector 하나 바뀌면 깨지는 자리를, 확률 하나 돌려받는 호출로 메운 것이다. 이 글을 쓰는 시점에 PR은 아직 머지되지 않았다.</p>
<p><picture><source type="image/webp" srcset="/content/260922/1.720w.webp 720w, /content/260922/1.1440w.webp 1440w, /content/260922/1.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/1.png" alt="Stagehand의 act()가 Jev를 쓰는 흐름. 지시문과 접근성 트리에서 후보 목록을 만들어 Jev에 &#x22;어느 것인가&#x22;와 &#x22;해당 없음인가&#x22;를 묻고, 해당 없음 확률이 낮으면 그 요소를 클릭하며 높으면 LLM에 넘긴다. 147번 중 143번이 Jev로 끝났다" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>그 PR이 그은 선이 이 글의 질문이다. Jev의 답을 받아들이는 기준을 <a href="https://www.browserbase.com/blog/what-is-jev" target="_blank" rel="noopener noreferrer">확률 0.7</a>로 두고, 못 넘으면 LLM에게 넘긴다. <strong>저 모델이 돌려주는 확률을 믿고 코드에서 선을 그어도 되는가.</strong></p>
<p>남의 벤치마크만으로는 판단할 수 없어서 필자의 데이터로 검증해보려 한다. 이 블로그에는 음차 외래어를 막는 게이트가 있다. <code>피커</code>가 아니라 <code>picker</code>로 쓰자는 규칙인데, 정규식은 확실한 절반만 잡고 <code>멀티스레드</code>나 <code>콜 스택</code>처럼 합성어이거나 이미 정착한 말은 필자가 매번 손으로 판단한다. 답은 "되돌린다"와 "둔다" 둘뿐이고, 아는 사람은 1초에 판단하고, 빌드 스크립트 안에서 사람 확인 없이 통과와 실패를 정해야 한다. Stagehand의 selector 판단과 모양이 같다.</p>
<p>그래서 순서를 이렇게 잡았다. Jev가 무엇을 돌려주는지, 그 확률이 어떤 학습에서 나오는지, 필자의 게이트에 넘기면 어떤 숫자가 나오는지, 그리고 그 숫자를 어디에 쓸 수 있는지다.</p>
<h2 id="텍스트를-만들지-않는-모델"><a class="anchor" href="#텍스트를-만들지-않는-모델">텍스트를 만들지 않는 모델</a></h2>
<p>2026년 9월 15일에 공개되었다. TypeSafe에서 이것을 LLM이라고 부르지 않고 System One Model이라는 새 범주로 부른다. 이 글에서는 결정 모델이라고 부른다. 창업자 Diogo Almeida는 OpenAI에서 <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT 논문</a>의 네 번째 저자로 참여했던 사람이다. ChatGPT 만든 팀에 있었고, 지금은 그 방법의 한계를 지적하는 쪽에 서 있다.</p>
<p>Jev는 문장을 생성하지 않는다. 답의 모양을 미리 정해 주면 그중에서 고르고 확률을 함께 돌려준다. 질문 타입은 셋뿐이다.</p>
<table>
<thead>
<tr>
<th>타입</th>
<th>묻는 것</th>
<th>돌려주는 것</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>choice</code></td>
<td>이 중 어느 것인가</td>
<td>선택, <code>probabilities</code>, <code>confidence</code></td>
</tr>
<tr>
<td><code>score</code></td>
<td>어느 수준인가</td>
<td>점수, <code>legend</code>, <code>probabilities</code>, <code>confidence</code></td>
</tr>
<tr>
<td><code>noul</code></td>
<td>참인가</td>
<td>0에서 1 사이 확률 하나</td>
</tr>
</tbody>
</table>
<p>endpoint도 하나다. <code>POST /v1/systemone</code>에 <code>state</code>(평가할 내용), <code>model</code>, <code>questions</code>(질문 맵)를 보내면 답이 온다. 같은 <code>state</code>에 대한 질문 여러 개를 한 요청에 담을 수 있고, 질문을 늘려도 응답 시간이 거의 안 늘어난다.</p>
<p>왜 빠른지는 <a href="/260610" target="_blank" rel="noopener noreferrer">토큰의 원리</a>에서 정리한 <span class="glossary-term-source" data-glossary-key="prefill">prefill</span>과 decode의 비대칭으로 설명된다. LLM이 느린 이유는 출력을 한 토큰씩 순차적으로 짜내기 때문인데, Jev는 그 decode 단계가 없다. 입력을 한 번 병렬로 읽고 확률을 바로 읽어낸다. 그래서 출력 토큰 요금이 아예 없고 입력만 백만 토큰당 $0.042다.</p>
<p>Archer Hume이라는 개발자가 API를 약 1만 번 호출해 바깥에서 동작을 복원한 <a href="https://archerhume.com/posts/jevs-architecture-unmasked/" target="_blank" rel="noopener noreferrer">분석</a>이 있다. 입력 360토큰에 중앙값 57.5ms, 29,835토큰에 218ms, 질문을 1,500개 넣었을 때 610ms였다. 그중 가장 직접적인 증거는 선택지가 200개인 응답이 2개짜리와 같은 속도로 돌아왔다는 관찰이다. 답을 쓰는 데 시간이 안 걸린다는 뜻이다.</p>
<h2 id="사람이-안-보는-판단"><a class="anchor" href="#사람이-안-보는-판단">사람이 안 보는 판단</a></h2>
<p>속도와 가격이 눈에 띄지만, Almeida가 공개 발표 글에서 던진 질문은 다른 쪽이었다. 모델이 몇 년째 대화에서 초인적인데 자동화는 다 어디 갔느냐는 것이다.</p>
<p>그의 답은 두 일의 종류가 다르다는 것이다. 챗봇과 copilot과 코딩 agent는 사람이 옆에서 보면서 만족하는 것이 목표다. 서버에서 조용히 돌면서 아무도 안 보는 판단은 그렇지 않다. 앞을 assistant, 뒤를 automation이라고 부른다면, 지금까지 나온 모델은 전부 앞쪽을 위해 만들어졌다.</p>
<p><a href="/260622" target="_blank" rel="noopener noreferrer">harness 설계</a>를 정리하면서도 비슷한 구분을 한 적이 있다. 에이전트 시스템 안에는 사람이 보지 않는 판단이 무수히 많다. 이 요청을 사람에게 넘길까. 이 명령을 실행해도 되나. 지금은 그것들을 전부 비싼 LLM에게 물어보고 있다. 필자의 정규식 게이트가 못 덮는 나머지 절반도 그런 판단이다.</p>
<h2 id="rlhf가-남긴-자리"><a class="anchor" href="#rlhf가-남긴-자리">RLHF가 남긴 자리</a></h2>
<p>그런데 왜 새 학습법이 필요했을까. 기존 모델에게 예/아니오만 시키면 안 되는 걸까.</p>
<p>답의 실마리는 <span class="glossary-term-source" data-glossary-key="calibration">Calibration</span>에 있다. 정확도가 몇 퍼센트 맞히느냐라면 calibration은 자기가 몇 퍼센트 맞힐지를 아느냐이고, 그 어긋남을 재는 지표가 <span class="glossary-term-source" data-glossary-key="ece">ECE</span>(expected calibration error)다. 확률 구간마다 말한 확률과 실제 적중률의 차이를 표본 비율로 가중해 평균한 값이라 0이 완벽이다. 그런데 사람의 선호를 최적화하는 <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span>(reinforcement learning from human feedback)를 거친 뒤 이 눈금이 어긋난 사례가 있다. <a href="https://arxiv.org/abs/2303.08774" target="_blank" rel="noopener noreferrer">GPT-4 기술 보고서</a>의 Figure 8에서는 사전학습 모델의 ECE가 <strong>0.007</strong>이었는데 post-training을 거친 모델은 <strong>0.074</strong>로 열 배 넘게 나빠졌다. 보고서는 그 원인을 적지 않았고, 필자도 원인을 확정한 1차 근거는 찾지 못했다. 정의와 원인이 어디까지 밝혀졌는지는 <a href="/260917" target="_blank" rel="noopener noreferrer">LLM calibration과 overconfidence</a>에 따로 정리해 두었다.</p>
<p><strong>다만 ECE 하나로는 부족하다.</strong> 모든 입력에 0.6을 찍는 상수 예측기도 실제 적중률이 60%면 ECE가 0이다. 확률이 정직하기만 하고 사안마다 갈리지 않으면 선을 그을 자리가 없다. 그래서 calibration과 별개로 <strong>확률이 실제로 갈라지는지</strong>를 같이 봐야 하고, 뒤에서 쓸 "오차 예산 안에서 자동 처리할 수 있는 비율"이 그 둘을 한 숫자로 묶은 지표다.</p>
<p>소프트웨어 입장에서 중요한 것은 이 부분이다. 확률이 정직하고 갈라지면 선을 그을 수 있다. 0.95 넘으면 자동 처리하고 그 아래는 사람에게 넘기는 구조가 나온다.</p>
<p>TypeSafe는 그 자리를 겨냥해 세 번째 길을 냈다고 말한다. 사람의 선호를 최적화하는 RLHF, 기계가 채점할 수 있는 정답을 최적화하는 RLVR(reinforcement learning with verifiable rewards), 그리고 calibration된 결정을 최적화한다는 RLCD다.</p>
<p>다만 RLCD로 공개된 것은 출력 계약 세 줄이 전부다. 이 글을 쓰는 2026년 9월 23일 기준으로 TypeSafe의 문서와 발표 글 어디에도 논문이나 기술 보고서가 없다. 같은 약어를 쓰는 <a href="https://arxiv.org/abs/2307.12950" target="_blank" rel="noopener noreferrer">ICLR 2024 논문</a>이 따로 있는데 그쪽은 reinforcement learning from contrastive distillation이라 다른 방법이다.</p>
<h2 id="confidence의-정체"><a class="anchor" href="#confidence의-정체">confidence의 정체</a></h2>
<p>그래서 Jev가 돌려주는 숫자는 정직한가. 답하기 전에 먼저 볼 것이 있다. <strong>돌려주는 숫자가 하나가 아니다.</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/3.720w.webp 720w, /content/260922/3.1440w.webp 1440w, /content/260922/3.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/3.png" alt="Jev의 응답에서 probabilities는 학습으로 만들어지고 confidence는 그 분포를 산술로 접은 값이며, calibration 주장은 probabilities에만 걸린다는 것을 보여주는 그림" style="max-width:min(720px, 100%);height:auto;" width="1560" height="840" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p><code>Choice</code>와 <code>Score</code> 응답에는 <code>probabilities</code>와 <code>confidence</code>가 같이 들어 있다. 앞은 선택지 전체에 걸친 확률 분포이고, 뒤는 0에서 1 사이 숫자 하나다. 코드에서 임계값을 걸 때 손이 먼저 가는 쪽은 <code>confidence</code>다.</p>
<p>이 값은 모델이 만든 것이 아니다. TypeSafe 조직이 공개한 <a href="https://github.com/typesafe-ai/system-one-adapter-python" target="_blank" rel="noopener noreferrer">system-one-adapter-python</a>의 <code>_utils/confidence_metrics.py</code>의 <code>Choice</code> 부분은 아래와 같다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">def</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> choice_confidence</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs: list[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) -> </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    """Scale peak choice probability from uniform to certainty."""</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    normalized_probs </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> _normalize(probs)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    uniform_probability </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> /</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability)</span></span></code></pre></figure>
<p>모델 호출도 없고 학습된 파라미터도 없다. <code>probabilities</code> 벡터 하나가 들어가고 실수 하나가 나온다. 식으로 쓰면 <code>c = (p_max − 1/K) / (1 − 1/K)</code>이고, 선택지가 셋이고 최대 확률이 0.8이면 0.7이 된다.</p>
<p>이 저장소는 프로덕션 Jev 서버가 아니라 LLM으로 같은 API를 흉내 내는 대체 구현이다. 그러니 이것만으로 단정할 수는 없다. 그런데 같은 식을 가리키는 증거가 둘 더 있다. TypeSafe 공식 문서의 <a href="https://docs.typesafe.ai/confidence" target="_blank" rel="noopener noreferrer">confidence 페이지</a>가 이 값을 "a statistic computed from the probability distribution"이라고 적고, 데모 코드에는 세 선택지용 근사식이라며 <code>(3 × largest probability − 1) / 2</code>를 실었다. K가 3이면 위 식과 같다. 그리고 <a href="https://github.com/SamuelSacco/jev-exploration" target="_blank" rel="noopener noreferrer">주장 감사 원장</a>이 실제 API에서 둘 다 틀린 선택지 둘만 준 응답의 승자가 0.52일 때 <code>confidence</code>가 0.04로 왔다고 기록했다. K가 2면 <code>(0.52 − 0.5) / (1 − 0.5) = 0.04</code>다. 식과 맞는다.</p>
<p>같은 저장소에서 <code>Score</code>는 아예 다른 식을 쓴다. 최빈 수준으로부터의 평균 절대 거리를 균등 분포의 그것으로 나눈 뒤 <strong>1에서 뺀다.</strong> 문서는 <code>Score</code>의 식을 공개하지 않는다. 그러니까 <code>confidence</code>라는 한 이름 아래 산식이 둘 있고, 타입이 다르면 같은 숫자가 다른 뜻이다. <code>Noul</code>에 <code>confidence</code>가 없는 것도 같은 이유다. 접을 분포가 없다.</p>
<p><picture><source type="image/webp" srcset="/content/260922/4.720w.webp 720w, /content/260922/4.1440w.webp 1440w, /content/260922/4.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/4.png" alt="p_max 눈금에서 1/K 를 0 으로, 1 을 1 로 당겨 confidence 눈금을 만드는 그림. 선택지가 셋일 때 0.80 이 0.7 로 옮겨지고 1/3 아래는 버려지며 순서는 그대로다" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>문서도 이것을 숨기지 않는다. 다른 계산을 쓰고 싶으면 <code>probabilities</code> 전체를 줄 테니 알아서 하라고까지 안내한다. <strong>적혀 있는 내용이고, 읽지 않고 쓰는 쪽이 문제다.</strong></p>
<p>그런데 <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" target="_blank" rel="noopener noreferrer">공개 발표 글</a>의 비교표는 둘을 붙여 쓴다. "Calibrated: higher confidence means higher accuracy"라고 적혀 있다. 읽는 사람은 <code>confidence</code>가 calibration된 값이라고 받아들이는데, calibration 주장이 실제로 걸려 있는 쪽은 <code>probabilities</code>다.</p>
<p>이 구분이 숫자로 얼마나 벌어지는지는 측정이 있다. <code>scienthoon</code>이라는 개발자가 공개한 <a href="https://github.com/scienthoon/jev-ood-calibration" target="_blank" rel="noopener noreferrer">독립 calibration 측정</a>이 <code>confidence</code>를 "맞을 확률"로 읽었을 때의 ECE를 따로 쟀다. OpenBookQA에서 0.035, HellaSwag에서 0.078, 합성 집합에서 0.18이다. 같은 집합의 원본 확률은 각각 0.024, 0.029, 0.107이었다. <strong>세 집합 전부에서 <code>confidence</code> 쪽이 나쁘다.</strong></p>
<p>왜 나빠지는지는 식에 있다. K가 고정이면 <code>c</code>는 <code>p_max</code>의 순증가 변환이라 순서가 보존된다. 판별력은 그대로이고 임계값만 옮겨 적으면 된다. <strong>망가지는 것은 정보가 아니라 눈금이다.</strong> K가 질문마다 다르면 문제가 하나 더 생긴다. 늘이는 배율도 달라진다. 같은 <code>p_max</code> 0.8이 선택지 둘에서는 0.6이 되고 다섯에서는 0.75가 된다. 선택지 수가 섞인 워크로드에서 임계값 하나를 쓰는 순간 그게 문제가 된다.</p>
<h2 id="분포에-따라-달라지는-calibration"><a class="anchor" href="#분포에-따라-달라지는-calibration">분포에 따라 달라지는 calibration</a></h2>
<p>그렇다면 <code>probabilities</code> 쪽은 믿어도 될까.</p>
<p>Jev를 실제로 호출해 재본 기록이 공개되어 있다. Jared Palmer가 만든 오픈소스 재현 구현 <a href="https://github.com/jaredpalmer/kev" target="_blank" rel="noopener noreferrer">Kev</a>의 저장소에는 <code>runs/jev-*</code> directory가 있고, <code>usage.json</code>이 <code>"model": "typesafe-ai/jev"</code>로 Vercel AI Gateway를 거친 실호출임을 기록하고 있다. <code>jev-*</code> directory 여섯 개의 <code>usage.json</code>을 합치면 2026년 9월 18일과 20일에 걸쳐 5,364회를 불렀고 입력이 2,418,260토큰이다. 아래 일곱 집합 중 <code>scienthoon</code>은 그 개발자의 결과 파일을 변환해 들여온 것이라 이 합계에 들어 있지 않다.</p>
<p>가격이 실제로 입력만 과금된다는 것은 다른 곳에서 확인된다. 피싱 벤치마크 저자가 TypeSafe dashboard를 보고 적어 뒀다. 두 번 돌린 456만 토큰 중 입력이 366만, 출력이 90만이었는데 청구는 $0.15였고 그 값이 입력만 계산한 것과 맞았다.</p>
<p>같은 directory의 <code>report.json</code>에서 평가 집합 일곱 개의 지표를 꺼내 정리하면 이렇다. 아래에서 <strong>확신도는 <code>confidence</code> 필드가 아니라 <code>probabilities</code>의 최댓값</strong>이다. overconfidence는 <code>평균 확신도 − 정확도</code>이고 총 5,743건이다. 판단 근거를 지운 "알 수 없는" 항목은 정확도 채점에서 빼는 <code>clean</code> 블록의 값이다.</p>
<table>
<thead>
<tr>
<th>평가 집합</th>
<th>n</th>
<th>정확도</th>
<th>평균 확신도</th>
<th>overconfidence</th>
<th>ECE</th>
</tr>
</thead>
<tbody>
<tr>
<td>semif</td>
<td>144</td>
<td>0.965</td>
<td>0.965</td>
<td>-0.000</td>
<td>0.011</td>
</tr>
<tr>
<td>transfer-v9</td>
<td>1,046</td>
<td>0.854</td>
<td>0.880</td>
<td>+0.026</td>
<td>0.034</td>
</tr>
<tr>
<td>transfer-v4</td>
<td>656</td>
<td>0.857</td>
<td>0.903</td>
<td>+0.046</td>
<td>0.049</td>
</tr>
<tr>
<td>transfer-v2</td>
<td>560</td>
<td>0.855</td>
<td>0.900</td>
<td>+0.045</td>
<td>0.055</td>
</tr>
<tr>
<td>decision-v4</td>
<td>1,264</td>
<td>0.845</td>
<td>0.906</td>
<td>+0.061</td>
<td>0.067</td>
</tr>
<tr>
<td>decision-v2</td>
<td>1,200</td>
<td>0.833</td>
<td>0.904</td>
<td>+0.071</td>
<td>0.074</td>
</tr>
<tr>
<td>scienthoon</td>
<td>873</td>
<td>0.753</td>
<td>0.851</td>
<td>+0.099</td>
<td>0.105</td>
</tr>
</tbody>
</table>
<p>두 가지가 읽힌다.</p>
<p>첫째, <strong>ECE가 열 배 가까이 움직인다.</strong> 0.011에서 0.105다. 같은 채점기, 같은 모델이다. "calibration됐다"는 모델의 성질이 아니라 모델과 분포의 관계인 것이다. 분포가 바뀌면 calibration이 무너진다는 것 자체는 이미 알려진 결과다. 여기서 새로운 것은 <strong>calibration을 학습 목표로 내세운 모델에서도 같은 모양이 나온다</strong>는 점이다.</p>
<p>ECE가 가장 큰 집합의 0.105는 앞에서 본 post-RLHF GPT-4의 0.074보다도 크다. 다만 이 둘을 순위로 읽으면 안 된다. 과제가 다르고, ECE는 구간을 몇 개로 나누느냐와 표본이 어느 구간에 몰렸느냐에 따라 값이 움직이는 추정량이라 서로 다른 채점기에서 나온 숫자를 맞대면 비교가 성립하지 않는다.</p>
<p>둘째, <strong>측정된 ECE가 overconfidence 하나로 거의 전부 설명된다.</strong> 두 열의 차이가 일곱 개 전부 0.002에서 0.011 사이다. ECE는 구간별 격차의 가중 평균이라 전체 overconfidence보다 작을 수 없는데, 그 차이가 거의 0이라는 것은 <strong>구간마다 어긋나는 부호가 한 방향으로 쏠려 있다</strong>는 뜻이다. 구간별로 엇갈려 상쇄되는 잡음이 아니다.</p>
<p>왜 그런지는 세로로 읽으면 보인다. 평균 확신도는 <code>semif</code>를 빼면 0.851에서 0.906 사이에 여섯 개가 붙어 있다. 같은 구간에서 정확도는 0.753에서 0.857로 두 배 넓게 움직인다. <strong>확신도는 분포가 바뀌어도 잘 안 움직이고 정확도만 움직인다. 남는 차이가 그대로 ECE다.</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/5.720w.webp 720w, /content/260922/5.1440w.webp 1440w, /content/260922/5.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/5.png" alt="Jev 실호출 평가 집합 7개에서 평균 확신도는 0.851에서 0.906 사이에 몰려 있고 정확도만 0.753에서 0.965로 움직이며 그 간격이 ECE와 거의 같다는 것을 보여주는 도표" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>이 성질이 실무에서 어떻게 나타나는지는 다른 벤치마크가 보여준다. 피싱 이메일 2,000건에 Claude Haiku 4.5를 대조군으로 세운 <a href="https://github.com/anisselbd/jev-phishing-bench" target="_blank" rel="noopener noreferrer">측정</a>에서 Jev의 정확도는 62.6%였고 Haiku는 81.3%였다. 그런데 더 눈에 띄는 것은 ECE다. 리포가 보고한 ECE는 Jev 0.154, Haiku 0.097이고, 주장 감사 원장이 Jev의 <code>P(phishing)</code> 값을 0에서 1까지 10구간으로 다시 재면 0.170이다. 어느 쪽으로 읽어도 <strong>calibration을 학습 목표로 내세운 모델이 RLHF로 만든 LLM에게 calibration에서 졌다.</strong> 같은 저자가 링크 호스트 목록 규칙 하나만으로 91.6%를 냈다.</p>
<p>같은 지표를 여러 집합에서 보면 폭이 더 분명하다. <span class="glossary-term-source" data-glossary-key="error-budget">오차 예산</span> 5%에서 자동 처리할 수 있는 비율이 <code>scienthoon</code> 집합에서 0.486, <code>transfer-v9</code>에서 0.695, <code>semif</code>에서 1.000이다. Kev의 채점기는 이 값을 표본 안에서 임계값을 고른 최댓값이라고 적어 두었고, 배포 후의 오차 보장이 아니다. <strong>어느 하나를 모델의 스펙처럼 인용하면 안 된다.</strong></p>
<h2 id="음차-판별-게이트"><a class="anchor" href="#음차-판별-게이트">음차 판별 게이트</a></h2>
<p>남의 벤치마크를 읽는 것과 내 데이터에서 재보는 것은 다르다. 그래서 서두의 게이트를 실제로 넘겨 봤다.</p>
<p>필자는 앞서 언급한 <strong>Kev-9B를 로컬에서 돌렸다.</strong> Qwen3.5-9B-Base에 rank-16 <span class="glossary-term-source" data-glossary-key="lora">LoRA</span>와 pointer head를 얹은 재현 구현이고 Apache-2.0이다. 아래 숫자는 Jev의 숫자가 아니다. Kev의 학습 데이터는 영어 결정 과제이고, 같은 저자의 측정에서 Kev-9B는 영어 과제에서도 Jev에 뒤진다. 오차 예산 5%의 자동화 비율이 0.45에서 0.57인데 Jev가 0.70이고, MMLU-Pro는 0.52 대 0.84다. 그리고 TypeSafe 문서도 Jev에 대해 <a href="https://docs.typesafe.ai/models" target="_blank" rel="noopener noreferrer">영어가 주 학습 언어이고 CJK는 동등하지 않다</a>고 밝히고 있다.</p>
<p>평가 집합은 필자의 한국어 블로그 글 18편에서 뽑았다. 라벨의 정답 기준은 <strong>블로그에서 그 단어를 일관되게 어느 표기로 쓰는가</strong>다. 한국어 기술 문서 공동체의 합의가 아니라 이 블로그의 관습이라는 뜻이고, 둘이 갈리는 단어에서는 모델이 공동체 기준으로 맞고 이 기준으로 틀릴 수 있다.</p>
<ul>
<li><strong>게이트에 있는 단어, 102문장</strong>: 게이트가 이미 아는 단어다. 본문이 영어로 쓴 <code>calendar</code>, <code>picker</code>, <code>adapter</code> 같은 단어를 한글 음차로 바꿔 만든 문장, 즉 <strong>되돌릴 문장</strong>이 23개이고, <code>write-post.md</code>가 예외로 명시한 <code>리렌더링</code>이나 <code>콜 스택</code>이 실제로 쓰인 문장, 즉 <strong>그대로 둘 문장</strong>이 79개다. 여기서 현재 정규식 게이트는 정의상 100%를 맞힌다.</li>
<li><strong>게이트에 없는 단어, 60문장</strong>: <strong>게이트가 한 번도 본 적 없는 단어다.</strong> 머신러닝에서 <span class="glossary-term-source" data-glossary-key="held-out-set">held-out</span> 이라고 부르는 쪽이다. 본문이 영어로만 쓰는 <code>loader</code>, <code>mutation</code>, <code>prefill</code>을 음차로 바꾼 되돌릴 문장이 30개, 본문이 한글로만 쓰는 <code>리듀서</code>, <code>스냅샷</code>, <code>런타임</code>이 실제로 쓰인 그대로 둘 문장이 30개다. 여기서 정규식은 되돌릴 문장을 하나도 못 잡는다.</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260922/6.720w.webp 720w, /content/260922/6.1440w.webp 1440w, /content/260922/6.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/6.png" alt="게이트에 있는 단어 102문장과 없는 단어 60문장이 각각 되돌릴 문장과 그대로 둘 문장으로 어떻게 나뉘는지, 그리고 정규식이 앞쪽은 전부 잡고 뒤쪽은 하나도 잡지 못하는 것을 보여주는 그림" style="max-width:min(720px, 100%);height:auto;" width="1560" height="860" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>되돌릴 문장은 필자가 만든 것이고 그대로 둘 문장은 실제 문장이라는 비대칭은 먼저 밝혀 둔다. 그리고 <strong>유효 표본은 문장 수가 아니라 단어 수다.</strong> 라벨이 단어 단위로 정해지므로 같은 단어가 나오는 문장들은 독립 관측이 아니다. 게이트에 있는 단어는 24종, 없는 단어는 13종이다.</p>
<p>아래 표는 "이 단어를 영어로 되돌려야 하는가"를 묻는 <code>noul</code> 질문 하나만 집계한 것이다. 같은 요청에 질문을 넷 담았지만 나머지 셋은 부정형과 <code>Choice</code> 변형, 그리고 라벨을 제대로 세우지 못한 합성어 판정이라 여기 섞지 않았다. <strong>5% 예산 자동화 비율</strong>은 확신도가 높은 쪽부터 잘라 내려가며 그 위 구간의 오차가 5%를 넘지 않는 최대 비율이다.</p>
<table>
<thead>
<tr>
<th></th>
<th>게이트에 있는 단어</th>
<th>게이트에 없는 단어</th>
</tr>
</thead>
<tbody>
<tr>
<td>문장 / 단어</td>
<td>102 / 24종</td>
<td>60 / 13종</td>
</tr>
<tr>
<td>현재 정규식 게이트</td>
<td><strong>1.000</strong></td>
<td>0.500</td>
</tr>
<tr>
<td>Kev-9B 정확도</td>
<td>0.225</td>
<td>0.500</td>
</tr>
<tr>
<td>단어 단위 정확도</td>
<td>6/24</td>
<td>7/13</td>
</tr>
<tr>
<td>다수 클래스 기준선</td>
<td>0.775</td>
<td>0.500</td>
</tr>
<tr>
<td>ECE</td>
<td>0.664</td>
<td>0.336</td>
</tr>
<tr>
<td>평균 확신도</td>
<td>0.876</td>
<td>0.821</td>
</tr>
<tr>
<td>확신도 0.9 이상 비율</td>
<td>0.490</td>
<td>0.317</td>
</tr>
<tr>
<td>그 구간의 실제 적중</td>
<td>0.240</td>
<td>0.526</td>
</tr>
<tr>
<td><strong>5% 예산 자동화 비율</strong></td>
<td><strong>0.010</strong></td>
<td><strong>0.000</strong></td>
</tr>
</tbody>
</table>
<p>다수 클래스 기준선은 문장을 안 읽고 많은 쪽 라벨만 찍었을 때의 점수다. 모델이 그 아래다.</p>
<p>마지막 줄이 이 실험의 답이다. <strong>게이트에 없는 단어에서는 임계값을 아무리 높게 잡아도 5% 오차 안에서 자동 처리할 수 있는 결정이 한 건도 없었다.</strong></p>
<p>예측 분포를 보면 무슨 일이 일어났는지 나온다. 모델은 <strong>162문장 전부에 "되돌려야 한다"고 답했다.</strong> 단어 37종 전부다. 되돌릴 문장은 전부 맞고 그대로 둘 문장은 전부 틀렸다. 그래서 게이트에 없는 단어의 0.500은 실력이 아니라 집합이 균형 잡혀 있어서 나온 숫자다.</p>
<p>그러면서 평균 확신도가 0.876이었다. 게이트에 있는 단어에서 확신도 0.9를 넘은 것이 49%인데 그 구간의 실제 적중은 0.240이다.</p>
<p>두 확률의 합이 1이 되는 관계도 성립하지 않았다. "되돌려야 하는가"와 "그대로 둬야 하는가"의 확률을 더하면 게이트에 있는 단어에서 평균 1.655가 나왔고, 102문장 전부가 1에서 0.1 넘게 벗어났다.</p>
<p>설계상 그렇다. 두 질문은 서로를 읽지 못하는 별개의 평가이고, TypeSafe도 <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" target="_blank" rel="noopener noreferrer">jaggedness 페이지</a>에 <code>refund</code> 0.72와 <code>not_refund</code> 0.47을 더해 1.19가 되는 예를 직접 실어 뒀다.</p>
<p>실무에서 걸리는 지점은 이것이다. <strong><code>Noul</code>에 맞춰 조율한 임계값을 <code>Choice</code>로 옮기면 안 된다.</strong> 같은 판단인데 결론이 갈리는 문장이 14%에서 17%였다.</p>
<p>무엇을 물어도 예라고 하는 모델이면 이 결과는 무의미하다. 그것부터 확인했다.</p>
<table>
<thead>
<tr>
<th>상태 / 질문</th>
<th>물은 것</th>
<th>답</th>
</tr>
</thead>
<tbody>
<tr>
<td>한국어 / 한국어</td>
<td>이 문장은 영어로 쓰여 있는가?</td>
<td>0.02</td>
</tr>
<tr>
<td>한국어 / 한국어</td>
<td>이 문장은 요리법을 설명하는가?</td>
<td>0.04</td>
</tr>
<tr>
<td>한국어 / 한국어</td>
<td>이 문장은 한국어로 쓰여 있는가?</td>
<td>0.98</td>
</tr>
<tr>
<td>한국어 / 영어</td>
<td>Is this sentence written in English?</td>
<td>0.01</td>
</tr>
<tr>
<td>영어 / 영어</td>
<td>Is this sentence about software?</td>
<td>0.96</td>
</tr>
</tbody>
</table>
<p><strong>한국어를 읽고, "아니오"를 말하고, 0.02와 0.98을 가른다.</strong> 전역적인 순응 편향은 아니다. 배제되는 것은 거기까지다. 게이트 질문의 문구 자체가 나쁠 가능성은 남는다.</p>
<h2 id="규칙을-state에-넣은-결과"><a class="anchor" href="#규칙을-state에-넣은-결과">규칙을 state에 넣은 결과</a></h2>
<p>그렇다면 판별에 필요한 지식을 직접 주면 달라질까. TypeSafe 문서가 권하는 도메인 적응 방식이 이것이다. 가중치를 건드리지 않고 참고 자료를 <code>state</code>에 실어 보낸다.</p>
<p><code>write-post.md</code>에 이미 적혀 있는 규칙 문단을 <code>state</code>에 함께 넣고 다시 돌렸다. 입력이 문장당 100토큰에서 450토큰으로 늘었다. <strong>게이트에 있는 단어 쪽은 <span class="glossary-term-source" data-glossary-key="data-leakage">데이터 누출</span>이다.</strong> 그 참고 자료가 게이트 단어와 예외 단어를 이름으로 나열하기 때문이다. 그래서 그쪽은 "참고 자료를 읽기는 하는가"를 보는 대조군이고, 진짜 시험은 게이트에 없는 단어다.</p>
<table>
<thead>
<tr>
<th></th>
<th>있는 단어(누출)</th>
<th>있는 단어 +규칙</th>
<th>없는 단어</th>
<th>없는 단어 +규칙</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>noul</code> 정확도</td>
<td>0.225</td>
<td>0.676</td>
<td><strong>0.500</strong></td>
<td><strong>0.500</strong></td>
</tr>
<tr>
<td>단어 단위 정확도</td>
<td>6/24</td>
<td></td>
<td>7/13</td>
<td><strong>5/13</strong></td>
</tr>
<tr>
<td>되돌릴 문장 정답</td>
<td>23/23</td>
<td>1/23</td>
<td>30/30</td>
<td>8/30</td>
</tr>
<tr>
<td>그대로 둘 문장 정답</td>
<td>0/79</td>
<td>68/79</td>
<td>0/30</td>
<td>22/30</td>
</tr>
<tr>
<td>5% 예산 자동화 비율</td>
<td>0.010</td>
<td>0.314</td>
<td>0.000</td>
<td>0.050</td>
</tr>
<tr>
<td>지연 중앙값</td>
<td>2,924ms</td>
<td>6,903ms</td>
<td>2,908ms</td>
<td>6,918ms</td>
</tr>
</tbody>
</table>
<p>지연은 M2 Max에서 돌린 로컬 추론이라 앞에서 본 Jev의 API 지연과 같은 축이 아니다. 여기서 볼 것은 절대값이 아니라 참고 자료를 넣었을 때의 2.4배다.</p>
<p><strong>균형 잡힌, 게이트에 없는 단어에서 문장 단위 정확도가 0.500에서 0.500으로, 전혀 움직이지 않았다.</strong> 단어 단위로는 7/13에서 5/13으로 오히려 내려갔다.</p>
<p><picture><source type="image/webp" srcset="/content/260922/7.720w.webp 720w, /content/260922/7.1440w.webp 1440w, /content/260922/7.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/7.png" alt="게이트에 없는 단어 60문장에서 규칙 문단을 state에 넣기 전과 후를 비교한 그림. 전에는 되돌릴 문장 30개를 전부 맞히고 그대로 둘 문장 30개를 전부 틀렸으며, 후에는 8개와 22개를 맞혔다. 맞힌 수는 둘 다 30개다" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>게이트에 있는 단어의 0.225에서 0.676은 실력 향상이 아니다. 전에는 102문장 전부에 "되돌린다"고 했고, 참고 자료를 주자 12문장에만 그렇게 답했다. 그대로 둘 문장이 0/79에서 68/79로 올랐지만 되돌릴 문장이 23/23에서 1/23으로 무너졌다. 그 집합은 그대로 둘 문장이 79대 23으로 많으니 "대체로 두라"는 상수가 자동으로 더 높은 점수를 받는다. <strong>구별을 배운 것이 아니라 기울기를 바꾼 것이다.</strong></p>
<p>그렇다고 모델이 문장을 읽지 않는 것은 아니다. 같은 단어의 같은 문장들인데 참고 자료를 주자 문장마다 예측이 흩어졌다. <code>로더</code>의 표준편차가 0.096에서 0.217로, <code>뮤테이션</code>이 0.042에서 0.236으로 늘었다. <strong>읽기는 하는데 구별을 못 한다.</strong></p>
<p>그리고 이 결과는 앞 절의 해석을 흔들기도 한다. state 텍스트 하나로 출력이 통째로 뒤집힌다면, 첫 실행의 "전부 되돌린다"도 모델의 한계가 아니라 질문 문구의 부족일 수 있다. 이 글은 그 가능성을 배제하지 못했다.</p>
<p>한 가지는 눈에 띄게 좋아졌다. "되돌린다"와 "그대로"의 확률 합이 게이트에 없는 단어에서 1.508에서 0.963으로 붙었고, 1에서 크게 벗어난 문장이 59개에서 18개로 줄었다. <strong>그런데 정확도는 움직이지 않았다.</strong> 두 질문의 확률이 서로 맞아떨어지는 것과 그 확률이 맞는 것은 다른 일이다.</p>
<h2 id="결정-모델의-자리"><a class="anchor" href="#결정-모델의-자리">결정 모델의 자리</a></h2>
<p>여기까지 보면 이 모델 범주 전체가 쓸모없다는 결론으로 기울기 쉬운데, 공개된 벤치마크를 나란히 놓으면 그렇지 않다.</p>
<table>
<thead>
<tr>
<th>과제</th>
<th>n</th>
<th>Jev</th>
<th>비교 대상</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/bitnovus/jev-spam-eval" target="_blank" rel="noopener noreferrer">스팸, 분포 안</a></td>
<td>18,514</td>
<td>98.3%</td>
<td>TF-IDF 회귀 98.4%</td>
</tr>
<tr>
<td>스팸, 분포 밖</td>
<td>2,876</td>
<td><strong>98.6%</strong></td>
<td>TF-IDF 회귀 <strong>73.0%</strong></td>
</tr>
<tr>
<td>피싱</td>
<td>2,000</td>
<td>62.6%</td>
<td>Haiku 4.5 81.3%, 규칙 기준선 91.6%</td>
</tr>
<tr>
<td><a href="https://github.com/anessbelbati/jev-rerank-bench" target="_blank" rel="noopener noreferrer">rerank 영어 8종</a></td>
<td>1,617</td>
<td>nDCG@10 0.692</td>
<td>Cohere Rerank 4 Pro 0.691</td>
</tr>
</tbody>
</table>
<p>rerank 행만 검색 순위 품질 지표라 다른 행의 정확도와 축이 다르다.</p>
<p><strong>분포 안에서는 정규식이나 맞춰 놓은 분류기가 이기거나 비긴다.</strong> 필자의 게이트에 있는 단어에서 정규식이 1.000 대 0.225로 이긴 것과 같은 자리다. 라벨을 모을 수 있으면 작은 모델을 학습시키는 편이 더 싸고 더 빠르고 더 정확하다. 그건 예전부터 하던 일이다.</p>
<p><strong>격차는 분포가 새롭고 라벨이 없을 때 벌어진다.</strong> <span class="glossary-term-source" data-glossary-key="out-of-distribution">분포 밖</span> 스팸에서 98.6% 대 73.0%다. 맞춰 놓은 분류기는 자기가 본 적 없는 모양 앞에서 무너지는데 이쪽은 버틴다. 결정 모델의 자리는 <strong>데이터를 모으고 라벨을 붙여 학습시킬 수 없는 판단</strong>이다. 매번 상황이 새로워 라벨을 못 모으는데 판단은 초 단위로 내려야 하는 곳이다.</p>
<p>필자의 음차 판별이 왜 실패했는지도 이 틀로 설명된다. 그 판단에 필요한 것은 일반적인 추론이 아니라 <strong>한국어 기술 문서에서 어떤 말이 정착했는가라는 특정 도메인 지식</strong>이고, 영어 결정 과제로 학습한 9B 재현 구현에게 그건 분포 밖이 아니라 그냥 없는 지식이다. 규칙을 글로 줘도 안 된 이유가 여기 있다.</p>
<p>TechCrunch 기사에서 Pi harness를 만드는 Earendil의 CTO Armin Ronacher가 <a href="https://techcrunch.com/2026/09/18/a-new-kind-of-ai-model-from-a-chatgpt-inventor-is-thrilling-developers/" target="_blank" rel="noopener noreferrer">이렇게 말했다</a>.</p>
<blockquote>
<p>At the end of the day, it delegates the hallucination problem a little bit to the user.</p>
</blockquote>
<p>환각을 없앤 것이 아니라 판단을 개발자에게 넘긴 것이다. TypeSafe 문서도 calibration은 예측 묶음에 대해 성립하는 성질이지 개별 답이 맞는다는 보장이 아니라고 적고 있고, 공개 발표 글의 환각률 0% 막대 아래 Nuance 항목에는 "Our number is not empirical"이라고 적혀 있다. 형식은 보장되고 내용은 아니다.</p>
<h2 id="jevable의-194개-프로젝트"><a class="anchor" href="#jevable의-194개-프로젝트">jevable의 194개 프로젝트</a></h2>
<p>필자의 데이터에서는 실패했으니 다른 사람들은 어디에 쓰는지 봤다. <a href="https://jevable.com/" target="_blank" rel="noopener noreferrer">jevable.com</a>은 Nikunj라는 개발자가 X에 올라온 Jev 프로젝트를 심사해 모은 독립 큐레이션이다. 2026년 9월 23일 기준 194개이고 등록일은 9월 16일부터 20일 사이에 몰려 있다. 152개가 9월 18일 하루에 들어왔다. 공개 사흘 뒤다.</p>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>개수</th>
</tr>
</thead>
<tbody>
<tr>
<td>Games</td>
<td>39</td>
</tr>
<tr>
<td>Developer tools</td>
<td>34</td>
</tr>
<tr>
<td>Productivity</td>
<td>31</td>
</tr>
<tr>
<td>Agents</td>
<td>19</td>
</tr>
<tr>
<td>Experiments</td>
<td>18</td>
</tr>
<tr>
<td>Creative tools</td>
<td>16</td>
</tr>
<tr>
<td>Data &#x26; research, Finance, Browser extensions, Robotics, Marketing</td>
<td>37</td>
</tr>
</tbody>
</table>
<p>카테고리보다 "무엇을 묻는가"로 다시 묶으면 세 모양이 나온다. 아래 수치는 전부 게시자가 자기 게시물에 적은 값이다.</p>
<p><strong>첫째, 분류와 라우팅.</strong> 이메일 1,500건 분류, 세금 서류 분류, 경쟁사 광고 1,891건을 19초에 $0.12로 분류, PR diff 하나에 typed check 14개, Android 알림에서 광고 판별, 건설 도면 26장을 2.9초에 분류. 앞 절의 표가 말한 대로 라벨이 매일 쌓이는 자리라, 시간이 지나면 자기 라벨로 학습한 작은 분류기가 이기거나 비긴다. Jev의 이점은 라벨이 없는 첫날에 있다.</p>
<p><strong>둘째, 행동 선택 루프.</strong> Browser Use에 붙여 항공권 검색을 7초에 $0.0039로 끝냈다는 browser agent, 음성으로 Mac을 조작하는 computer use, Mario가 죽을 때마다 VM을 네 갈래로 fork해 살아남는 쪽을 고르는 게임, 3D 캐릭터의 입과 눈썹과 시선 등 열 가지를 메시지마다 결정하는 표정 엔진. 매 스텝 action space가 바뀌므로 라벨을 모을 수 없고, 판단은 초 아래로 내려야 한다. 분포 밖 스팸에서 격차가 벌어진 것과 같은 자리다. Games가 39개로 가장 큰 카테고리인 이유도 여기 있다고 본다. 게임은 틀려도 되돌릴 수 있는 행동 선택 루프다.</p>
<p><strong>셋째, 확률을 사용자에게 보여주는 UI.</strong> 답 대신 판정만 돌려주는 Ask Jev, 다음 질문을 확률로 고르는 분기 폼 JevForm, 기술 깊이와 드라마 같은 슬라이더 여섯 개로 Hacker News 첫 화면을 다시 정렬하는 Upweight. 임계값을 코드에 박지 않고 사람이 확률을 읽는 자리라, 이 글이 문제 삼은 calibration 요구가 가장 낮다.</p>
<p>한 가지가 눈에 띈다. 194개 설명문에서 비용을 적은 것이 30개, 속도를 적은 것이 53개인데, 정확도나 기준선을 적은 것은 7개다. 설명문이 원 게시물의 앞부분만 담으므로 하한이지만 방향은 분명하다. 빠르고 싸다는 것은 첫날 알 수 있고 맞는지는 재야 알 수 있는데, 재는 쪽이 드물다. 그 7개 중 하나가 <code>jevcal</code>이다. 다들 임계값을 감으로 고른다며, 자기 데이터와 목표 정확도를 넣으면 임계값과 자동 처리 비율을 돌려주는 도구다. 다음 절이 권하는 절차를 그대로 도구로 만든 것이다.</p>
<h2 id="선을-긋는-절차"><a class="anchor" href="#선을-긋는-절차">선을 긋는 절차</a></h2>
<p>이 글의 결론은 이것이다. <strong>모델이 돌려준 확률을 스펙처럼 읽지 말고, 자기 데이터에서 직접 재서 선을 그어야 한다.</strong></p>
<p>재는 절차는 이렇다. 라벨이 있는 100건 남짓을 모아 확률 구간별 실제 적중률 표를 만들고, 임계값을 위에서부터 낮춰 가며 그 위 구간의 오차율을 본다. 오차 예산을 정했으면 그 예산을 지키는 최대 커버리지가 곧 자동화할 수 있는 비율이다. 필자의 실험에서 확신도 0.9 이상 구간의 실제 적중이 0.240이었다는 것은 102문장으로 드러났다.</p>
<p><strong>눈금이 어긋난 것은 대개 고칠 수 있다.</strong> overconfidence가 전 구간에 고르게 깔려 있으면 온도 하나로 대부분 맞춰진다. Kev도 그렇게 해서 ECE를 0.106에서 0.042로 내렸다. 문제는 그 온도를 맞추려면 그 분포의 라벨이 필요하다는 것이고, 프로덕션에서 없는 것이 정확히 그것이다. 게다가 온도는 확률의 순서를 바꾸지 못하므로 자동화 가능 비율은 그만큼 안 올라간다. 그래서 눈금보다 순서가, ECE보다 오차 예산 커버리지가 더 실무적인 지표다.</p>
<p>문서가 예제에 쓴 0.9를 그대로 코드에 박았다면, 이 게이트는 조용히 38건을 잘못 고쳤을 것이다. 에러가 나지 않으므로 알아차리기 어려운 실패다. TypeSafe 문서도 그 예제 바로 아래에 자기 데이터로 시험해 보라고 적어 두었다. 코드에 박히는 것은 대개 그 아래 문장이 아니라 위의 숫자다.</p>
<p>키가 없어도 재는 것부터는 오늘 할 수 있다. Kev가 Apache-2.0으로 공개돼 있고 노트북에서 돈다.</p>
<p>지금 운영하는 서비스에서 큰 모델에게 예/아니오만 묻는 호출이 몇 개인지 세어 보면 좋겠다. 그 호출을 결정 모델로 옮기기 전에, 옮긴 다음 그어야 할 선이 어디인지를 먼저 재 보기를 권한다. 필자는 그 순서를 지켰기 때문에 게이트를 안 건드리기로 정할 수 있었다. Jev 키를 받으면 같은 집합을 다시 돌려 볼 생각이고, 그때 결론이 바뀌면 그것도 적겠다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2411.15124" target="_blank" rel="noopener noreferrer">Lambert et al., Tulu 3: RLVR의 명명</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/themsquared/jev-benchmark" target="_blank" rel="noopener noreferrer">themsquared/jev-benchmark, 도구 호출 위험 판정</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>calibration</category>
        </item>
        <item>
            <title><![CDATA[overlay-kit]]></title>
            <link>https://hooninedev.com/260921</link>
            <guid isPermaLink="false">https://hooninedev.com/260921</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 오버레이를 선언적으로 다루는 인터페이스에 대한 이야기를 해보려고 한다. 필자는 선언적인 코드를 좋아한다. 오버레이를 띄우고, 닫고, 그 안에서 비동기 처리를 하는 일련의 행위는 그 오버레이가 스스로 관리하는 편이 낫다고 생각한다. 호출하는 쪽이 isOpen을 들고 있다가 켜고 끄는 방식은 상태를 다루는 코드와 그 상태가 그려지는 컴포넌트를...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 오버레이를 선언적으로 다루는 인터페이스에 대한 이야기를 해보려고 한다.</p>
<p>필자는 선언적인 코드를 좋아한다. 오버레이를 띄우고, 닫고, 그 안에서 비동기 처리를 하는 일련의 행위는 그 오버레이가 스스로 관리하는 편이 낫다고 생각한다. 호출하는 쪽이 <code>isOpen</code>을 들고 있다가 켜고 끄는 방식은 상태를 다루는 코드와 그 상태가 그려지는 컴포넌트를 서로 다른 자리에 떨어뜨려 놓는다.</p>
<p>그런데 필자가 회사에서 맡은 화면의 모달은 정확히 그 반대였다. 열림 여부를 전역 스토어에 담아 두고, 호출부가 열고 호출부가 닫았다. 모달을 닫는 코드와 결과를 받는 콜백까지 호출부가 직접 쓰고 있었다. 그래서 이것을 선언적인 방향으로 어떻게 옮길지 한참 고민했고, 결국 직접 갈아엎었다.</p>
<p>작업을 하면서 <a href="https://github.com/toss/overlay-kit" target="_blank" rel="noopener noreferrer">overlay-kit</a>을 살펴보았다. 필자가 지향하는 인터페이스와 비슷했다. 이 글은 overlay-kit의 인터페이스의 선언적 인터페이스를 살펴보고, 어떻게 사내 modal 시스템을 설계했는지에 대해 기록해보겠다.</p>
<h2 id="상태로-들고-있던-모달"><a class="anchor" href="#상태로-들고-있던-모달">상태로 들고 있던 모달</a></h2>
<p>필자는 <a href="/260518" target="_blank" rel="noopener noreferrer">상태 관리</a>에 대한 글에서 모달의 열림 여부를 가장 단순한 로컬 상태의 예로 들었다. 한 컴포넌트 안에서만 쓰고 외부에서는 알 필요도 알 권리도 없는 상태라고 썼다. 그 말은 지금도 맞다고 생각한다. 다만 모달이 수십 종류가 되고, 서로 겹쳐 뜨고, 결과값을 호출부로 돌려줘야 하는 순간부터는 그 상태가 컴포넌트 안에 머물러 있지 않는다.</p>
<p>필자가 만난 코드는 열린 모달의 목록을 전역 스토어에 두고 있었다. 사내 코드라 아래 예시는 식별자를 바꿔 구조만 옮긴 것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [state.openPopup, state.closePopup]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> handleAddNote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    itemId,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onClose</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">note</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(note);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [closePopup, openPopup, itemId]);</span></span></code></pre></figure>
<p>이 코드가 불편했던 자리는 셋이다.</p>
<p><strong>첫째, 여는 함수를 훅으로 꺼내야 한다.</strong> <code>openPopup</code>과 <code>closePopup</code>은 스토어에서 선택해 와야 하는 값이라 컴포넌트 안에서만 얻을 수 있다. 그래서 모달을 여는 코드가 컴포넌트 밖으로 나가지 못한다. API 응답을 가로채는 자리나 라우터 가드처럼 훅을 쓸 수 없는 곳에서 모달을 띄우려면 그 함수를 인자로 받아 내려보내야 한다.</p>
<p><strong>둘째, 결과가 콜백으로 돌아온다.</strong> 노트를 저장했는지, 사용자가 그냥 닫았는지를 <code>onSubmit</code>과 <code>onClose</code> 두 갈래로 받는다. 모달을 띄운 뒤에 할 일이 있으면 그 일이 콜백 안으로 들어간다. 모달을 두 개 연달아 띄워야 하면 콜백 안에 콜백이 생긴다.</p>
<p><strong>셋째, 닫는 책임이 호출부에 있다.</strong> <code>closePopup(PopupType.ADD_NOTE)</code>를 호출부가 부른다. 모달이 자기 자신을 닫는 것이 아니라, 모달을 연 쪽이 그 모달의 종류를 기억하고 있다가 닫는다. 그래서 같은 상수가 한 함수 안에 세 번 나온다.</p>
<p>셋의 공통점은 모달 하나를 띄우는 데 호출부가 세 가지 코드를 써야 한다는 것이다. 여는 코드, 결과를 받는 콜백, 그리고 닫는 코드다.</p>
<p>무엇을 띄울지 정하는 데까지가 호출부의 몫이라고 보면, 그 뒤의 일까지 호출부가 떠안고 있었다. 특히 <code>closePopup(PopupType.ADD_NOTE)</code>는 닫을 대상을 문자열 상수로 지목한다. 다른 모달의 상수를 적어도 같은 열거형이라 타입 검사를 통과하고, 엉뚱한 모달이 닫힌다.</p>
<h2 id="결과를-기다리는-인터페이스"><a class="anchor" href="#결과를-기다리는-인터페이스">결과를 기다리는 인터페이스</a></h2>
<p>그러면 이 셋 중 무엇을 덜어낼 수 있을까. 필자가 도달한 답은 모달을 여는 것을 함수 호출로 두고, 그 함수가 종료 결과를 담은 Promise를 돌려주게 하는 것이었다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openModalAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD_NOTE'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { itemId });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.reason </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'confirmed'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(result.value);</span></span></code></pre></figure>
<p>바뀐 것은 세 가지다. 여는 함수가 훅이 아니라 모듈 함수가 되면서 컴포넌트 밖에서도 부를 수 있게 됐다. 결과가 콜백의 인자가 아니라 반환값이 되면서 후속 작업이 호출 함수의 흐름 안에 남았다. 그리고 닫는 일은 모달 컴포넌트가 직접 하게 됐다.</p>
<p>반환 타입이 <code>Promise&#x3C;ModalResult&#x3C;V>></code>가 되면서 모달의 종료가 값 하나로 모였다.</p>
<p>Promise는 한 번만 끝난다. 이미 끝난 Promise를 다시 resolve해도 아무 일이 일어나지 않으므로, 종료 사유를 그 값 하나에 전부 담아야 한다. 그래서 <code>reason</code>을 다섯 가지로 나눴다. 사용자가 확정했는지, 명시적으로 취소했는지, 값 없이 닫혔는지, 같은 종류의 새 모달로 교체됐는지, 전체 정리로 강제 종료됐는지다. 불리언 하나로 두면 "취소"와 "화면이 통째로 정리됨"이 같은 값이 되는데, 뒤처리가 필요한 흐름에서는 그 둘을 구분해야 했다.</p>
<p>반대로 끝내지 않으면 호출부는 영원히 기다린다. 콜백은 부르지 않아도 아무 일이 없지만 <code>await</code>는 반환되지 않은 채로 남는다. 그래서 모달을 화면에서 걷어내는 경로가 늘어날 때마다 그 경로도 Promise를 끝내는지 확인해야 했다. 교체와 강제 종료가 각자 <code>reason</code>을 하나씩 가진 것이다.</p>
<h2 id="overlay-kit의-호출부"><a class="anchor" href="#overlay-kit의-호출부">overlay-kit의 호출부</a></h2>
<p>toss overlay-kit 호출부는 이렇게 생겼다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> confirmed</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">openAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ConfirmDialog</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isOpen}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onConfirm</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p>필자가 좋은 인터페이스라고 생각한 지점은 셋이다.</p>
<p><strong>무엇이 열리는지가 호출부에 있다.</strong> 필자가 만든 쪽은 <code>'ADD_NOTE'</code>라는 문자열 key를 넘긴다. 그 key가 어느 컴포넌트인지는 registry 파일이 알고 있다. 문자열 key와 그 모달을 가져오는 <code>import()</code> 함수를 짝지어 둔 객체 하나가 들어 있는 파일이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> modalImporters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../add-note-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  CONFIRM</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../confirm-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 모달 종류만큼 이어진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>overlay-kit은 JSX를 그 자리에 쓴다. 코드를 읽는 사람이 이 파일로 이동하지 않아도 무엇이 뜨는지 안다.</p>
<p><strong>결과의 타입이 호출부에서 정해진다.</strong> <code>openAsync&#x3C;boolean></code>의 타입 인자가 곧 <code>close</code>의 인자 타입이고 <code>await</code>의 결과 타입이다. 모달마다 결과 타입을 따로 선언하고 표에 등록할 필요가 없다.</p>
<p><strong>닫는 것과 지우는 것이 나뉘어 있다.</strong> 컨트롤러가 받는 props는 <code>overlayId</code>, <code>isOpen</code>, <code>close</code>, <code>unmount</code> 넷이다. <code>close</code>는 <code>isOpen</code>을 <code>false</code>로 만들고 컴포넌트는 그대로 둔다. <code>unmount</code>가 실제로 제거한다. <a href="https://overlay-kit.slash.page/ko/docs/guides/introduction" target="_blank" rel="noopener noreferrer">공식 문서의 오버레이 열고 닫기 안내</a>는 이렇게 설명한다.</p>
<blockquote>
<p>이러한 차이가 발생하는 이유는 <code>close</code>를 사용할 때 오버레이가 닫히는 애니메이션을 보여주기 위해서 메모리에 계속 남겨두기 때문이에요.</p>
</blockquote>
<p>필자가 만든 쪽에는 이 구분이 없다. 닫으면 목록에서 바로 빠진다. 그래서 닫기 애니메이션을 라이브러리의 컴포넌트에 맡기고 있고, 그것이 가능한 화면만 다루고 있었다는 뜻이기도 하다.</p>
<h2 id="overlayopen에서-렌더까지"><a class="anchor" href="#overlayopen에서-렌더까지">overlay.open에서 렌더까지</a></h2>
<p>인터페이스가 마음에 들면 그다음 궁금한 것은 안이다. 모듈 함수 하나가 React 트리 안에 컴포넌트를 그리려면 그 사이를 잇는 무언가가 있어야 한다.</p>
<h3 id="이벤트와-리듀서"><a class="anchor" href="#이벤트와-리듀서">이벤트와 리듀서</a></h3>
<p><code>overlay.open</code>은 상태를 바꾸지 않는다. 이벤트를 하나 쏜다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlayId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> options?.overlayId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> componentKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dispatchOpenEvent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  dispatchOpenEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ controller, overlayId, componentKey });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlayId;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>받는 쪽은 <code>OverlayProvider</code>다. Provider는 <code>useReducer</code>를 들고 있고, 이벤트를 구독해서 리듀서에 <code>ADD</code>를 넣는다. 여기서 주의해서 볼 것은 <code>isOpen</code>이 <code>false</code>로 들어간다는 점이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/index.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useReducer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(overlayReducer, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  current: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayOrderList: [],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayData: {},</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">componentKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    overlay: { id: overlayId, componentKey, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, isMounted: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, controller },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, []);</span></span></code></pre></figure>
<p>그리고 컨트롤러가 마운트된 다음 프레임에 <code>OPEN</code>을 넣어 <code>isOpen</code>을 <code>true</code>로 만든다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/content-overlay-controller.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'OPEN'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, overlayId });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [overlayDispatch, overlayId]);</span></span></code></pre></figure>
<p>여기서 필자가 예상과 달랐던 부분이 있다.</p>
<p><strong>상태가 React 밖에 있지 않다.</strong> 모듈 함수로 여는 API라서 스토어도 모듈 수준에 있을 줄 알았는데, 실제로는 Provider 안의 <code>useReducer</code>다. React 밖에 있는 것은 이벤트 버스뿐이고, 그것은 상태를 들지 않는다. 오버레이는 Provider의 <code>children</code> 옆에 형제로 렌더된다. <code>createPortal</code>을 쓰지 않고, <code>react-dom</code>을 import하지도 않는다.</p>
<p>한 가지 더 눈에 띄는 것이 있다. 호출부에서 보면 <code>overlay.open()</code> 한 줄인데, 그 안은 이벤트를 쏘고, 리듀서에 <code>ADD</code>를 넣고, 한 프레임 기다렸다가 <code>OPEN</code>을 다시 넣는 순서가 정해진 절차다. 선언적인 인터페이스가 명령형 코드를 없앤 것이 아니라 경계 안쪽으로 옮긴 것이다.</p>
<h3 id="여는-쪽과-닫는-쪽"><a class="anchor" href="#여는-쪽과-닫는-쪽">여는 쪽과 닫는 쪽</a></h3>
<p>앞에서 본 <code>requestAnimationFrame</code>과, 그 앞 절에서 본 <code>close</code>와 <code>unmount</code>의 분리는 얼핏 별개로 보인다. 실제로는 같은 제약의 양쪽 끝을 각각 막는 장치다.</p>
<p>그 제약은 CSS 트랜지션이 <strong>시작 상태와 끝 상태를 둘 다 실제로 렌더해야 돈다</strong>는 것이다. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions" target="_blank" rel="noopener noreferrer">MDN의 CSS 트랜지션 문서</a>가 DOM에 막 추가한 요소에 대해 이렇게 경고한다.</p>
<blockquote>
<p>This is treated as if the initial state had never occurred and the element was always in its final state.</p>
</blockquote>
<p>여기서 실패가 두 모양으로 나온다. 열 때는 시작 상태가 없어서 안 돌고, 닫을 때는 끝 상태가 그려지기 전에 요소가 사라져서 안 돈다.</p>
<table>
<thead>
<tr>
<th></th>
<th>여는 쪽</th>
<th>닫는 쪽</th>
</tr>
</thead>
<tbody>
<tr>
<td>문제</td>
<td>시작 상태가 없다</td>
<td>끝 상태를 그리기 전에 사라진다</td>
</tr>
<tr>
<td>해법</td>
<td>닫힌 채로 먼저 마운트하고 다음 프레임에 연다</td>
<td>열린 채로 두고 <code>isOpen</code>만 끄고, 나중에 제거한다</td>
</tr>
<tr>
<td>장치</td>
<td><code>ADD(isOpen: false)</code>, rAF, <code>OPEN</code></td>
<td><code>CLOSE</code>, 애니메이션, <code>REMOVE</code></td>
</tr>
<tr>
<td>타이밍을 아는 쪽</td>
<td>라이브러리</td>
<td>사용자</td>
</tr>
</tbody>
</table>
<p>닫는 쪽이 사용자 몫인 이유가 마지막 줄에 있다. 여는 쪽은 한 프레임이면 된다는 상수를 라이브러리가 안다. 닫는 쪽은 애니메이션이 200밀리초인지 400밀리초인지 라이브러리가 알 수 없다.</p>
<h4 id="unmount로-넘긴-닫기"><a class="anchor" href="#unmount로-넘긴-닫기"><code>unmount</code>로 넘긴 닫기</a></h4>
<p>닫는 쪽이 성립하는 근거는 리듀서에 있다. <code>CLOSE</code>는 <code>overlayData</code>의 <code>isOpen</code>만 뒤집고 <code>overlayOrderList</code>는 건드리지 않는다. Provider가 그 목록을 순회해 렌더하므로, 목록에 남아 있는 동안 컴포넌트는 살아 있다. <code>REMOVE</code>가 목록에서 빼야 비로소 언마운트된다.</p>
<p><strong>다만 rAF가 항상 필요한 것은 아니다.</strong> react-transition-group은 <a href="https://reactcommunity.org/react-transition-group/transition" target="_blank" rel="noopener noreferrer">기본적으로 첫 마운트에서 enter 트랜지션을 실행하지 않는다</a>. <code>in</code>이 이미 <code>true</code>여도 건너뛰고, <code>appear</code>를 켜야 한다. overlay-kit의 rAF는 <code>isOpen</code>을 마운트 이후에 <code>false</code>에서 <code>true</code>로 바꿔 주므로 이 경우를 덮는다.</p>
<h3 id="promise로-감싸는-20줄"><a class="anchor" href="#promise로-감싸는-20줄">Promise로 감싸는 20줄</a></h3>
<p><code>openAsync</code>는 별도의 구현이 아니라 <code>open</code>을 감싼 얇은 층이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openAsync</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deprecatedLegacyContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">param</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(param);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> reject</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reason</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(reason);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlayProps, close, reject };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(props, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">deprecatedLegacyContext);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>컨트롤러에 넘어가는 <code>close</code>를 바꿔치기하는 것이 전부다. 원래 <code>close</code>는 인자를 받지 않는데, 여기서는 인자를 받아 Promise를 resolve한 뒤에 원래 <code>close</code>를 부른다. 결과 타입이 호출부에서 정해지는 이유도 여기에 있다. <code>T</code>가 <code>close</code>의 인자 타입이자 Promise의 타입이다.</p>
<p>다만 이 구조에는 구멍이 하나 있다. resolve는 <code>close</code>가 불릴 때만 일어난다. 그래서 오버레이를 <code>unmount</code>로만 제거하면 Promise는 영원히 pending으로 남는다. <a href="https://github.com/toss/overlay-kit/issues/169" target="_blank" rel="noopener noreferrer">이슈 #169</a>에 올라와 있고 해결이 필요하다고 생각하지만 아직 이슈가 오픈되어있다.</p>
<p>resolve 시점이 <code>close</code>라는 점은 또 다른 의미도 갖는다. 닫기 애니메이션이 끝난 시점이 아니라 닫기 시작하는 시점이다. 확인을 받고 API를 호출하는 흐름이라면 문제가 없지만, 확인을 받고 다음 모달을 여는 흐름이라면 앞 모달이 사라지는 동안 뒤 모달이 올라온다.</p>
<h3 id="설계가-한-바퀴-돈-자리"><a class="anchor" href="#설계가-한-바퀴-돈-자리">설계가 한 바퀴 돈 자리</a></h3>
<p>설계 과정을 탐색해보기 위해 커밋 기록을 따라가 보았다. 그리고 이 구조가 처음부터 이랬던 것은 아니라는 것을 알게 되었다. 1.0은 지금과 같은 이벤트와 리듀서 조합이었다. 1.2.0에서 상태를 모듈 수준 외부 스토어로 옮기고 <code>useSyncExternalStore</code>로 읽게 바꿨다가, 1.8.0에서 다시 리듀서로 되돌렸다.</p>
<p>되돌린 이유는 <a href="https://github.com/toss/overlay-kit/issues/148" target="_blank" rel="noopener noreferrer">이슈 #148</a>에 적혀 있다.</p>
<blockquote>
<p><code>useSyncExternalStore</code> resolves tearing issues, but it doesn't work well with suspense.</p>
</blockquote>
<p>오버레이 안에서 데이터를 가져오다 suspend하면 동기 입력에 응답하는 중에 컴포넌트가 suspend했다는 React 경고가 떴다. 외부 스토어로 옮긴 원래 목적은 훅 없이도 오버레이 상태를 읽게 하는 것이었는데, 의도대로 해결하지 못했다는 것이 당시 PR 본문에 적혀 있다. 결국 목적은 훅으로 해결됐고 저장 방식만 제자리로 왔다.</p>
<p>필자가 이 기록에서 얻은 것은 결론이 아니라 기준이다. 상태를 React 밖에 두면 호출 지점이 자유로워지는 대신 React의 스케줄링과 어긋날 여지가 생긴다. overlay-kit은 호출 지점만 밖에 두고 상태는 안에 뒀다.</p>
<h2 id="같은-id로-다시-열었을-때"><a class="anchor" href="#같은-id로-다시-열었을-때">같은 id로 다시 열었을 때</a></h2>
<p>리듀서를 읽다가 걸린 부분이 있었다. <code>ADD</code>의 맨 앞에 이런 분기가 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/reducer.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">case</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] != </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">].isOpen === </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlay</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.overlayData[action.overlay.id];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (overlay </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.isOpen) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      current: action.overlay.id,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      overlayData: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayData, [action.overlay.id]: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span></code></pre></figure>
<p><code>overlayId</code>를 지정해 연 오버레이를 <code>close</code>한 뒤 같은 id로 다시 열면 이 분기를 탄다. 그런데 반환값이 <strong>기존 <code>overlay</code> 객체</strong>를 그대로 쓴다.</p>
<p>새로 들어온 <code>action.overlay</code>의 <code>controller</code>는 어디에도 쓰이지 않는다.</p>
<p>읽은 것만으로는 확신이 서지 않아 라이브러리 저장소를 받아 테스트를 직접 붙여 봤다. 서로 다른 내용을 그리는 컴포넌트 둘을 준비하고 같은 id로 순서대로 열었다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerA</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>AAA&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerB</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>BBB&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerA, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerB, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span></code></pre></figure>
<p>결과는 이랬다. overlay-kit 1.9.0 시점의 main 브랜치에서 vitest로 돌렸다.</p>
<table>
<thead>
<tr>
<th>절차</th>
<th>렌더된 것</th>
</tr>
</thead>
<tbody>
<tr>
<td>A를 열고 <code>close</code> 후 B로 다시 열기</td>
<td><code>AAA</code>. B가 버려진다</td>
</tr>
<tr>
<td>A를 열고 <code>unmount</code> 후 B로 다시 열기</td>
<td><code>BBB</code>. 정상 교체</td>
</tr>
<tr>
<td>카운터를 1 올리고 <code>close</code> 후 다시 열기</td>
<td><code>1</code>. 컴포넌트 상태가 유지된다</td>
</tr>
</tbody>
</table>
<p>세 번째 줄은 문서가 기능으로 설명하는 동작이라 예상한 대로다. 문제는 첫 줄이다.</p>
<h3 id="무시되는-새-jsx"><a class="anchor" href="#무시되는-새-jsx">무시되는 새 JSX</a></h3>
<p>여기서 하나를 더 확인했다. 같은 리듀서의 뒷부분에는 이런 주석이 달려 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/**</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> * </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">@description</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> Brings the overlay to the front when reopened after closing without unmounting.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> */</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">overlayOrderList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayOrderList.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">item</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> item </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> action.overlay.id), action.overlay.id],</span></span></code></pre></figure>
<p>닫았다가 다시 열면 맨 앞으로 가져온다는 설명이다. 그런데 앞의 분기가 먼저 <code>return</code>하기 때문에 이 줄은 그 상황에서 실행되지 않는다. 오버레이 둘을 겹쳐 열고 뒤에 깔린 쪽을 <code>close</code>한 뒤 다시 열어 봤더니, DOM 순서가 바뀌지 않고 그대로였다.</p>
<h3 id="주석과-다른-실제-동작"><a class="anchor" href="#주석과-다른-실제-동작">주석과 다른 실제 동작</a></h3>
<p>다만 이것이 화면에서 곧바로 문제로 보이는지는 확인하지 않았다. 테스트는 DOM 순서까지만 봤고, 대부분의 다이얼로그 라이브러리가 자체 z-index나 포털을 쓰므로 DOM 순서가 곧 시각적 순서는 아니다.</p>
<p>필자가 만든 쪽은 같은 상황을 옵션으로 노출한다. 같은 종류의 모달을 다시 열 때 기존 것을 유지할지 새 props로 교체할지 호출부가 고른다. 교체를 고르면 기존 Promise는 <code>replaced</code>로 종료된다. 앞에서 결과 타입을 다섯 갈래로 나눈 이유 중 하나가 이것이다.</p>
<p><strong>여기서 두 설계의 차이가 드러난다.</strong> 필자의 방식은 모달을 데이터로 다룬다. 문자열 key와 props가 스토어에 있으니 나중에 교체하거나 갱신할 수 있다. overlay-kit은 모달을 함수로 다룬다. 컨트롤러가 클로저라 한 번 저장되면 바깥에서 그 내용을 바꿀 방법이 없다.</p>
<h2 id="호출부-jsx의-대가"><a class="anchor" href="#호출부-jsx의-대가">호출부 JSX의 대가</a></h2>
<p>그러면 호출부에 JSX를 쓰는 방식은 문자열 key 방식보다 언제나 나은가. 필자는 그렇지 않다고 생각한다. 치르는 값이 셋 있다.</p>
<p><strong>코드 분할이 기본값에서 빠진다.</strong> 호출부가 JSX를 쓴다는 것은 호출부가 그 컴포넌트를 import한다는 뜻이다. 모달이 무거운 편집 화면이고 그 버튼을 누르는 사용자가 소수라면, 그 무게가 목록 화면의 번들에 들어간다.</p>
<p>컨트롤러 안에서 <code>React.lazy</code>가 동작한다. 앞의 테스트와 같은 방식으로 재 봤더니 <code>open</code> 전에는 모듈이 로드되지 않았고, 열었을 때 <code>Suspense</code> fallback이 먼저 뜬 뒤 본문이 렌더됐다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Heavy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lazy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'./HeavyModal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}>&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Heavy</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClose</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{close} />&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>그러니 차이는 가능 여부가 아니라 기본값이다.</p>
<p>필자가 만든 쪽은 registry 파일의 값이 전부 <code>import()</code> 함수라 코드 분할이 구조상 강제된다. overlay-kit은 호출부마다 손으로 써야한다.</p>
<p><strong><code>unmount</code>를 부르는 자리를 사용자가 정해야 한다.</strong> <code>close</code>만 부르고 끝내면 오버레이 정보가 메모리에 남는다. 공식 문서도 이 점을 경고하면서, 디자인 시스템마다 어디에 <code>unmount</code>를 걸어야 하는지를 따로 안내한다. MUI와 Mantine은 전환이 끝나는 콜백이 있어서 거기에 걸면 되지만, 그런 콜백이 없는 라이브러리에서는 애니메이션 길이만큼 타이머를 두라고 안내한다. 안내 문서가 라이브러리별로 나뉘어 있다는 사실 자체가 이 결정의 비용이다.</p>
<p><strong>오버레이가 Provider 자리에서 렌더된다.</strong> 포털을 쓰지 않기 때문에 React Native에서도 같은 코드가 도는 이점이 있다. 반대로 <code>OverlayProvider</code> 아래에서 제공한 컨텍스트는 오버레이 안에서 보이지 않는다. 라우터나 폼 컨텍스트에 기대는 모달을 열 때 이 지점을 먼저 확인해야 한다.</p>
<h2 id="선언적이라는-말의-범위"><a class="anchor" href="#선언적이라는-말의-범위">선언적이라는 말의 범위</a></h2>
<p>여기까지 쓰고 나니 한 가지가 걸린다. 필자는 이 인터페이스를 선언적이라고 부르며 글을 시작했는데, <code>overlay.openAsync(...)</code>는 어느 모로 보나 함수 호출이다.</p>
<p>정의부터 놓고 보자. <a href="https://react.dev/learn/reacting-to-input-with-state" target="_blank" rel="noopener noreferrer">React 공식 문서</a>는 두 가지를 이렇게 가른다.</p>
<blockquote>
<p>In React, you don't directly manipulate the UI--meaning you don't enable, disable, show, or hide components directly. Instead, you <strong>declare what you want to show,</strong> and React figures out how to update the UI.</p>
</blockquote>
<p>같은 문서가 드는 비유가 더 선명하다. 명령형은 차에 탄 사람에게 길을 하나하나 불러 주는 것이고, 선언적인 쪽은 택시에 타서 목적지만 말하는 것이다.</p>
<p><strong>선언적인 인터페이스는 명령형 절차를 없애는 것이 아니라 경계 반대편으로 옮기고 그 경계에 이름을 붙이는 것이다.</strong> 앞에서 본 overlay-kit의 내부가 정확히 그렇다. 호출부는 한 줄인데 안에서는 이벤트를 쏘고 두 번 dispatch하는 절차가 돈다.</p>
<p>그러면 각 프로젝트가 이 단어를 어디에 쓰는지 보자. overlay-kit의 문서는 기존 방식을 명령형이라고 부른다. 그때 가리키는 것은 <code>useState</code>로 열림 여부를 들고 이벤트 핸들러에서 그 값을 뒤집는 코드다. 반대로 선언적이라고 부르는 것은 오버레이를 상태가 아니라 동작으로 다루는 쪽이다. 토스 기술 블로그의 <a href="https://toss.tech/article/frontend-declarative-code" target="_blank" rel="noopener noreferrer">선언적인 코드에 대한 글</a>은 이 단어를 더 넓게 정의한다. 선언적인 코드란 추상화 레벨이 높아진 코드이고, 오버레이를 띄우는 동작을 추상화한 훅이 그 예라는 것이다.</p>
<p>그래서 필자는 이 단어를 이렇게 좁혀 쓰기로 했다. <strong>호출부가 닫는 코드를 쓰지 않는 것.</strong> 언제 닫을지, 닫히는 동안 무엇이 남아 있을지는 오버레이 쪽이 정하고, 호출부는 무엇을 띄울지와 결과로 무엇을 받을지만 적는다.</p>
<p>이 기준으로 보면 <code>useState</code>로 <code>isOpen</code>을 들고 있는 코드와 전역 스토어에 열린 목록을 두고 호출부가 닫는 코드는 같은 편에 있다. 저장 위치만 다를 뿐 닫는 문장을 호출부가 쓴다는 점은 같기 때문이다.</p>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>필자가 회사 코드에서 고친 것은 결국 저장 위치가 아니었다. 닫는 코드를 누가 쓰는가였다.</p>
<p>전역 스토어를 쓰던 시절에도 상태는 이미 컴포넌트 밖에 있었지만, 열고 닫는 문장은 여전히 호출부에 있었다. <code>await</code> 한 줄로 바꾼 뒤에야 그 문장이 모달 안으로 들어갔다.</p>
<p>overlay-kit은 같은 자리에 도달한 뒤 한 걸음을 더 갔다. 무엇을 띄울지까지 호출부에 적게 한 것이다. 필자는 그 인터페이스가 읽기에 좋다고 생각한다. 그리고 쓰는 동안 그 한 걸음의 값이 무엇인지도 알게 됐다.</p>
<p><strong>모달을 함수로 다룰지 데이터로 다룰지의 차이였다.</strong> 호출부에 JSX를 쓰면 컨트롤러가 클로저가 되고, 클로저는 한 번 저장되면 바깥에서 내용을 바꿀 수 없다. 같은 id로 다시 열었을 때 새 JSX가 무시되는 것이 그 구조에서 나온다. 문자열 key로 다루면 모달이 스토어에 담긴 값이 되어 교체하고 갱신하고 코드 분할할 수 있지만, 호출부는 이름만 알게 된다.</p>
<p>라이브러리를 고를 때 인터페이스가 마음에 든다는 감각은 꽤 믿을 만한 신호라고 생각한다. 다만 그 감각이 정확히 무엇을 가리키는지 한 번은 말로 옮겨 봐야 한다. 옮겨 보지 않으면 마음에 든 것이 인터페이스인지 그 라이브러리를 쓰는 사람들의 평판인지 구분되지 않는다. 이 글을 읽는 독자 분들도 지금 쓰고 있는 라이브러리에 대해 한 번쯤 그 작업을 해보기를 권한다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://overlay-kit.slash.page/" target="_blank" rel="noopener noreferrer">overlay-kit 공식 문서</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/desko27/react-call" target="_blank" rel="noopener noreferrer">desko27/react-call</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>라이브러리</category>
        </item>
        <item>
            <title><![CDATA[LLM calibration과 overconfidence]]></title>
            <link>https://hooninedev.com/260917</link>
            <guid isPermaLink="false">https://hooninedev.com/260917</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 모델의 calibration과 RLHF 뒤에 나타나는 overconfidence에 대한 이야기를 해보려고 한다. 모델이 돌려주는 확률이나 확신도를 코드에서 판단 기준으로 쓰려는 개발자를 위한 글이다. 끝까지 읽으면 정확도와 calibration이 어떻게 다른지, ECE가 무엇을 재는지, 그리고 사람의 선호로 다듬은 모델의 확률이 실제 적중...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 모델의 calibration과 RLHF 뒤에 나타나는 overconfidence에 대한 이야기를 해보려고 한다. 모델이 돌려주는 확률이나 확신도를 코드에서 판단 기준으로 쓰려는 개발자를 위한 글이다. 끝까지 읽으면 정확도와 calibration이 어떻게 다른지, ECE가 무엇을 재는지, 그리고 사람의 선호로 다듬은 모델의 확률이 실제 적중률과 어떻게 어긋나는지와 그 원인이 어디까지 밝혀졌는지 설명할 수 있다.</p>
<p>필자는 TypeSafe AI가 공개한 결정 모델 Jev의 주장을 검증하면서 이 개념부터 정리해야 했다. Jev는 문장 대신 선택지마다 확률을 돌려주고, calibration을 학습 목표로 내세운다. 그 주장을 따지려면 calibration이 무엇이고 기존 모델에서 왜 어긋나는지부터 알아야 했다.</p>
<h2 id="정확도와-calibration"><a class="anchor" href="#정확도와-calibration">정확도와 calibration</a></h2>
<p>먼저 정확도와 <span class="glossary-term-source" data-glossary-key="calibration">Calibration</span>을 구분해야 한다. 정확도는 몇 퍼센트 맞히느냐이고, calibration은 자기가 몇 퍼센트 맞힐지를 아느냐다. 강수 확률 70%라고 한 날들만 모았을 때 실제로 열 번 중 일곱 번 비가 왔다면 그 예보는 calibration이 잘 된 것이다. 정확도가 높다는 뜻이 아니다. 자기 한계를 안다는 뜻이다. <strong>60%만 맞히는 모델도 스스로 60%라고 말하면 calibration은 만점이다.</strong></p>
<p>눈금이 어긋나는 방향은 둘이다. 모델이 말한 확률이 실제 적중률보다 높으면 overconfidence, 낮으면 underconfidence라고 한다. 말한 확률을 가로축에, 그 확률 구간의 실제 적중률을 세로축에 놓으면 둘이 갈리는 자리가 한눈에 보인다. 이렇게 그린 그림을 reliability diagram이라고 부른다.</p>
<p><picture><source type="image/webp" srcset="/content/260917/1.720w.webp 720w, /content/260917/1.1440w.webp 1440w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260917/1.png" alt="가로축은 모델이 말한 확률인 confidence, 세로축은 실제 적중률인 accuracy다. 점선 대각선이 perfect calibration이고, 대각선 아래로 처진 주황색 곡선이 overconfidence, 대각선 위쪽 영역이 underconfidence다" style="max-width:min(720px, 100%);height:auto;" width="1440" height="1200" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>곡선이 대각선에 붙어 있으면 말한 만큼 맞힌 것이다. 대각선 아래로 처질수록 말한 확률에 비해 덜 맞힌다.</p>
<p>그 어긋남을 재는 지표가 <span class="glossary-term-source" data-glossary-key="ece">ECE</span>(expected calibration error)다. <a href="https://arxiv.org/abs/1706.04599" target="_blank" rel="noopener noreferrer">Guo et al. 2017</a>의 정의를 따르면 확률 구간마다 "말한 확률"과 "실제 적중률"의 차이를 그 구간의 표본 비율로 가중해 평균한 값이고, 0이 완벽이다. 아래는 그 식을 옮긴 코드다. 10문제 중 6문제를 맞히는 모델이 매번 0.6이라고 말할 때와 매번 0.95라고 말할 때를 비교한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Guo et al. 2017 의 식 (3). 구간은 등간격 10개(논문 실험은 15개), 경계는 왼쪽 닫힘</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">preds</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bins</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ n: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, hit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, conf: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">correct</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins[Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> correct </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> p</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">abs</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 10문제 중 6문제를 맞힌다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> answers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ correct: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.6 이라고 말할 때</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.95</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.95 라고 말할 때</span></span></code></pre></figure>
<p>2026-10-08에 Node v24.16.0으로 돌린 결과다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>0.000</span></span>
<span data-line=""><span>0.350</span></span></code></pre></figure>
<p>정확도는 둘 다 0.6이다. 말한 확률만 바뀌었는데 ECE는 0에서 0.35로 올라간다. 그런데 식에 절댓값이 들어 있어서 ECE는 어긋남의 크기만 재고 방향은 모른다. 매번 0.25라고 말하면서 6문제를 맞혀도 ECE는 똑같이 0.35다. overconfidence인지 underconfidence인지는 reliability diagram이나 평균 확신도에서 정확도를 뺀 값을 봐야 안다.</p>
<h2 id="rlhf의-목표-함수"><a class="anchor" href="#rlhf의-목표-함수">RLHF의 목표 함수</a></h2>
<p>그렇다면 사람의 피드백으로 다듬은 모델은 왜 이 눈금이 어긋날까. 먼저 <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span>(reinforcement learning from human feedback)가 무엇을 최적화하는지 봐야 한다. 사람의 선호 비교로 보상 모델을 세우는 골격은 <a href="https://arxiv.org/abs/1706.03741" target="_blank" rel="noopener noreferrer">Christiano et al. 2017</a>에서 나왔고, 이것을 언어 모델에 옮긴 초기 작업이 <a href="https://arxiv.org/abs/1909.08593" target="_blank" rel="noopener noreferrer">Ziegler et al. 2019</a>이며, <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT</a>가 지시 따르기에 적용했다. 이때 끌어올리는 값은 <strong>사람의 선호를 흉내 낸 보상 모델의 점수</strong>다. 언어 모델 쪽 작업은 강화학습을 시작하기 전 모델에서 너무 멀어지지 않도록 KL penalty를 보상에 더하고, InstructGPT는 사전학습 데이터의 기울기도 섞었지만(PPO-ptx), 끌어올리는 대상은 여전히 그 점수다.</p>
<p>챗봇에는 사람의 선호가 맞는 목표다. 문제는 그 선호가 불확실성을 드러낸 말투를 피하는 쪽으로 기울어 있다는 것이다. <a href="https://arxiv.org/abs/2401.06730" target="_blank" rel="noopener noreferrer">Zhou et al. 2024</a>는 네 개의 공개 선호 데이터셋에서 주석자가 "I'm not sure, maybe" 같은 약화 표현이 든 답을 덜 고른다는 것을 보였다. 차이는 작지만 유의했다. 다만 확신을 강조한 표현이 든 답을 더 고르지는 않았다. <a href="https://arxiv.org/abs/2410.09724" target="_blank" rel="noopener noreferrer">Leng et al. 2025</a>는 보상 모델이 답의 실제 품질과 상관없이 높은 확신 점수를 적은 답에 점수를 더 준다는 것을 보였다. 둘 다 토큰 확률이 아니라 문장 속 확신 표현(verbalized confidence)에 대한 결과다. RLHF를 대신할 학습법을 내놓은 TypeSafe도 자사의 <a href="https://docs.typesafe.ai/introduction/machine-learning-primer" target="_blank" rel="noopener noreferrer">입문 문서</a>에서 RLHF가 자신 있게 들리는 환각(confident-sounding hallucinations)을 보상할 수 있다고 적는다. 대안을 파는 쪽의 입장이다.</p>
<h2 id="gpt-4의-calibration-곡선"><a class="anchor" href="#gpt-4의-calibration-곡선">GPT-4의 calibration 곡선</a></h2>
<p>토큰 확률에서도 같은 방향의 어긋남이 관측됐다. <a href="https://arxiv.org/pdf/2303.08774v6#page=12" target="_blank" rel="noopener noreferrer">GPT-4 기술 보고서</a> 본문의 Figure 8은 사전학습 모델과 post-training 모델의 reliability diagram을 나란히 놓는데, 캡션이 이렇다.</p>
<blockquote>
<p>Right: Calibration plot of the post-trained GPT-4 model on the same subset of MMLU. The post-training hurts calibration significantly.</p>
</blockquote>
<p>가로축은 MMLU 객관식 문제의 보기 A, B, C, D 각각에 모델이 매긴 확률(logprob)이고, 세로축은 그 구간의 실제 적중률이다. Figure 8에 찍힌 숫자로는 사전학습 모델의 ECE가 <strong>0.007</strong>이고 PPO(RLHF에서 보상 모델의 점수를 높이는 쪽으로 모델을 갱신할 때 쓰는 강화학습 알고리즘)를 거친 모델이 <strong>0.074</strong>다. 열 배 넘게 나빠졌다.</p>
<p>Figure 8의 오른쪽(PPO) 패널에서 0.4 이상 구간의 막대는 대각선 아래에 있지만 0.1에서 0.3 사이 구간은 오히려 대각선 위다. 0.1에서 0.9 사이 막대는 실제 적중률 약 0.3에서 0.5에 평평하게 몰려 있어서, 모델이 말하는 확률이 올라가도 적중률이 거의 따라 오르지 않는다. 0.074에는 이 underconfidence 구간도 섞여 있다. 그리고 Guo et al.이 지적했듯이 reliability diagram은 구간마다 표본이 몇 개인지 보여주지 않으므로, 막대가 대각선에서 떨어진 거리와 그 구간이 ECE에 보탠 몫은 비례한다고 볼 수 없다.</p>
<p>이 패널은 위 개념도와 두 가지가 다르다. 먼저 개념도의 곡선처럼 대각선 아래로만 처지지 않고, 대각선을 한 번 가로지른다. 또 보기 네 개의 확률을 전부 구간에 넣었으므로, 위 코드처럼 고른 답 하나의 확률로 재는 정의와도 다르다. 그래서 0.074와 위 코드의 0.35를 같은 눈금에 놓고 비교하지는 않는다.</p>
<p>그러면 왜 나빠졌을까. 보고서 본문은 사전학습 모델이 잘 calibration되어 있었고 post-training 뒤에 calibration이 줄었다고만 적고, 원인은 말하지 않는다. 단서는 Anthropic의 <a href="https://arxiv.org/abs/2207.05221" target="_blank" rel="noopener noreferrer">Kadavath et al. 2022</a>에 있다. 이들이 자기 언어 모델로 학습한 RLHF 정책은 겉보기에 calibration이 매우 나빴다. 논문은 이것이 놀랍지 않다며, RL fine-tuning이 보상을 가장 많이 받는 행동 쪽으로 예측을 몰아주는 경향이 있기 때문이라고 적었다. 본론이 아니라 짧게 덧붙인 실험이다. 그런데 모든 평가에 같은 온도 T=2.5 하나를 적용하자 세 평가에서 calibration 문제가 대부분 풀렸다. 온도는 logit을 나누는 값이고, 1보다 크면 답의 순서는 그대로 둔 채 확률 분포만 평평하게 편다. 필자는 값 하나로 대부분 풀렸다는 결과를, 어긋남이 일부 구간에 몰린 것이 아니라 분포 전체가 한쪽으로 좁혀진 모양이라는 뜻으로 읽는다. 논문은 더 강한 RL 학습은 이렇게 고칠 수 없는 방식으로 calibration을 망가뜨릴 수 있다는 단서도 함께 달았다.</p>
<p>이것은 Anthropic 모델의 결과라서 Figure 8의 원인으로 바로 옮길 수는 없다. 앞 절의 연구들도 문장 속 확신 표현을 다뤘다. 사람이 불확실한 말투를 피한다는 결과와 토큰 확률의 어긋남을 직접 이어 주는 1차 근거는 필자가 찾지 못했다.</p>
<h2 id="코드가-받는-확률"><a class="anchor" href="#코드가-받는-확률">코드가 받는 확률</a></h2>
<p>Figure 8의 확률은 토큰의 로그 확률이다. 같은 종류의 값을 받으려면 <a href="https://github.com/openai/openai-openapi/blob/506aff0a8099581b50e119b87f8f2692cdad043f/openapi.yaml" target="_blank" rel="noopener noreferrer">OpenAI API 명세</a>대로 Chat Completions 요청에 <code>logprobs</code>를 <code>true</code>로 주고, <code>top_logprobs</code>로 토큰 위치마다 받을 후보 수를 0에서 20 사이로 정한다. 모델에게 답과 함께 확신도를 숫자로 말하게 해서 받는 값은 문장으로 말한 확신이고, 토큰 확률과는 다른 값이다.</p>
<p>두 값을 다룬 연구는 서로 다른 짝을 비교한다. <a href="https://arxiv.org/abs/2305.14975" target="_blank" rel="noopener noreferrer">Tian et al. 2023</a>은 ChatGPT, GPT-4, Claude 같은 RLHF 모델 안에서 문장으로 말한 확신이 조건부 확률보다 대체로 calibration이 좋았고, 세 벤치마크에서 ECE를 상대적으로 50% 가량 줄인 경우가 많았다고 보고했다. 다만 이 비교에서 가중치가 공개되지 않은 모델의 조건부 확률은 API의 토큰 확률이 아니라 같은 질문을 10번 샘플링해 답이 나온 비율로 추정한 값이다. 앞서 본 Leng et al.은 RLHF 이전 모델과 견주어 RLHF 모델이 문장으로 말한 확신에서 더 overconfident하다고 보고했다. 두 결과는 함께 성립할 수 있다. 그리고 0.074는 2023년 GPT-4 post-training 모델이 MMLU 일부에서 낸 값이라, 지금 API로 부르는 모델에 그대로 옮길 수 없다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, calibration은 몇 퍼센트 맞히느냐가 아니라 자기가 몇 퍼센트 맞힐지 아느냐의 문제이고, ECE는 그 어긋남의 크기를 재는 숫자다. 방향은 reliability diagram이나 평균 확신도에서 정확도를 뺀 값을 봐야 안다. RLHF는 사람의 선호를 흉내 낸 보상 모델의 점수를 끌어올린다. Kadavath et al.은 그 최적화가 보상을 많이 받는 쪽으로 예측을 좁히는 경향이 있다고 본다. 사람은 불확실성을 드러낸 답을 덜 고르고, 보상 모델은 품질과 상관없이 높은 확신 점수를 적은 답에 점수를 더 준다는 연구가 있다. GPT-4에서는 post-training 뒤 ECE가 0.007에서 0.074로 찍혔지만, OpenAI는 그 원인까지 적지는 않았다. 코드가 받는 확률이 토큰 확률인지 문장으로 말한 확신인지에 따라 calibration도 다르게 나오므로, 어느 쪽이든 자기 데이터에서 다시 재야 한다.</p>
<p>Calibration을 학습 목표로 내세운 결정 모델 Jev의 확률이 실제로 정직한지는 <a href="/260922" target="_blank" rel="noopener noreferrer">결정 모델, Jev와 Kev</a>에서 다룬다.</p>
<p>이 글을 읽는 독자 분들도 모델이 돌려준 확률을 코드에 임계값으로 박기 전에, 그 모델이 무엇을 목표로 학습됐고 자기 데이터에서 눈금이 맞는지 한 번쯤 재 보시길 바란다.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>calibration</category>
        </item>
        <item>
            <title><![CDATA[공유 언어]]></title>
            <link>https://hooninedev.com/260723</link>
            <guid isPermaLink="false">https://hooninedev.com/260723</guid>
            <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 타직군과의 소통에 대한 이야기를 해보려고 한다. 개발을 하면서 필자가 코드를 작성하는만큼 시간을 많이 할애하는 것은 무엇을 만들어야 하는지 알아내는 일이다. 기획자와 요구사항을 주고받고, 디자이너와 화면을 맞추고, 도메인을 아는 사람과 용어에 대해 무엇을 뜻하는지 묻는다. 그리고 이 과정이 어긋났을 때의 비용이 코드를 잘못 쓴 비용보다 훨...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 타직군과의 소통에 대한 이야기를 해보려고 한다.</p>
<p>개발을 하면서 필자가 코드를 작성하는만큼 시간을 많이 할애하는 것은 무엇을 만들어야 하는지 알아내는 일이다. 기획자와 요구사항을 주고받고, 디자이너와 화면을 맞추고, 도메인을 아는 사람과 용어에 대해 무엇을 뜻하는지 묻는다. 그리고 이 과정이 어긋났을 때의 비용이 코드를 잘못 쓴 비용보다 훨씬 크다는 것을 몇 번 겪었다.</p>
<p>최근에 James Samuel이 쓴 <a href="https://softwareleads.substack.com/p/engineering-leaders-day-to-day-activities" target="_blank" rel="noopener noreferrer">엔지니어링 리더의 일상 활동</a>이라는 글을 읽었다. 저자는 리더의 업무를 여섯 가지로 나누는데, 가장 먼저 다루는 것이 정보 수집이었다. 모든 결정과 방향과 행동이 지금 무슨 일이 벌어지고 있는지에 대한 정확한 이해에 의존한다는 이유에서다.</p>
<p><picture><source type="image/webp" srcset="/content/260723/1.720w.webp 720w, /content/260723/1.1440w.webp 1440w, /content/260723/1.1951w.webp 1951w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/1.png" alt="관리자 활동을 여섯 개의 원으로 나타낸 그림. 정보 수집, 계획, 리딩, 의사결정 영향, 의사결정, 정보 공유가 둘러 이어진다" width="1951" height="1673" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>필자는 언젠가 리더 역할을 하고 싶다는 생각을 갖고 있는데, 이 대목을 읽고 나서 든 생각은 조금 달랐다. <strong>리더가 가장 먼저 다루는 일이 정보 수집이라면, 실무자에게 그 대응물은 요구사항 파악이다.</strong> 지금 요구사항을 다루는 방식이 나중에 조직의 정보를 다루는 방식이 될 것이다.</p>
<p>이 글에서 리더십에 관한 이야기를 하려는 것은 아니다. 지금 실무자로서 타직군과 소통할 때 무엇을 잘못하고 있었고 무엇을 도구로 쓸 수 있는지에 대한 이야기다. 결론부터 말하면 필자는 이 문제가 태도만의 문제가 아니라고 생각하게 됐다. 그 대신 쓸 수 있는 표현으로 공유 언어(ubiquitous language)라는 내용이 있는데, 무엇인지 알아보자.</p>
<h2 id="같은-단어-다른-뜻"><a class="anchor" href="#같은-단어-다른-뜻">같은 단어, 다른 뜻</a></h2>
<p>Martin Fowler는 <a href="https://martinfowler.com/bliki/BoundedContext.html" target="_blank" rel="noopener noreferrer">Bounded Context</a> 글에서 전기 회사 사례를 든다. 필자가 겪은 어긋남의 상당 부분은 이 형태였다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>여기서 'meter'라는 단어는 조직의 서로 다른 부분에서 미묘하게 다른 뜻이었다. 전력망과 한 장소의 연결인지, 전력망과 한 고객의 연결인지, 아니면 물리적 계량기 자체인지.</p></div><div class="quote-original" lang="en"><p>here the word 'meter' meant subtly different things to different parts of the organization: was it the connection between the grid and a location, the grid and a customer, the physical meter itself</p></div></blockquote>
<p>같은 "meter"라는 단어가 조직의 부서마다 미묘하게 다른 것을 뜻했다는 것이다. 그리고 Fowler는 이런 혼란이 특정 사례의 우연이 아니라고 덧붙인다. Customer나 Product 같은 다의어에서 같은 혼란이 계속 반복되는 것을 봐왔다는 것이다.</p>
<p><strong>이건 회의 참가자들이 회의를 잘 못해서 생긴 일이 아니었다.</strong> 조직에 서로 다른 맥락이 있으면 같은 단어가 갈라지는 것이 자연스럽고, 그것을 방치하면 코드까지 갈라진다.</p>
<p>이 이야기의 기술적 배경에 대해서는 <a href="/260418" target="_blank" rel="noopener noreferrer">도메인 모델</a>에서 한 번 다뤘다. 그때는 코드 안에서 모델을 어떻게 표현할지를 이야기했다. 이 글에서는 그 모델이 만들어지기 전, 사람들 사이의 대화 단계를 다룬다.</p>
<h2 id="두가지-의미의-단어"><a class="anchor" href="#두가지-의미의-단어">두가지 의미의 단어</a></h2>
<p>그런데 왜 이런 일이 구조적으로 반복되는 걸까. 두 갈래의 설명이 있다.</p>
<p>하나는 조직 구조 쪽이다. Mel Conway가 1968년 Datamation에 실은 논문 <a href="https://melconway.com/Home/Committees_Paper.html" target="_blank" rel="noopener noreferrer">How Do Committees Invent?</a>는, 조직이 자신의 커뮤니케이션 구조를 유연하게 바꾸지 못하는 만큼 그 조직은 자기 이미지를 만들어내는 모든 설계물에 찍어낸다고 말한다. 흔히 Conway의 법칙으로 인용되는 한 문장은 사실 저자가 훗날 스스로 정식화한 표현인데, 1968년 본문의 이 서술이 더 직접적이라고 느껴진다.</p>
<p>필자가 이 문장에서 실무적으로 읽어낸 부분은 "찍어낸다"는 표현이다. 용어가 갈라지는 것이 대화에서 끝나지 않고 <strong>산출물에 그대로 남는다</strong>는 얘기이기 때문이다. 앞의 "meter"이 그렇다. "meter"의 범위를 서로 다르게 이해한 채로 각자 만들면, 그 차이는 회의록이 아니라 테이블 이름과 API 응답 필드와 화면의 문구에 남는다. 그리고 그때부터는 고치는 비용이 대화를 한 번 더 하는 비용과 비교가 안 되게 커진다.</p>
<p>여기서 실무자가 할 수 있는 일과 할 수 없는 일이 갈린다. 조직의 커뮤니케이션 구조를 바꾸는 것은 실무자의 손을 벗어난다. 그런데 <strong>용어의 경계를 눈에 보이게 만드는 것</strong>은 지금 자리에서 할 수 있다. 이 문서에서 말하는 단어의 뜻이 어느 범위인지 적어두고, 다른 팀이 쓰는 단어와 다르다면 다르다고 명시하는 일이다. 뒤에서 볼 도구들이 전부 그 일을 쉽게 만들어주는 장치다.</p>
<p>다른 하나는 조금 더 근본적이다. 인지과학 쪽에서 보면 협업은 애초에 공유된 배경 위에서만 성립한다. Herbert Clark과 Susan Brennan이 1991년에 쓴 <a href="https://web.stanford.edu/~clark/1990s/Clark,%20H.H.%20_%20Brennan,%20S.E.%20_Grounding%20in%20communication_%201991.pdf" target="_blank" rel="noopener noreferrer">Grounding in Communication</a>은 피아노 듀엣을 예로 들며 시작한다. 두 연주자는 방대한 양의 공유 정보, 즉 common ground를 가정하지 않고는 내용을 맞추는 일조차 시작할 수 없다는 것이다. 여기서 common ground는 상호 지식과 상호 신념, 상호 가정을 뜻한다. 그리고 저자들은 모든 집단 행동이 common ground와 그것의 축적 위에 세워진다고 못 박는다.</p>
<p>필자가 이 두 갈래를 나란히 놓고 본 뒤에 얻은 관점은 이렇다. <strong>용어가 어긋나는 것은 사고가 아니라 기본 상태다.</strong> 맞춰져 있는 상태가 오히려 유지 비용을 들여야 하는 예외다. 그러면 그 비용을 누가 어떻게 지불할 것인가가 실무의 질문이 된다.</p>
<h2 id="적극적인-감시"><a class="anchor" href="#적극적인-감시">적극적인 감시</a></h2>
<p>Evans는 2003년에 낸 Domain-Driven Design에서 ubiquitous language, 우리말로 옮기면 편재하는 언어 혹은 <strong>공유 언어</strong>라는 패턴을 제시한다. 흔히 "개발자와 도메인 전문가가 같은 용어를 쓰자"는 정도로 요약되지만, 원서를 읽어보면 처방이 훨씬 구체적이다. 모델을 언어의 뼈대로 삼고, 팀 내 모든 커뮤니케이션과 코드에서 그 언어를 끈질기게 쓰기로 팀이 약속하라고 요구한다. 그리고 그다음에 이어지는 문장이 필자에게 가장 중요했다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>도메인 전문가는 도메인 이해를 전달하기에 어색하거나 부적절한 용어 또는 구조에 이의를 제기하고, 개발자는 설계에 문제를 일으킬 모호함이나 비일관성을 살핀다.</p></div><div class="quote-original" lang="en"><p>Domain experts object to terms or structures that are awkward or inadequate to convey domain understanding, while developers watch for ambiguity or inconsistency that will trip up design.</p></div></blockquote>
<p>도메인 전문가는 도메인 이해를 전달하기에 어색하거나 부족한 용어와 구조에 이의를 제기하고, <strong>개발자는 설계를 넘어뜨릴 모호함이나 비일관성을 감시한다</strong>는 것이다.</p>
<p>이 문장을 읽고 필자는 자기 역할에 대한 이해를 고쳤다. 그동안 필자는 요구사항 회의에서 스스로를 수신자로 여겼다. 기획이 정해지면 그것을 정확히 받아서 구현하는 것이 필자의 일이라고 생각했다. 그런데 Evans가 규정한 개발자의 몫은 수신이 아니었다. <strong>모호함을 발견해서 되돌려주는 것이다.</strong> 이건 소극적 협조가 아니라 적극적 감시다.</p>
<p>(참고로 Fowler는 자신의 글에서 이 문장을 "Domain experts <strong>should</strong> object ... developers <strong>should</strong> watch" 로 옮겼다. 원서에는 조동사가 없다. 사소한 차이지만 원서 쪽이 더 단정적으로 읽힌다. 그렇게 하는 것이 좋다는 권고가 아니라, 그렇게 하는 것이 이 패턴의 정의라는 어조다.)</p>
<p>그렇다면 모호함은 어떻게 발견할까. Evans는 여기에도 답을 준다. 대화에서 반복해서 쓰면 용어 해석의 차이가 드러난다는 것이다. 필자는 이 문장을 이렇게 읽었다. 모호함은 문서를 정독해서 찾는 게 아니다. 기획서를 아무리 꼼꼼히 읽어도 "meter"이라는 단어가 여러 가지를 뜻한다는 사실은 드러나지 않는다. 그 단어를 실제 사례에 대고 반복해서 써봐야 갈라진다. "다른 분들은 meter 를 어떤 의미로 이해하고 계신가요?" 같은 질문이 나오는 순간이 그 지점이다.</p>
<h2 id="최소-협력-노력의-원리"><a class="anchor" href="#최소-협력-노력의-원리">최소 협력 노력의 원리</a></h2>
<p>우리는 이 방법을 알고도 실천하지 못한 시기가 있을것이다. 그럼 왜 반복해서 물어보지 않았을까?</p>
<p>다시 Clark과 Brennan의 논문으로 돌아가보자. 저자들은 말한 것을 common ground의 일부로 만들어가는 과정에 grounding이라는 이름을 붙인다. 그리고 사람들이 이 과정에서 어떻게 행동하는지에 대해 원리 하나를 제시한다. 최소 협력 노력의 원리(the principle of least collaborative effort)다. 사람은 필요한 것보다 더 열심히 일하기를 좋아하지 않는다는 관찰에서 나온 원리다.</p>
<p>여기서 중요한 대목이 나온다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>최소 협력 노력의 원리에 따르면, 사람들은 필요한 만큼의 공동 노력만 들여 서로의 이해를 맞추려 해야 한다. 그러나 어떤 일에 노력이 드는지는 소통 매체에 따라 크게 달라진다.</p></div><div class="quote-original" lang="en"><p>By the principle of least collaborative effort, people should try to ground with as little combined effort as needed. But what takes effort changes dramatically with the communication medium.</p></div></blockquote>
<p><strong>노력이 드는 지점이 매체에 따라 극적으로 달라진다는 것이다.</strong> 어떤 매체에서 쓸 수 있는 확인 기법이 다른 매체에서는 아예 불가능하거나, 가능하더라도 비용이 훨씬 크다. 저자들은 특히 말하는 즉시 상대가 받지 못하는 매체에서는 남이 오해를 고쳐주기를 기대하는 비용이 매우 커지므로, 말하는 사람이 그걸 피하려 든다고 지적한다.</p>
<p>여기서 한 가지 짚고 갈 것이 있다. 이 논문은 1991년에 쓰였고, 저자들이 비교한 매체는 대면 대화와 전화, 편지, 자동응답기 같은 것들이다. <strong>슬랙이나 노션을 다루지 않는다.</strong> 매체에 따라 grounding 비용이 달라진다는 틀은 저자들의 것이고, 그것을 지금의 비동기 업무 채널에 적용하는 것은 필자의 해석이다.</p>
<p>그렇게 적용해서 보면 필자가 왜 반복해서 묻지 않았는지가 설명된다. 기획서를 텍스트로 받아서 텍스트로 확인하는 환경에서, 애매한 지점마다 확인 메시지를 보내는 일은 비용이 크다. 답이 언제 올지 모르고, 여러 번 물으면 이해하지 못한 것처럼 보일까 걱정된다. 그러니 사람은 자연스럽게 자기 해석으로 넘어간다.</p>
<p>여기서 힘이 두 개라는 점을 구분해둘 필요가 있다. 하나는 매체가 확인 비용을 올린다는 것이고, 이건 Clark과 Brennan의 얘기다. 다른 하나는 여러 번 물으면 무능해 보일까 걱정하는 것인데, 이건 그들의 원리가 예측하는 바가 아니라 뒤에 나올 심리적 안전감 쪽 이야기다. 정확히 말하면 최소 협력 노력의 원리가 예측하는 것은 <strong>더 싼 확인 방법을 고른다</strong>이지 확인을 그만둔다가 아니다. 확인을 접고 추측으로 넘어가는 것은 원리의 작동이 아니라 grounding의 실패다. 그리고 그 해석이 틀렸다는 사실은 구현이 끝난 뒤에 드러난다.</p>
<h2 id="충분한-수준까지의-도달"><a class="anchor" href="#충분한-수준까지의-도달">충분한 수준까지의 도달</a></h2>
<p>그러면 애매한 것마다 다 물어봐야 하는 걸까. 그건 현실적이지 않고, 필자는 그렇게 하는 사람도 본 적이 없다. 이 질문에 대해서도 논문이 기준을 제시한다. grounding criterion이다. 상대가 말한 사람의 뜻을 <strong>현재 목적에 충분한 수준까지</strong> 이해했다고 양쪽이 서로 믿는 상태를 가리킨다. 저자들은 그 앞에 완벽한 이해는 애초에 불가능하다는 문장을 덧붙여 둔다.</p>
<p>필자에게 이 기준이 실용적으로 느껴진 이유는 목표를 낮춰주기 때문이다. 요구사항을 완벽하게 이해할 필요는 없다. <strong>지금 하려는 일에 충분한 만큼 서로 믿을 수 있으면 된다.</strong> 그리고 저자들은 목적이 바뀌면 기준도 바뀌어야 한다고 말한다.</p>
<p>이 부분이 실무에서는 확인의 강도를 조절하는 기준이 된다. 아직 방향을 탐색하는 자리에서 "여기서 meter은 어떤 의미로 해석되나요?"를 붙잡고 늘어지면 대화가 진도를 못 나간다. 그 단계에서 필요한 것은 무엇을 왜 하려는지에 대한 합의이지 경계값의 확정이 아니다. 반대로 구현에 들어가기 직전에는 그 질문이 반드시 나와야 한다. 이때는 "meter"이 어느 범위인지 서로 같은 것을 떠올리고 있다고 믿을 수 있어야 하고, 그렇지 않으면 잘못된 전제 위에 코드가 쌓인다.</p>
<p>그래서 필자는 같은 애매함을 만나도 <strong>지금이 어느 단계인지에 따라 다르게 처리한다.</strong> 탐색 단계에서는 목록에 적어두고 넘어가고, 구현 직전에는 그 목록을 열어 하나씩 닫는다. 확인을 미루는 것과 확인을 포기하는 것은 다른 일이다.</p>
<p>이 지점에서 함께 쓰기 좋은 도구가 하나 있다. Matthew Skelton과 Manuel Pais의 Team Topologies는 팀 사이의 상호작용을 세 모드로 구분한다. 정해진 기간 동안 함께 새로운 것을 발견하는 collaboration, 한쪽이 제공하고 한쪽이 소비하는 X-as-a-Service, 한쪽이 다른 쪽을 도와주고 멘토링하는 facilitation이다.</p>
<p>원래는 조직 설계 이야기지만, 필자는 이걸 회의 단위로 축소해서 쓰는 게 실용적이라고 느꼈다. <strong>지금 이 대화가 어떤 모드인지 서로 다르게 알고 있으면 회의가 어긋난다.</strong> 기획자는 이미 정해진 것을 전달하는 자리라고 생각하는데 개발자는 함께 발견하는 자리라고 생각하면, 개발자의 질문은 협업이 아니라 딴지로 들린다. 반대 경우도 있다. 개발자가 확정된 스펙을 받으러 왔는데 기획자는 아직 탐색 중이었다면, 개발자는 스펙이 없다고 답답해한다.</p>
<p>그래서 필자는 요즘 회의 초반에 이걸 먼저 확인한다. "이건 아직 열려 있는 건가요, 아니면 정해진 걸 확인하는 자리인가요?" 이 질문 하나가 그 뒤의 대화 성격을 바꾼다. grounding criterion을 목적에 맞춰 조정하는 일과 같은 얘기다.</p>
<h2 id="해상도가-맞지-않으면-대화가-겉돈다"><a class="anchor" href="#해상도가-맞지-않으면-대화가-겉돈다">해상도가 맞지 않으면 대화가 겉돈다</a></h2>
<p>모드를 맞췄다고 해도 남는 문제가 있다. 필자가 자주 겪은 또 다른 어긋남은 서로 다른 <strong>해상도</strong>로 말하고 있는 상황이었다.</p>
<p>기획자가 문장으로 준 요구사항은 대체로 너무 추상적이다. "사용자가 예약 내역을 편하게 확인할 수 있게" 같은 문장에서는 화면이 몇 개인지, 어디서 어디로 넘어가는지가 나오지 않는다. 반대로 디자이너가 준 시안은 너무 구체적이다. 버튼의 색과 여백까지 정해져 있어서, 정작 "이 흐름이 맞는가"를 논의하기 어렵다.</p>
<p>Basecamp의 Ryan Singer가 쓴 Shape Up이 이 문제를 정확히 짚는다. 와이어프레임이나 구체적인 시각 레이아웃부터 시작하면 불필요한 디테일에 갇혀서 필요한 만큼 넓게 탐색할 수 없게 된다는 것이다. 그래서 <a href="https://basecamp.com/shapeup/1.3-chapter-04" target="_blank" rel="noopener noreferrer">Shape Up이 제안하는 것</a>은 그 사이의 표현이다. breadboarding이라고 부르는데, 전기공학에서 개념을 빌려왔다. 브레드보드는 실제 기기의 부품과 배선은 다 갖췄지만 산업 디자인은 없는 시제품이다. 그래서 그리는 것도 딱 세 가지다. 넘어갈 수 있는 장소(places), 사용자가 조작할 수 있는 것(affordances), 그리고 그 조작이 사용자를 어디로 데려가는지 보여주는 연결선(connection lines)이다.</p>
<p>필자가 이 기법을 좋게 본 이유는, 개발자가 만들 수 있는 산출물이기 때문이다. 기획자에게 더 자세히 써 달라고 요청하거나 디자이너에게 시안을 기다리지 않고, 지금 이해한 흐름을 그 자리에서 그려서 "이렇게 이해했는데 맞나요" 라고 되돌려줄 수 있다. Evans가 말한 감시(모호함을 되돌려주기)를 실행하는 구체적 방법인 셈이다. 그리고 앞 절의 관점에서 보면, 이건 grounding 비용을 낮추는 장치다. 그림 한 장이 텍스트로 주고받는 확인 여러 번을 대신한다.</p>
<p>그런데 이 해상도는 유지하기가 생각보다 어렵다. 흐름을 그리다 보면 자연스럽게 "여기 버튼은 오른쪽 아래에 두는 게 낫지 않나" 같은 이야기로 내려가게 된다. 필자도 몇 번 그랬다. 그렇게 한 번 내려가면 그 자리에서 논의되던 질문, 그러니까 이 흐름이 맞는가는 조용히 사라진다. 아직 흐름이 확정되지 않았는데 버튼 위치를 정하고 있으면, 나중에 흐름이 바뀔 때 그 논의는 통째로 버려진다.</p>
<p>그래서 필자는 이 단계에서 <strong>일부러 못 그린 그림</strong>을 유지하려고 한다. 네모와 화살표와 이름뿐인 그림이면 상대도 디테일을 지적할 생각을 하지 않고 흐름에만 반응한다. 그림의 완성도가 낮은 것이 이 도구에서는 기능인 셈이다. (예쁘게 그리고 싶은 마음을 누르는 게 생각보다 어렵다)</p>
<p>추상화 수준이라는 개념 자체가 협업의 도구가 될 수 있다는 점은 <a href="/260201" target="_blank" rel="noopener noreferrer">추상화</a>에서 다뤘던 이야기와도 이어진다. 그때는 코드의 추상화 수준을 말했는데, 대화에도 같은 것이 있다.</p>
<h2 id="규칙과-예시와-질문을-분리하기"><a class="anchor" href="#규칙과-예시와-질문을-분리하기">규칙과 예시와 질문을 분리하기</a></h2>
<p>해상도를 맞추는 또 하나의 방법은 논의 자체를 구조화하는 것이다.</p>
<p>Cucumber 프로젝트를 이끈 Matt Wynne이 2015년에 소개한 <a href="https://cucumber.io/blog/bdd/example-mapping-introduction/" target="_blank" rel="noopener noreferrer">Example Mapping</a>을 살펴보자. 그는 많은 팀이 요구사항 논의를 어려워하는 이유를 구조가 없어서 오래 걸리고 지루해지기 때문이라고 진단한다. 그래서 규칙적으로도, 일관되게도 하지 않게 된다는 것이다. 필자도 정확히 그랬다. 요구사항 회의가 길어지면 다음부터는 짧게 끝내려 하고, 짧게 끝내면 애매한 것들이 그대로 남는다.</p>
<p>Example Mapping은 네 색의 카드로 논의를 나눈다. 노란색은 지금 다루는 스토리, 파란색은 규칙 혹은 수용 기준, 초록색은 그 규칙을 설명하는 구체적 예시, 그리고 빨간색은 답을 모르는 질문이다.</p>
<p>필자는 이 중에서 <strong>빨간색 카드가 핵심이라고 생각한다.</strong> 나머지 셋은 정리 기법이지만, 빨간 카드는 "모르겠다"를 회의의 정식 산출물로 만드는 장치다. 모른다는 사실을 말하는 것이 회의를 지연시키는 행동이 아니라 회의가 생산해야 하는 결과물이 된다.</p>
<p>이게 왜 중요한지는 반대 경우를 떠올려보면 분명해진다. 빨간 카드가 없는 회의에서 애매한 지점을 만나면 선택지는 두 개다. 지금 붙잡고 늘어져서 회의를 늘리거나, 넘어가서 나중에 혼자 해석하는 것이다. 앞 절에서 본 대로 사람은 대체로 후자를 고른다. 그런데 카드가 한 장 있으면 세 번째 선택지가 생긴다. <strong>적어두고 넘어가되 사라지지 않게 하는 것이다.</strong> 회의는 계속 진행되고, 애매함은 없어지지 않은 채로 목록에 남는다.</p>
<p>초록색 카드도 비슷한 역할을 한다. 규칙만 적으면 다들 동의하지만, 그 규칙을 설명하는 구체적 예시를 적으려는 순간 해석이 갈린다. Evans가 말한 "대화에서 반복해서 쓰면 차이가 드러난다"를 회의 양식으로 옮겨놓은 셈이다. 규칙 하나에 예시를 세 개씩 붙여보라고 하면, 그중 하나에서 대체로 "어? 이 경우는 어떻게 되죠?"가 나온다. (필자의 경험상 그 질문이 나오지 않는 회의는 다들 이해한 회의가 아니라 각자 다르게 이해한 회의였다)</p>
<p>비슷한 발상으로 BDD 쪽에서 오래 쓰인 Three Amigos도 있다. 비즈니스와 개발과 테스트라는 세 관점이 각각 다른 질문을 던진다는 것인데, <a href="https://www.agilealliance.org/glossary/three-amigos/" target="_blank" rel="noopener noreferrer">Agile Alliance 용어집</a>은 그 질문을 각각 무슨 문제를 풀려는 것인지, 그 문제를 풀 해법을 어떻게 만들 것인지, 그리고 이건 어떤가 무슨 일이 일어날 수 있는가로 정리한다. 세 번째 질문이 나오지 않는 회의가 어떻게 되는지는 굳이 설명하지 않아도 될 것 같다.</p>
<h2 id="모르는것을-확인해보는-절차를-만들어보자"><a class="anchor" href="#모르는것을-확인해보는-절차를-만들어보자">모르는것을 확인해보는 절차를 만들어보자.</a></h2>
<p>그런데 왜 우리는 빨간 카드를 잘 꺼내지 못할까. 여기서 심리적 안전감 이야기를 하지 않을 수 없다.</p>
<p>Amy Edmondson이 1999년에 Administrative Science Quarterly에 실은 <a href="https://web.mit.edu/curhan/www/docs/Articles/15341_Readings/Group_Performance/Edmondson%20Psychological%20safety.pdf" target="_blank" rel="noopener noreferrer">논문</a>이 팀 심리적 안전감이라는 개념을 정식으로 세운 연구다. (논문 자신은 1965년 Schein과 Bennis의 연구를 뿌리로 밝히고 있다) 팀 심리적 안전감을 팀이 대인관계상의 위험을 감수해도 안전한 곳이라는 공유된 믿음으로 정의한다. 모른다고 말하는 것, 반대 의견을 내는 것, 실수를 인정하는 것이 여기 해당한다. 논문은 제조업 회사의 51개 팀을 조사해서, 심리적 안전감이 학습 행동과 연관되고 그 학습 행동이 심리적 안전감과 팀 성과 사이를 매개한다는 결과를 보였다.</p>
<p>그런데 필자가 이 논문에서 가장 실용적이라고 느낀 문장은 정의가 아니라 그 바로 다음이었다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>대부분 이 믿음은 암묵적인 경향이 있다. 개인이나 팀 전체가 당연하게 여기며 직접 주의를 기울이지 않는다.</p></div><div class="quote-original" lang="en"><p>For the most part, this belief tends to be tacit—taken for granted and not given direct attention either by individuals or by the team as a whole.</p></div></blockquote>
<p>이 믿음은 대개 <strong>암묵적</strong>이고, 개인도 팀도 직접 주의를 기울이지 않는다는 것이다.</p>
<p>여기서부터는 필자의 추측이다. 논문이 말한 것은 이 믿음이 암묵적이라는 데까지이고, 그러니 양식을 만들면 된다는 처방은 논문에 없다. 다만 암묵적인 것이 선언으로 바뀌지 않는다는 것은 여러 번 봤다. "우리 팀은 모르는 걸 물어봐도 되는 팀이에요" 라고 말하는 것으로는 잘 바뀌지 않는다. 그래서 필자는 앞 절의 빨간 카드가 중요하다고 생각한다. <strong>모르겠다는 말을 양식 안의 칸으로 만들어버리면, 그것을 채우는 일이 용기가 아니라 절차가 된다.</strong></p>
<p>물론 반문이 가능하다. 안전하지 않은 팀에서는 빨간 카드 칸도 그냥 비어 있을 것이다. 맞는 지적이고, 이 도구가 안전감을 만들어낸다고 주장할 생각은 없다. 다만 칸이 있으면 최소한 <strong>비어 있다는 사실이 눈에 보인다.</strong> 아무도 모르는 게 없어서 비었는지, 말하기 어려워서 비었는지를 물어볼 자리가 생긴다.</p>
<p><picture><source type="image/webp" srcset="/content/260723/3.720w.webp 720w, /content/260723/3.1440w.webp 1440w, /content/260723/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/3.jpg" alt="DDD 커뮤니티의 Bounded Context Canvas 양식. 목적, 전략 분류, 유비쿼터스 언어, 가정, 검증 지표, 미해결 질문 칸이 나뉘어 있다" width="1680" height="1100" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>같은 발상이 실제 문서 양식에도 들어가 있다. DDD 커뮤니티가 만든 <a href="https://github.com/ddd-crew/bounded-context-canvas" target="_blank" rel="noopener noreferrer">Bounded Context Canvas</a>를 보면, 하나의 컨텍스트를 설계하고 기록하는 협업 도구인데 칸 구성이 흥미롭다. 이름과 목적, 전략적 분류, 도메인 역할, 인바운드와 아웃바운드 커뮤니케이션, 그리고 <strong>Ubiquitous Language</strong>, 비즈니스 결정, <strong>Assumptions</strong>, 검증 지표, <strong>Open Questions</strong> 다.</p>
<p>공유 언어를 적는 칸과 가정을 적는 칸과 열린 질문을 적는 칸이 각각 따로 있다. 캔버스의 설명은 목적을 적는 행위 자체가 흐릿한 생각을 명확히 말하도록 강제하고 팀 전체를 같은 페이지에 놓는다고 말한다.</p>
<p>필자는 이 발상이 소통 문제에 대한 가장 현실적인 접근이라고 생각한다. 사람의 태도를 바꾸려 하는 대신, <strong>말하기 어려운 것에 칸을 만들어주는 것이다.</strong> 좋은 구조는 사람이 잘하도록 요구하는 대신 잘하기 쉽게 만들어준다.</p>
<h2 id="이게-취향의-문제가-아닌-이유"><a class="anchor" href="#이게-취향의-문제가-아닌-이유">이게 취향의 문제가 아닌 이유</a></h2>
<p>여기까지 읽으면 이런 반응이 나올 수 있다. 좋은 얘기지만 결국 커뮤니케이션을 열심히 하자는 것 아닌가. 그건 성향 아닌가.</p>
<p>필자도 한동안 그렇게 생각했다. 그런데 이 부분에 데이터가 있다.</p>
<p>DORA는 조직 문화를 다룰 때 사회학자 Ron Westrum의 분류를 가져온다. 권력 지향의 pathological, 규칙 지향의 bureaucratic, 성과 지향의 generative 세 가지다. 그리고 <a href="https://dora.dev/capabilities/generative-organizational-culture/" target="_blank" rel="noopener noreferrer">DORA의 공식 문서</a>는 자신들의 연구 결과를 이렇게 요약한다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>신뢰가 높고 정보 흐름을 중시하는 조직 문화는 소프트웨어 배포 성과를 예측한다.</p></div><div class="quote-original" lang="en"><p>organizational culture that is high-trust and emphasizes information flow is predictive of software delivery performance</p></div></blockquote>
<p>신뢰가 높고 <strong>정보 흐름을 중시하는</strong> 조직 문화가 소프트웨어 배포 성과를 예측한다는 것이다. 소통이 잘 되는 팀이 기분이 좋다는 얘기가 아니라 성과와 함께 움직이는 변수라는 얘기다.</p>
<p>다만 여기서 예측한다는 말을 인과로 읽으면 안 된다. DORA의 데이터는 같은 응답자가 문화와 성과를 함께 답한 설문이고, 조직 수준의 상관이다. 조직에 만족한 사람이 양쪽 다 후하게 답했을 가능성이 남아 있고, 조직 수준의 관계에서 개인이 이렇게 행동하라는 처방이 곧바로 나오지도 않는다. 그러니 이 자료로 말할 수 있는 것은 여기까지다. 정보 흐름은 취향의 영역에만 있는 것이 아니라 성과와 같이 움직이는 자리에 있다.</p>
<p>같은 문서는 Westrum이 말한 좋은 정보의 세 가지 성질도 정리해 두었다. 수신자가 필요로 하는 질문에 답을 주고, 시기가 적절하며, 수신자가 효과적으로 쓸 수 있는 방식으로 제시된다는 것이다. 세 가지 모두 기준이 <strong>수신자</strong>에 있다는 점을 눈여겨볼 만하다. 많이 공유하는 것이 좋은 정보 흐름이 아니다.</p>
<p>Google의 Project Aristotle도 자주 인용되는 자료다. <a href="https://rework.withgoogle.com/intl/en/guides/understand-team-effectiveness" target="_blank" rel="noopener noreferrer">reWork에 공개된 내용</a>에 따르면 180개 팀을 조사하고 수백 개 변수에 35개 이상의 통계 모델을 돌렸으며, 팀 효과성에 영향을 주는 다섯 가지 요인으로 심리적 안전감, 신뢰성, 구조와 명확성, 의미, 영향력을 제시했다. 그리고 결론은 팀에 누가 있는지보다 그 팀이 어떻게 함께 일했는지가 중요했다는 것이었다.</p>
<p>다만 한 가지 짚어두고 싶다. 공식 페이지는 다섯 요인을 중요도 순으로 나열한다고 밝히고 있고 심리적 안전감을 맨 앞에 둔다. 그런데 이 연구를 인용한 글들에서 자주 보이는 "압도적으로 1위" 같은 크기 표현은 그 페이지에 없다. 순서가 있다는 것과 나머지를 압도한다는 것은 다른 얘기다. 그래서 이 글에서는 크기를 주장하지 않는다. 심리적 안전감의 중요성을 말하려면 앞 절의 Edmondson 원 논문에 기대는 것이 정확하다.</p>
<h2 id="다시-리더-이야기로"><a class="anchor" href="#다시-리더-이야기로">다시 리더 이야기로</a></h2>
<p>처음에 인용한 글로 돌아가보자.</p>
<p>James Samuel은 정보 수집을 가장 먼저 다루면서, 실무자 시절의 방법이 통하지 않게 된다고 말한다. 개인 기여자일 때는 자기 일의 전체 그림을 갖고 있지만, 사람에 대한 책임을 지게 되면 예전 방법을 쓸 수 없다는 것이다. 그래서 필요한 역량으로 노이즈를 걸러내고 정보를 종합해 일관된 현실의 그림으로 만드는 능력을 꼽는다. 어떤 관리자도 모든 것을 처리할 수는 없기 때문이다.</p>
<p>필자는 이것이 앞서 본 Westrum의 좋은 정보 세 가지 성질과 같은 얘기라고 느꼈다. 많이 모으는 것이 아니라 쓸 수 있는 형태로 만드는 것이다. 그리고 그건 지금 요구사항을 다루면서 하고 있는 일과 다르지 않다. 기획서의 문장에서 애매한 부분을 골라내고, breadboard로 다시 그려서 확인하고, 모르는 것을 빨간 카드로 남기는 일이 바로 노이즈를 걸러 일관된 그림을 만드는 연습이다.</p>
<p>저자가 의사결정에 대해 남긴 말도 기억에 남는다. 확실성을 기다리는 것도 그 자체로 하나의 결정이고 비용을 수반한다는 것이다. 요구사항이 완전히 명확해질 때까지 기다리는 것도 마찬가지다. 그래서 완벽한 이해가 아니라 현재 목적에 충분한 수준이라는 grounding criterion이 실용적인 기준이 된다.</p>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>정리하면 이렇다.</p>
<p>타직군과의 소통 실패는 대개 태도만의 문제가 아니다. 조직에 서로 다른 맥락이 있으면 같은 단어가 갈라지는 것이 기본 상태이고, 맞춰진 상태가 오히려 비용을 들여 유지해야 하는 예외다. 그 비용을 지불하는 방식이 공유 언어이고, 그 안에서 개발자의 몫은 요구사항을 받아쓰는 것이 아니라 모호함을 감시해서 되돌려주는 것이다.</p>
<p>그런데 되돌려주는 일에는 노력이 들고, 사람은 그 노력을 최소화하려 한다. 그래서 필자는 의지에 기대는 대신 세 가지를 쓰기로 했다. 이 대화가 어떤 모드인지 먼저 확인하는 것, 말과 시안 사이의 해상도로 흐름을 그려서 되돌려주는 것, 그리고 모르는 것을 적는 칸을 회의 양식 안에 만들어두는 것이다. 셋 다 태도를 고치라고 요구하지 않고 하기 쉽게 만드는 쪽이다.</p>
<p>필자는 아직 리더가 아니고, 이 글에 적은 것들도 그 자리에서 검증해본 것이 아니다. 팀 규모나 조직 문화가 다르면 통하지 않는 것도 있을 것이다. 다만 하나는 지금 확신할 수 있을 것 같다. 나중에 정보를 다루는 방식은 새로 배우는 것이 아니라, 지금 요구사항을 다루는 습관이 그대로 커진 형태일 것이다. 그래서 필자는 요즘 회의에서 "그건 무슨 뜻인가요" 라고 묻는 일을 예전만큼 부끄러워하지 않으려 한다.</p>
<p>이 글을 읽는 독자 분들도 최근 회의에서 고개를 끄덕였지만 실은 확실하지 않았던 단어가 하나쯤 떠오른다면, 다음에는 그 단어를 한 번 더 써보시기를 권한다. 반복해서 쓰면 드러난다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/bliki/UbiquitousLanguage.html" target="_blank" rel="noopener noreferrer">Martin Fowler, Ubiquitous Language</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://www.domainlanguage.com/ddd/reference/" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design Reference</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://teamtopologies.com/key-concepts" target="_blank" rel="noopener noreferrer">Team Topologies, Key Concepts</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://basecamp.com/shapeup/1.2-chapter-03" target="_blank" rel="noopener noreferrer">Basecamp, Shape Up: Set Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.eventstorming.com/book/" target="_blank" rel="noopener noreferrer">Alberto Brandolini, EventStorming</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://domainstorytelling.org/" target="_blank" rel="noopener noreferrer">Stefan Hofer, Henning Schwentner, Domain Storytelling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://gojko.net/books/specification-by-example/" target="_blank" rel="noopener noreferrer">Gojko Adzic, Specification by Example</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>협업</category>
            <category>도메인</category>
            <category>DDD</category>
            <category>소통</category>
        </item>
        <item>
            <title><![CDATA[Harness(Systems) Engineering]]></title>
            <link>https://hooninedev.com/260622</link>
            <guid isPermaLink="false">https://hooninedev.com/260622</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 프롬프트 엔지니어링과 컨텍스트 엔지니어링, 그 다음에 대한 이야기를 해보려고 한다. 바로 앞 글인 토큰 절약법을 정리하면서 줄곧 머릿속을 떠나지 않던 질문이 하나 있었다. "개별적 프롬프팅에서 비우고 골라내는 기술(context engineering)로 무게중심이 옮겨가고 있다" 고 생각하는데, 글을 닫고 나니 자연스럽게 다음 질문이 따라...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 프롬프트 엔지니어링과 컨텍스트 엔지니어링, 그 다음에 대한 이야기를 해보려고 한다.</p>
<p>바로 앞 글인 <a href="/260611" target="_blank" rel="noopener noreferrer">토큰 절약법</a>을 정리하면서 줄곧 머릿속을 떠나지 않던 질문이 하나 있었다. "개별적 프롬프팅에서 비우고 골라내는 기술(context engineering)로 무게중심이 옮겨가고 있다" 고 생각하는데, 글을 닫고 나니 자연스럽게 다음 질문이 따라왔다. 그러면 컨텍스트 다음은 무엇인가?</p>
<p><img src="/content/260622/3.webp" alt="프롬프트·컨텍스트·하네스 엔지니어링으로 구성된 신뢰할 수 있는 AI 시스템의 3계층 구조" width="1680" height="920" loading="eager" fetchpriority="high" decoding="async"></p>
<p>"앞으로의 방향" 을 말하는 글은 조심스럽다. 미래에 대한 수많은 선택지가 있을텐데 우리는 <strong>이미 공개된 1차 자료에서 읽히는 무게중심의 이동</strong>을 정리하는 데 초점을 둔다. 추측이 다소 섞일지라도 다양한 관점으로 글을 접해보면 좋겠다.</p>
<h2 id="프롬프트에서-컨텍스트로"><a class="anchor" href="#프롬프트에서-컨텍스트로">프롬프트에서 컨텍스트로</a></h2>
<p>용어부터 정리하고 가자. 한동안 업계의 화두는 <strong>프롬프트 엔지니어링</strong>이었다. 모델에게 보내는 한 번의 지시를 어떻게 잘 쓰느냐, 즉 명확한 지시문과 좋은 예시, 출력 형식을 어떻게 설계하느냐의 문제였다.</p>
<p>그러다 일의 단위가 커졌다. 단발성 질문이 아니라 수십 턴을 도는 에이전트가 보편화되면서, 한 번의 프롬프트가 아니라 <strong>모델이 매 턴 보는 컨텍스트 전체</strong>(시스템 프롬프트 + 도구 정의 + 대화 기록 + 검색 결과 + 메모리)를 어떻게 구성하느냐가 더 중요해졌다. 이걸 <strong>컨텍스트 엔지니어링</strong>이라 부른다. Anthropic 이 2025년 9월 "Effective context engineering for AI agents" 글로 이 프레임을 정리했고, Chroma 연구팀(Hong et al.)이 같은 해 발표한 context rot 연구가 정량적 근거를 보탰다. 이들은 GPT-4.1, Claude 4, Gemini 2.5, Qwen3 등 18개 모델을 두고, 단어를 그대로 따라 적는 수준의 단순 과제에서조차 입력이 길어질수록 성능이 비균일하게 무너진다는 사실을 보였다. 모델이 100번째 토큰과 10,000번째 토큰을 똑같이 다룰 거라는 흔한 가정이 실제로는 깨진다는 것이다. "컨텍스트는 길수록 좋다" 가 아니라 "무엇이 들었느냐만큼 어떻게 놓였느냐가 중요하다" 는 이 결론이, 채우기에서 골라내기로의 전환에 힘을 실으면서 용어가 빠르게 자리 잡았다.</p>
<p>여기서 흔한 오해 하나를 짚어야겠다. 프롬프트 엔지니어링이 컨텍스트 엔지니어링으로 <strong>대체된</strong> 것이 아니다. 프롬프트를 잘 쓰는 일은 여전히 기본이고, 컨텍스트 엔지니어링은 그 위에 얹힌 상위 개념에 가깝다. (코드를 잘 짜는 법에서 시스템을 잘 설계하는 법으로 관심이 옮겨갔다고 해서 코딩이 필요 없어지지 않는 것과 같다.) 즉 정확한 서술은 "갈아탔다" 가 아니라 <strong>"포함하면서 넓어졌다"</strong> 이다.</p>
<p>그렇다면 다시, 그 넓어짐은 컨텍스트에서 멈췄을까? 그렇지 않아 보인다.</p>
<h2 id="anthropic-엔지니어링-블로그"><a class="anchor" href="#anthropic-엔지니어링-블로그">Anthropic 엔지니어링 블로그</a></h2>
<p>방향을 점치는 가장 정직한 방법은, 이 분야를 실제로 끌고 가는 곳이 무엇을 쓰고 있는지를 시간순으로 읽는 것이라 생각한다. Anthropic 엔지니어링 블로그의 글 목록을 "Effective context engineering"(2025년 9월) 이후로 따라가 보면, 제목들만으로도 무게중심이 어디로 더 갔는지 윤곽이 잡힌다.</p>
<ul>
<li>2025년 10월, Agent Skills 로 에이전트 무장하기</li>
<li>2025년 11월, Code execution with MCP: 더 효율적인 에이전트</li>
<li>2025년 11월, Effective harnesses for long-running agents</li>
<li>2026년 1월, Demystifying evals for AI agents</li>
<li>2026년 1월, AI 에 저항하는(AI-resistant) 기술 평가 설계</li>
<li>2026년 2월, 에이전틱 코딩 평가의 인프라 노이즈 정량화</li>
<li>2026년 3월, Harness design for long-running application development</li>
<li>2026년 4월, Scaling Managed Agents: 두뇌와 손을 분리하기</li>
<li>2026년 5월, 제품 전반에서 Claude 를 격리(contain)하는 법</li>
</ul>
<p>목록을 한 발 물러서서 보면 키워드가 세 갈래로 뭉친다. <strong>harness</strong>, <strong>eval</strong>, 그리고 <strong>containment</strong>다. 컨텍스트를 잘 채우고 비우는 법에서 한 단계 더 올라가, 에이전트라는 시스템 전체를 설계하고, 측정하고, 통제하는 쪽으로 논의가 옮겨가고 있다는 뜻으로 읽힌다. (물론 이건 한 회사의 강조점이라는 한계가 있다. 다만 이 회사가 코딩 에이전트 생태계에서 차지하는 비중을 생각하면, 한 벤더의 관심사로만 치부하기는 어렵다.)</p>
<p>하나씩 풀어보자.</p>
<h2 id="harness"><a class="anchor" href="#harness">harness</a></h2>
<p>harness 라는 단어가 조금 낯설 수 있다. 직역하면 마구(馬具), 즉 말에 씌워 힘을 원하는 방향으로 끌어내는 장치다. AI 에이전트에서 harness 는 <strong>모델 바깥에서 모델을 둘러싸 일을 시키는 골격 전체</strong>를 가리킨다. 어떤 도구를 어떤 순서로 쓸 수 있게 할지, 한 번 실패하면 어떻게 복구할지, 권한은 어디까지 줄지, 루프는 언제 멈출지 같은 것들이다.</p>
<p>컨텍스트 엔지니어링이 "모델에게 무엇을 보여줄까" 의 문제였다면, harness 설계는 "모델이 그 안에서 어떻게 움직이게 할까" 의 문제다. 한 칸 더 바깥이다. 모델을 똑똑한 신입에 비유하면, 컨텍스트가 그 신입에게 건네는 업무 자료라면 harness 는 그 신입이 일하는 작업 환경과 절차서에 가깝다. 같은 사람이라도 환경이 어수선하면 성과가 흔들리고, 잘 짜인 절차 위에서는 같은 능력으로 더 멀리 간다.</p>
<p>이게 왜 중요한지는 <a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents" target="_blank" rel="noopener noreferrer">Anthropic 엔지니어링 팀이 직접 부딪힌 실패</a>에서 또렷하게 드러난다. 이들은 최상위 코딩 모델인 Opus 4.5 를 Claude Agent SDK 위에 올려 "claude.ai 클론을 만들어줘" 같은 고수준 지시만 던지고 여러 세션을 돌려봤는데, 모델 자체가 똑똑한데도 프로덕션 수준의 앱은 나오지 않았다. 실패는 두 가지 모양으로 반복됐다. 하나는 한 방에 다 끝내려다 구현 도중 컨텍스트가 바닥나, 다음 세션이 반쯤 만들다 만 기능을 물려받는 경우다. 다른 하나는 뒤늦게 투입된 세션이 "이미 꽤 진행됐네" 하고는 멀쩡히 남은 일을 두고 완료를 선언해버리는 경우다. (긴 작업을 사람 여러 명이 교대 근무로 잇는데, 교대자마다 앞 사람의 기억이 전혀 없는 상황을 떠올리면 된다.)</p>
<p>해법은 모델을 더 똑똑하게 만드는 쪽이 아니라 골격을 바꾸는 쪽이었다. 첫 세션에는 환경을 까는 전용 프롬프트(initializer agent)를 주어 200개가 넘는 기능 명세를 <code>feature_list.json</code> 으로 펼치게 하고, 개발 서버를 띄우는 <code>init.sh</code> 와 진행 로그 파일(<code>claude-progress.txt</code>)을 만들게 했다. 이후 세션(coding agent)은 매번 기능을 딱 하나씩만 처리하고, git 커밋과 진행 노트로 깔끔한 상태를 남긴 뒤 끝낸다. 다음 세션은 그 progress 파일과 git 로그를 먼저 읽어 "앞 교대자가 어디까지 했는지" 를 파악하고 이어받는다. 똑같은 모델인데, 이 골격 위에 올리자 결과가 달라졌다. 모델이 아니라 harness 가 성패를 갈랐다는 뜻이다. (흥미롭게도 Anthropic 이 든 처방들은 새로운 게 아니다. 기능 목록, 작은 단위 커밋, 진행 노트, 매번 돌리는 스모크 테스트는 숙련된 개발자가 매일 하는 일 그대로다. 에이전트에게 좋은 골격이란 결국 좋은 엔지니어링 습관을 환경에 박아 넣는 일에 가깝다.)</p>
<p>이 흐름이 토큰 절약 논의와 닿는 지점도 분명하다. 앞 글에서 다룬 서브에이전트 격리, 도구 정의 다이어트, 모델 라우팅은 따로 보면 개별 절약 기법이지만, 묶어서 보면 결국 <strong>하나의 harness 를 어떻게 설계하느냐</strong>의 부분들이다. 어떤 작업을 어떤 모델 레인으로 보내고, 어떤 도구만 켜두고, verbose 한 탐색은 어디로 격리할지를 정하는 일 전체가 harness 설계다. 절약은 그 설계의 부산물에 가깝다.</p>
<p>그런데 harness 설계에는 묘한 함정이 하나 있다. <strong>잘 짠 골격일수록 모델이 좋아지면 낡는다는 점이다.</strong> harness 는 본질적으로 "모델이 혼자서는 못 하는 것" 에 대한 가정의 집합인데, 모델이 그 일을 스스로 하게 되는 순간 그 가정은 군더더기가 된다. Anthropic 이 든 <a href="https://www.anthropic.com/engineering/managed-agents" target="_blank" rel="noopener noreferrer">사례</a>가 정확히 이렇다. Sonnet 4.5 는 컨텍스트 한도가 다가오면 작업을 서둘러 끝내버리는 버릇(context anxiety)이 있어서, 골격에 컨텍스트 리셋 장치를 넣어 대응했다. 그런데 같은 골격을 Opus 4.5 에 올리자 그 버릇 자체가 사라져 있었고, 애써 넣은 리셋은 죽은 무게가 됐다. 모델이 한 칸 똑똑해질 때마다 골격의 일부가 이렇게 유통기한을 맞는다.</p>
<p>그래서 한 발 더 나아간 발상이 등장한다. 특정 골격을 잘 짜는 대신, <strong>골격이 바뀌어도 흔들리지 않는 인터페이스를 짜는 것</strong>이다. Anthropic 의 Managed Agents 가 이 방향인데, 발상의 뿌리는 의외로 운영체제다. OS 가 수십 년을 버틴 건 하드웨어를 프로세스·파일 같은 추상으로 가상화해, 아직 존재하지도 않는 프로그램까지 담을 그릇을 먼저 만들어뒀기 때문이다. <code>read()</code> 한 줄은 1970년대 디스크든 요즘 SSD든 똑같이 동작한다. 같은 사고로 에이전트를 세 조각으로 가른다. 판단하는 <strong>두뇌</strong>(Claude 와 harness), 실제로 손을 쓰는 <strong>손</strong>(코드 실행 샌드박스·도구), 그리고 일어난 모든 일을 append 로 쌓는 <strong>세션 로그</strong>다. 셋을 분리해두면 컨테이너가 죽어도 두뇌는 도구 호출 에러로 받아 넘기고, harness 가 죽어도 세션 로그에서 마지막 지점부터 다시 깨어난다. 부수 효과로 비용·지연도 줄었다. 컨테이너를 정말 필요할 때만 띄우게 되면서 첫 토큰까지의 지연(TTFT)이 중앙값 기준 약 60%, p95 기준 90% 넘게 떨어졌다고 보고한다. (이 지점이 토큰 절약 글과 다시 만난다. 앞 글에서 prompt caching 을 위해 정적인 부분을 앞에 모으라고 했는데, 그 "어떻게 캐시 적중률을 높이게 컨텍스트를 배치할까" 가 바로 이 두뇌 옆 harness 가 맡는 일이다.)</p>
<h2 id="eval"><a class="anchor" href="#eval">eval</a></h2>
<p>두 번째 갈래가 개인적으로 가장 흥미로웠다. 2026년 초의 글들이 약속이라도 한 듯 <strong>평가(eval)</strong> 로 모인다.</p>
<p>이유를 생각해보면 자연스럽다. 컨텍스트를 잘 구성했는지, harness 를 잘 짰는지, 비용을 정말 아꼈는지를 <strong>무엇으로 확인할 것인가?</strong> 에이전트가 길고 복잡한 작업을 자율적으로 처리할수록, "그래서 이게 잘 돌아간 건가" 를 사람이 눈으로 일일이 확인하기 어려워진다. 결국 신뢰의 근거는 측정으로 옮겨간다. 그래서 "에이전트 평가를 어떻게 설계하나", "평가 자체의 노이즈를 어떻게 걷어내나", "모델이 평가를 눈치채고 행동을 바꾸는(eval awareness) 문제를 어떻게 다루나" 같은 주제가 전면에 나온 것이다.</p>
<p><picture><source type="image/webp" srcset="/content/260622/4.720w.webp 720w, /content/260622/4.1440w.webp 1440w, /content/260622/4.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260622/4.png" alt="pass@k 와 pass^k 의 차이를 보여주는 그래프. 시행 횟수가 늘면 pass@k 는 100% 로 올라가지만 pass^k 는 0% 로 떨어진다" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>에이전트 평가가 까다로운 건 단발성 질의응답과 결이 다르기 때문이다. 에이전트는 여러 턴에 걸쳐 도구를 부르고 상태를 바꾸며 진행하므로, 한 번의 실수가 뒤로 전파되며 누적된다. 게다가 똑같은 입력에도 매 실행 결과가 흔들린다. <a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic 은</a> 이 비결정성을 두 지표로 나눠 본다. <strong>pass@k</strong> 는 k 번 시도 중 한 번이라도 성공할 확률이라, 시도를 늘릴수록 올라간다. <strong>pass^k</strong> 는 k 번을 모두 성공할 확률이라, 시도를 늘릴수록 떨어진다. 한 번만 맞으면 되는 코드 생성은 pass@1 이 중요하고, 매번 안정적으로 동작해야 하는 고객 응대 에이전트는 pass^k 가 핵심이다. (per-trial 성공률이 75% 라면 세 번 연속 성공할 확률은 0.75³, 약 42% 로 뚝 떨어진다. "대체로 잘된다" 와 "매번 잘된다" 사이의 간극이 이렇게 크다.)</p>
<p>그렇다면 한 번의 시도는 무엇으로 채점하나? 같은 글은 채점기(grader)를 세 종류로 나눈다. <strong>코드 기반</strong>(테스트 통과 여부, 정적 분석, 도구 호출 검증)은 빠르고 싸고 객관적이지만, 정답이 여럿인 열린 작업에는 약하다. <strong>모델 기반</strong>(LLM-as-judge, 루브릭 채점)은 미묘한 품질까지 잡아내지만 비결정적이라 사람 채점과 주기적으로 보정해야 한다. <strong>사람 기반</strong>은 가장 정확하지만 느리고 비싸다. 실무에서는 이 셋을 섞되, 가능하면 결정론적 채점을 기본으로 깔고 모델 채점을 거드는 식으로 조합한다. 또 하나 구분이 있다. <strong>capability eval</strong> 은 "이 에이전트가 무엇을 해낼 수 있나" 를 묻기에 낮은 점수에서 시작해 올라갈 언덕을 주고, <strong>regression eval</strong> 은 "예전에 되던 게 지금도 되나" 를 묻기에 거의 100% 를 유지해야 한다. 점수가 떨어지면 어딘가 망가졌다는 신호다.</p>
<p>여기서 의외로 자주 간과되는 함정이 있다. <strong>점수가 낮은 게 에이전트가 아니라 평가 탓일 수 있다는 것이다.</strong> Anthropic 의 보고에 따르면 Opus 4.5 가 CORE-Bench 에서 처음 42% 를 받았는데, 파보니 "96.124991…" 를 기대하는데 "96.12" 를 틀렸다고 처리하는 식의 경직된 채점, 모호한 문제 명세, 재현 불가능한 확률적 과제가 원인이었다. 버그를 고치고 제약을 푼 스캐폴드로 다시 돌리자 점수는 95% 로 뛰었다. 그래서 이들은 한 가지 원칙을 강조한다. <strong>점수를 액면 그대로 믿지 말고 트랜스크립트(실행 기록)를 직접 읽어라.</strong> 프런티어 모델이 100번 시도해 0% 가 나온다면 대개 모델이 무능한 게 아니라 문제가 깨진 것이다.</p>
<p>측정이 발전하면 기준선 자체가 빠르게 움직인다는 점도 흥미롭다. 대표적인 코딩 에이전트 벤치마크인 SWE-bench Verified(실제 GitHub 이슈를 주고 테스트 통과 여부로 채점) 에서 프런티어 모델들의 점수는 1년 만에 30% 대에서 80% 이상으로 올라섰다. 이쯤 되면 쉬운 문제는 다 풀려 점수가 천장(saturation)에 닿고, 큰 능력 향상이 작은 점수 차로만 보이는 역설이 생긴다. 실제로 어떤 코드 리뷰 스타트업은 처음에 단발성 평가만 보고 신형 모델을 시큰둥하게 여겼다가, 더 길고 복잡한 작업을 재는 에이전트형 평가로 갈아탄 뒤에야 향상을 제대로 봤다고 한다. 그래서 평가는 한 번 만들고 끝나는 게 아니라, 계속 어렵게 갈아 끼워야 하는 살아있는 자산이 된다. (Anthropic 은 이를 안전공학의 "스위스 치즈 모델" 에 빗댄다. 구멍 뚫린 치즈 한 장으로는 막을 수 없지만, 자동 평가와 프로덕션 모니터링, 사람의 트랜스크립트 검토를 겹겹이 쌓으면 한 층을 빠져나간 실패가 다음 층에 걸린다는 것이다.)</p>
<h2 id="containment"><a class="anchor" href="#containment">containment</a></h2>
<p>세 번째는 결이 조금 다르다. 비용이나 성능이 아니라 <strong>안전과 통제</strong>의 문제다.</p>
<p>에이전트가 더 많은 도구를 쥐고 더 자율적으로 움직일수록, 한 번의 실수가 미치는 범위(blast radius)도 같이 커진다. 파일을 지우고, 외부에 요청을 보내고, 권한이 있는 작업을 대신 수행하는 에이전트라면, "얼마나 잘하나" 만큼이나 "잘못했을 때 피해를 어디서 막나" 가 중요해진다. 2026년 5월 <a href="https://www.anthropic.com/engineering/how-we-contain-claude" target="_blank" rel="noopener noreferrer">Anthropic 이 제품 전반의 격리(containment)를 다룬 글</a>을 전면에 내세운 것도 이 맥락으로 읽힌다. 이 글은 에이전트의 위험을 세 갈래로 나눈다. 사용자가 악의로든 부주의로든 해로운 일을 시키는 <strong>사용자 오용</strong>, 아무도 시키지 않은 행동을 모델이 스스로 하는 <strong>모델 오작동</strong>, 도구·파일·네트워크를 통해 바깥에서 들어오는 <strong>외부 공격</strong>이다. 흥미로운 지적은 모델이 똑똑해진다고 위험이 줄지만은 않는다는 것이다. 덜 똑똑한 모델은 상황을 오독해 빤한 실수를 하지만, 더 똑똑한 모델은 실수는 줄어드는 대신 아무도 적어두지 않은 제약을 우회하는 뜻밖의 경로를 더 잘 찾아낸다.</p>
<p>이들이 강조하는 핵심은 "사람이 매번 감독하는 방식"의 한계다. Claude Code 는 초기에 쓰기·실행·네트워크 접근마다 사용자에게 승인을 묻는 식으로 안전을 확보했는데, 텔레메트리를 보니 사용자들은 승인 요청의 약 93% 를 그냥 허락하고 있었다. 승인 창이 많아질수록 하나하나에 주의를 덜 기울이게 되는 <strong>승인 피로(approval fatigue)</strong> 가 생긴 것이다. 결국 감독을 사람의 클릭에 의존하는 확률적 방어에는 새는 구멍이 남는다. 그래서 무게중심이 "에이전트가 무엇을 하는지 감시" 에서 "에이전트가 애초에 무엇을 할 수 있는지를 제한" 으로 옮겨간다. 방어는 세 계층으로 겹쳐 쌓는다. 샌드박스·VM·egress 통제 같은 <strong>환경 계층</strong>, 시스템 프롬프트·분류기 같은 <strong>모델 계층</strong>, MCP·플러그인·검색 결과 같은 <strong>외부 콘텐츠 계층</strong>이다. 핵심 원칙은 <strong>결정론적으로 막아주는 환경 계층을 가장 먼저 깐다</strong>는 것이다. 모델 계층 방어가 약해서가 아니다. 실제로 prompt injection 을 테스트하는 Gray Swan 벤치마크에서 단발 공격 성공률은 약 0.1% 로 최상급이다. 다만 100번 적응형으로 두드리면 5~6% 까지 올라가고, 확률적 방어는 본질적으로 적중률이 100% 가 될 수 없다. 그래서 마지막으로 부딪히는 하드한 경계를 깔아두는 것이다. (OS 수준 샌드박스를 도입했을 때 승인 창이 84% 줄었다고 한다. 안전장치가 오히려 마찰을 줄인 셈이다.)</p>
<p><picture><source type="image/webp" srcset="/content/260622/2.720w.webp 720w, /content/260622/2.1440w.webp 1440w, /content/260622/2.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/2.png" alt="containment 3계층 방어 구조: 환경 계층(결정론적, 최후 방어선) 위에 모델 계층과 외부 콘텐츠 계층(확률적)을 겹쳐 쌓는다" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1198" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>토큰 절약과는 멀어 보이지만, 실은 같은 뿌리를 공유한다. 둘 다 <strong>"에이전트에게 무엇을, 어디까지 쥐여줄 것인가"</strong> 의 문제다. Anthropic 에서 두가지 사례로 이 연결을 잘 보여준다. 하나는 내부 레드팀이 직원을 피싱해 악성 프롬프트로 Claude Code 를 실행시킨 일인데, 슬쩍 끼워둔 지시가 <code>~/.aws/credentials</code> 를 읽어 외부로 POST 하게 만들었고 25번 중 24번 성공했다. 사용자가 직접 타이핑한 지시라 모델 분류기에는 수상할 게 없었다. 막아낸 건 똑똑한 모델이 아니라 자격증명을 애초에 샌드박스 밖에 둔 환경 경계와 egress 통제였다. 다른 하나는 더 미묘하다. egress 허용 목록이 <code>api.anthropic.com</code> 을 정상 통과시켰는데, 공격자가 심어둔 파일이 자기 API 키로 Anthropic 의 파일 업로드 API 를 호출하게 시켜 데이터가 공격자 계정으로 빠져나갔다. 샌드박스는 완벽히 동작했는데도 데이터가 샌 것이다. 허용 목록을 "목적지 필터" 가 아니라 "그 도메인으로 할 수 있는 모든 기능을 허용하는 권한" 으로 봐야 했다는 교훈이다. (Anthropic 이 반복해 강조하는 원칙이 이거다. 검증된 하이퍼바이저·시스콜 필터·컨테이너 런타임은 멀쩡히 버텼고, <strong>정작 깨진 건 그 위에 자기들이 직접 만든 부품</strong>이었다.) 안 쓰는 MCP 도구를 떼는 일도 같은 맥락이다. 비용을 아끼는 동시에 공격 표면(attack surface)을 줄이는 일이기도 하다. 가볍게 유지하는 설계가 싸고 정확할 뿐 아니라, 더 안전하기도 하다는 뜻이다.</p>
<h2 id="그래서-정리하면"><a class="anchor" href="#그래서-정리하면">그래서, 정리하면</a></h2>
<p>세 갈래를 한 줄로 줄이면 이렇다. 관심의 단위가 <strong>프롬프트(한 번의 지시)에서 컨텍스트(매 턴 보여줄 것)로, 다시 에이전트 시스템 전체(어떻게 굴리고 어떻게 측정하고 어떻게 가둘 것인가)로</strong> 한 칸씩 바깥으로 넓어지고 있다. harness 는 "어떻게 굴릴까", eval 은 "잘 굴렀는지 어떻게 알까", containment 는 "잘못 굴렀을 때 어떻게 막을까" 에 각각 대응한다.</p>
<p><picture><source type="image/webp" srcset="/content/260622/1.720w.webp 720w, /content/260622/1.1440w.webp 1440w, /content/260622/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/1.png" alt="프롬프트에서 컨텍스트, 다시 에이전트 시스템 전체로 관심이 넓어지는 3단 확장 구조와 harness, eval, containment 세 갈래" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1122" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>다시 한번 못 박아두자면, 이 세 갈래는 필자가 <strong>이미 공개된 자료에서 읽어낸 무게중심의 이동</strong>이지, "2026년 하반기엔 이게 표준이 된다" 같은 예언이 아니다. 어떤 흐름은 더 커질 것이고 어떤 흐름은 다른 이름으로 흡수될 것이다. 다만 분명한 건, 프롬프트도 컨텍스트도 사라지지 않고 더 큰 틀의 부분으로 남는다는 점이다. 새 단어가 옛 단어를 지우는 게 아니라 그 위에 한 층을 더 쌓는 식으로 분야가 움직여 왔고, 앞으로도 그럴 가능성이 높다.</p>
<p>비용 이야기로 시작해 여기까지 왔다. 앞 글에서 토큰 가계부를 꺼내보길 권했는데, 이 글의 권유는 한 칸 더 나아간다. 절약한 비용을 <strong>무엇으로 확인하고</strong>(eval), 그 절약이 <strong>어떤 골격 위에서 반복 가능하며</strong>(harness), 그 골격이 <strong>어디까지 안전한지</strong>(containment)를 함께 들여다보면 좋겠다. 결국 가장 오래 가는 건 특정 절약 기법이 아니라, 자기 시스템을 측정하고 통제하는 습관일 것이라 생각한다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic, Effective context engineering for AI agents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" target="_blank" rel="noopener noreferrer">Anthropic, Harness design for long-running application development</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma Research, Context Rot</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>에이전트</category>
        </item>
        <item>
            <title><![CDATA[Kalyx]]></title>
            <link>https://hooninedev.com/260617</link>
            <guid isPermaLink="false">https://hooninedev.com/260617</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 필자가 만든 React headless DatePicker 라이브러리 Kalyx에 대한 이야기를 해보려고 한다. 이 글은 2026년 6월에 쓴 회고를 다시 쓴 것이다. 처음 글이 내세운 "일곱 picker를 하나의 API로, 경쟁 라이브러리의 calendar 하나보다 작게"는 다시 확인해 보니 절반은 틀렸고 절반은 차별점이 아니었다. 그래...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 필자가 만든 React headless DatePicker 라이브러리 <strong>Kalyx</strong>에 대한 이야기를 해보려고 한다.</p>
<p>이 글은 2026년 6월에 쓴 회고를 다시 쓴 것이다. 처음 글이 내세운 "일곱 picker를 하나의 API로, 경쟁 라이브러리의 calendar 하나보다 작게"는 다시 확인해 보니 절반은 틀렸고 절반은 차별점이 아니었다. 그래서 만든 이유, 기존 선택지와의 차이, 기술적 정의 순서로 다시 정리한다.</p>
<p>결론을 먼저 말하면 Kalyx의 차이는 컴포넌트 개수가 아니라 <strong>값 모델</strong>에 있다. 기준 버전은 <code>@kalyx/react</code> 1.4.7과 <code>@kalyx/core</code> 1.4.8(MIT, React 19 전용)이고, 코드 인용은 <a href="https://github.com/jiji-hoon96/kalyx" target="_blank" rel="noopener noreferrer">GitHub 저장소</a>의 2026-09-16 <code>main</code>(<code>0bb302e</code>) 기준이다.</p>
<h2 id="날짜-picker를-선언적으로-쓰고-싶었다"><a class="anchor" href="#날짜-picker를-선언적으로-쓰고-싶었다">날짜 picker를 선언적으로 쓰고 싶었다</a></h2>
<p>만든 이유는 두 가지였다. 복잡하고 쓰기 어려운 날짜 라이브러리를 더 선언적이고 단순하게 쓰고 싶었고, 그런 라이브러리가 안에서 어떻게 만들어지는지 공부하고 싶었다. "쓰기 어렵다"는 말은 막연하니, 필자가 걸렸던 지점이 지금 최신 버전에도 남아 있는지 각 라이브러리의 타입 정의로 다시 확인했다.</p>
<h3 id="모드를-prop으로-켜는-api"><a class="anchor" href="#모드를-prop으로-켜는-api">모드를 prop으로 켜는 API</a></h3>
<p>react-datepicker는 시간 선택을 <code>showTimeSelect</code>, 월 선택을 <code>showMonthYearPicker</code>, 연 선택을 <code>showYearPicker</code>, 범위 선택을 <code>selectsRange</code>로 켠다. 한 컴포넌트가 prop 조합에 따라 다른 물건이 되는 구조다. 그 비용은 타입에서 보인다. 9.1.0의 타입 정의에서는 <code>selectsRange</code> 값에 따라 <code>onChange</code> 시그니처가 갈린다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// react-datepicker/dist/index.d.ts (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsRange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsMultiple</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    formatMultipleDates</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> never;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">event</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">MouseEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">KeyboardEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>잘 만든 union이지만, 모드가 늘수록 분기가 곱해지고 사용하는 쪽은 prop 이름만 보고 지금 어떤 조합인지 떠올려야 한다. (필자가 <a href="/260201" target="_blank" rel="noopener noreferrer">추상화</a> 글에서 다룬 "잘못 묶은 추상화는 결합도를 늘린다"와 같은 모양이다) 필자는 "입력 칸과 popover와 calendar"가 JSX 구조 자체로 읽히길 원했다.</p>
<h3 id="값-타입과-timezone이-새는-자리"><a class="anchor" href="#값-타입과-timezone이-새는-자리">값 타입과 timezone이 새는 자리</a></h3>
<p>react-datepicker와 react-day-picker는 native <code>Date</code>를 주고받는다. 둘 다 IANA <code>timeZone</code> prop이 있기는 하다. react-datepicker는 optional peer로 <code>date-fns-tz</code>를 요구하고 react-day-picker의 것은 실험적인데, 어느 쪽이든 값 타입은 여전히 <code>Date</code>다. <code>Date</code>는 실행 환경의 로컬 timezone으로 해석되기 때문에 서울에서 <code>new Date(2026, 3, 15)</code>의 <code>toISOString()</code>은 <code>2026-04-14T15:00:00.000Z</code>다. 4월 15일을 골랐는데 서버에는 14일로 보이는 문제다. react-datepicker의 <a href="https://github.com/Hacker0x01/react-datepicker/issues/1018" target="_blank" rel="noopener noreferrer">"Date Selected is One Day Off"</a> 이슈는 2017년 9월에 열려 2025년 12월에 닫혔다.</p>
<p>반대편의 Ark UI와 React Aria는 <code>@internationalized/date</code>의 <code>CalendarDate</code>, <code>ZonedDateTime</code> 객체를 쓴다. 의미는 정확하지만 폼 상태와 서버 응답이 전부 문자열인 앱에서는 경계마다 변환 코드가 생긴다.</p>
<p>timezone 지원이 날짜 라이브러리 선택에 묶이기도 한다. MUI X Date Pickers 9.13.0의 adapter 코드를 보면 dayjs, Luxon, Moment adapter는 <code>isTimezoneCompatible = true</code>, date-fns 계열은 <code>false</code>다. date-fns를 쓰는 앱이 <code>timezone</code> prop을 쓰려면 날짜 라이브러리를 하나 더 들여야 한다.</p>
<p>모드는 prop 조합으로, 값은 해석이 실행 환경에 기대는 <code>Date</code>로, timezone 지원은 날짜 라이브러리 선택으로 흩어져 있다. <strong>선언 하나로 의도를 적기 어려웠다.</strong></p>
<h3 id="만들며-배우기"><a class="anchor" href="#만들며-배우기">만들며 배우기</a></h3>
<p>날짜 picker는 작아 보이지만 달력 계산, locale, timezone과 DST, 키보드 탐색, SSR이 전부 들어 있다. 그래서 쓰는 쪽에서는 JSX만 보고 무엇을 만드는지 읽히게, 만드는 쪽에서는 값이 어디서 변환되는지 설명할 수 있을 만큼 좁히는 것을 목표로 잡았다.</p>
<p>그렇다면 이런 요구를 이미 풀어 둔 라이브러리는 정말 없었을까?</p>
<h2 id="이미-있는-선택지와-무엇이-다른가"><a class="anchor" href="#이미-있는-선택지와-무엇이-다른가">이미 있는 선택지와 무엇이 다른가</a></h2>
<p>답부터 말하면 있다. 필자는 headless이면서 여러 picker를 갖춘 라이브러리가 없다고 믿고 시작했는데, 다시 조사해 보니 그 전제가 틀렸다.</p>
<h3 id="선택지마다-고른-값-모델"><a class="anchor" href="#선택지마다-고른-값-모델">선택지마다 고른 값 모델</a></h3>
<p>2026-09-16에 npm 최신판을 설치해 타입 정의와 공식 문서로 확인한 내용이다. 크기 열은 아래에서 설명할 같은 방법으로 잰 값이다.</p>
<table>
<thead>
<tr>
<th>라이브러리</th>
<th>headless</th>
<th>값 타입</th>
<th>시간 입력</th>
<th>월·연 단독 선택</th>
<th>gzip</th>
</tr>
</thead>
<tbody>
<tr>
<td>react-day-picker 10.0.1</td>
<td>아니오 (CSS 제공)</td>
<td><code>Date</code></td>
<td>없음</td>
<td>없음</td>
<td>20.0KB</td>
</tr>
<tr>
<td>react-datepicker 9.1.0</td>
<td>아니오 (CSS import)</td>
<td><code>Date</code></td>
<td><code>showTimeSelect</code></td>
<td>prop으로</td>
<td>45.4KB</td>
</tr>
<tr>
<td>MUI X 9.13.0</td>
<td>아니오 (Material)</td>
<td>adapter 객체</td>
<td>TimePicker</td>
<td><code>views</code></td>
<td>113.1KB</td>
</tr>
<tr>
<td>Ark UI 5.39.2</td>
<td>예</td>
<td><code>@internationalized/date</code></td>
<td>별도 <code>DateInput</code> 세그먼트 (크기 미포함)</td>
<td><code>minView</code></td>
<td>42.7KB</td>
</tr>
<tr>
<td>React Aria Components 1.21.1</td>
<td>예</td>
<td><code>@internationalized/date</code></td>
<td><code>TimeField</code> 세그먼트</td>
<td>없음</td>
<td>75.3KB (DatePicker), 78.8KB (범위·시간 포함)</td>
</tr>
<tr>
<td>Kalyx 1.4.7</td>
<td>예</td>
<td>ISO 8601 UTC 문자열</td>
<td>목록형 HourList, MinuteList</td>
<td>MonthPicker, YearPicker</td>
<td>18.9KB (DatePicker), 25.6KB (전부)</td>
</tr>
</tbody>
</table>
<p>react-day-picker는 <a href="https://daypicker.dev/guides/timepicker" target="_blank" rel="noopener noreferrer">공식 가이드</a>에서 "DayPicker does not include a built-in time picker"라고 밝힌다. MUI 크기는 <code>@mui/material</code>과 emotion을 포함한 값이라 이미 MUI 앱이라면 증가분이 훨씬 작다.</p>
<h3 id="headless-완성형은-이미-있다"><a class="anchor" href="#headless-완성형은-이미-있다">headless 완성형은 이미 있다</a></h3>
<p>표에서 중요한 줄은 Ark UI와 React Aria다. <a href="https://ark-ui.com/docs/components/date-picker" target="_blank" rel="noopener noreferrer">Ark UI</a>는 <code>DatePicker.Root</code> 같은 dot notation 합성 API로 단일, 다중, 범위 선택과 월, 연 단위 선택을 낸다. <a href="https://react-aria.adobe.com/DatePicker" target="_blank" rel="noopener noreferrer">React Aria</a>는 Adobe의 접근성 중심 구현이다. 그래서 Kalyx의 자리는 처음 생각보다 좁다.</p>
<blockquote>
<p>headless 완성형은 이미 있다. 다만 Ark UI와 React Aria는 값을 <code>@internationalized/date</code> 객체로 주고받고 시간 입력은 세그먼트 필드로 낸다. Kalyx는 값을 JSON에 그대로 실리는 UTC 시점 문자열로 고정했고, 목록에서 고르는 TimePicker와 월, 연, 주 picker를 같은 합성 API에 넣었다.</p>
</blockquote>
<p>다만 "값이 문자열이라 좋다"는 약한 주장이다. <code>CalendarDate</code>도 <code>toString()</code> 한 번이면 문자열이 된다. 차별점은 형식이 아니라 <strong>그 문자열이 항상 실제 순간(시점)이고 calendar 칸(좌표)과 섞이지 않는다는 계약, 그리고 그 계약을 지키는 테스트</strong>에 있다.</p>
<h3 id="번들-크기를-같은-방법으로-다시-쟀다"><a class="anchor" href="#번들-크기를-같은-방법으로-다시-쟀다">번들 크기를 같은 방법으로 다시 쟀다</a></h3>
<p>README 배지의 번들 크기는 다른 라이브러리와 비교할 수 있는 양이 아니었다. 배지의 약 19.5KB는 <code>@kalyx/react</code>의 <code>dist</code> 파일 하나인데, 이 파일은 <code>@kalyx/core</code>, <code>@kalyx/adapter-date-fns</code>, <code>@floating-ui/react</code>를 외부 import로 남긴다. 그 숫자를 의존성이 포함된 남의 숫자 옆에 세워 둔 것이다.</p>
<p>그래서 "소비자 앱이 import 한 줄을 추가했을 때 번들이 얼마나 커지는가"로 모두 다시 쟀다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">echo</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "import { DatePicker } from '@kalyx/react'; export default DatePicker;"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> esbuild</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --bundle</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --minify</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --format=esm</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --platform=browser</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> \</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  --external:react</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react-dom</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react/jsx-runtime</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> gzip</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -6</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> wc</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -c</span></span></code></pre></figure>
<p>esbuild 0.28.2, 2026-09-16 측정이고 KB는 바이트를 1024로 나눈 값이다. react-datepicker는 CSS를 뺐다. React Aria Components는 단일 DatePicker 조립에 필요한 export 12개(<code>DatePicker</code>, <code>DateInput</code>, <code>Calendar</code>, <code>Popover</code>, <code>Dialog</code> 등)와, 범위와 시간까지 넣은 14개(<code>DateRangePicker</code>, <code>RangeCalendar</code>, <code>TimeField</code> 포함)로 두 번 쟀다.</p>
<p><picture><source type="image/webp" srcset="/content/260617/1.720w.webp 720w, /content/260617/1.1440w.webp 1440w, /content/260617/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/1.png" alt="같은 esbuild 조건으로 재면 Kalyx DatePicker 18.9KB, react-day-picker 20.0KB, Kalyx 전부 25.6KB, Ark UI 42.7KB, react-datepicker 45.4KB, React Aria Components 75.3KB(범위·시간 포함 78.8KB), MUI X 113.1KB 순으로 커진다." style="max-width:min(720px, 100%);height:auto;" width="1680" height="980" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>같은 조건에서 참인 문장은 하나다. DatePicker 하나(18.9KB)는 <code>DayPicker</code>(20.0KB)와 비슷한 크기이고, 일곱 종 전부(25.6KB)는 그보다 크다. (그마저 DayPicker는 calendar만, Kalyx DatePicker는 입력 칸과 popover까지 포함한다) 크기는 import 조합과 gzip 레벨에 민감하니 자릿수로 읽는 편이 맞고, 크기가 Kalyx를 고를 중심 이유는 아니다.</p>
<h2 id="kalyx를-기술적으로-정의하면"><a class="anchor" href="#kalyx를-기술적으로-정의하면">Kalyx를 기술적으로 정의하면</a></h2>
<blockquote>
<p>Kalyx는 모든 입출력을 UTC 시점 문자열로 고정하고, calendar 좌표와 시점 사이의 변환을 두 함수에만 두어 그 왕복을 런타임이 아는 모든 timezone에 대해 속성 테스트로 검사하는 headless React 날짜 picker다.</p>
</blockquote>
<p>사용하는 쪽의 API는 이렇다. <code>value</code>와 <code>onChange</code>는 <code>string | null</code>이고 <code>displayTimezone</code>은 어느 존의 달력으로 보여줄지 정한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{iso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">displayTimezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"America/New_York"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Input</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Calendar</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="값은-시점이고-calendar-칸은-좌표다"><a class="anchor" href="#값은-시점이고-calendar-칸은-좌표다">값은 시점이고 calendar 칸은 좌표다</a></h3>
<p>Kalyx 안에는 형식이 같은 두 종류의 ISO 문자열이 흐른다. <strong>좌표</strong>는 calendar 그리드의 한 칸으로, <code>YYYY-MM-DDT00:00:00.000Z</code>로 적지만 timezone 개념이 없다. 그리드 계산이 UTC로만 돌아서 실행 환경과 무관하다. <strong>시점</strong>은 <code>onChange</code>로 나가는 값으로, <code>displayTimezone</code>에서 그날 자정인 실제 순간이다. 같은 1월 15일이 뉴욕에서는 <code>2026-01-15T05:00:00.000Z</code>, 서울에서는 <code>2026-01-14T15:00:00.000Z</code>다.</p>
<p><picture><source type="image/webp" srcset="/content/260617/2.720w.webp 720w, /content/260617/2.1440w.webp 1440w, /content/260617/2.1640w.webp 1640w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/2.png" alt="calendar 좌표 2026-01-15T00:00:00.000Z는 civilMidnightFromUtcDay로 뉴욕과 서울의 시점이 되고, calendarDayFromInstant로 다시 좌표로 돌아온다." style="max-width:min(720px, 100%);height:auto;" width="1640" height="1040" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>둘을 잇는 함수는 <code>@kalyx/core</code>의 두 개뿐이다. 좌표를 시점으로 바꾸는 <code>civilMidnightFromUtcDay</code>와 그 반대인 <code>calendarDayFromInstant</code>다. 뒤쪽은 "그 시점이 이 존에서 며칠인가"를 읽어 UTC 자정 좌표로 다시 적는다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:187-190</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> p</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> partsInTimezone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(iso), timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">UTC</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p.year, p.month </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, p.day)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>규칙은 방향이다. 칸을 골라 값을 커밋할 때는 좌표를 시점으로, 저장값에서 보여줄 월과 포커스를 정할 때는 시점을 좌표로 바꾼다. DatePicker Root의 <code>selectDate</code>는 변환하고, 변환된 시점으로 제약을 검사하고, 통과하면 커밋한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DatePicker/Root.tsx:182-194</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> normalized</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  coordinate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> displayTimezone</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, displayTimezone)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> coordinate;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (normalized </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDateDisabled</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized, disabledRules, adapter, displayTimezone)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isControlled) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setUncontrolledValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.(normalized);</span></span></code></pre></figure>
<p>호출 자리는 여기만이 아니다. <code>packages/react/src</code>를 검색하면 두 함수가 DatePicker, RangePicker, DateTimePicker의 Root와 Calendar, Presets, 키보드 이동 유틸, headless 훅 여섯 개에 흩어져 있다. 그래도 날짜 칸을 커밋하거나 뷰를 정하는 경로는 전부 둘 중 하나를 지난다. 시각 커밋은 <code>setTimeInTimezone</code>이 맡는데, 안에서는 같은 내부 함수 <code>resolveCivilDateTime</code>으로 변환한다.</p>
<p>이 계약은 속성 테스트(property-based test)로 지킨다. 모든 좌표 <code>c</code>와 존 <code>z</code>에 대해 <code>calendarDayFromInstant(civilMidnightFromUtcDay(c, z), z) === c</code>여야 한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/__tests__/timezone.property.test.ts:86-94</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">it</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'round-trips every UTC calendar coordinate through civil midnight'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assert</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">property</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">utcCalendarCoordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">zone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">coordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> instant</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(instant, timeZone)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toBe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    RUNS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><a href="https://fast-check.dev/" target="_blank" rel="noopener noreferrer">fast-check</a>가 2020년에서 2045년 사이의 날짜를 무작위로 만들어, +5:45 Kathmandu, +14 Kiritimati, -11 Niue 등 대표 존 14개와 조합해 300번 돈다. 바로 다음 테스트는 같은 왕복을 <code>Intl.supportedValuesOf('timeZone')</code>이 돌려주는 존 전부에 대해 존마다 12번씩 검사한다. 필자 로컬 Node 24.16에서 그 목록은 418개다. 1.4.8에서는 DST 전환마다 경계 시각의 변환 결과를 Temporal 구현과 대조하는 전수 테스트가 더해졌다.</p>
<p>그렇다면 사용자가 넘긴 <code>"2026-01-15T00:00:00.000Z"</code>를 라이브러리가 알아서 시점으로 정규화하면 되지 않을까? 문자열만 보고는 좌표인지 시점인지 알 수 없어서 이 길은 막혀 있다. 이미 시점인 서울 값 <code>2026-01-14T15:00:00.000Z</code>에 <code>civilMidnightFromUtcDay</code>를 다시 걸면 <code>2026-01-13T15:00:00.000Z</code>가 되고, 서울처럼 오프셋이 양수인 존에서는 렌더마다 하루씩 계속 밀린다. (뉴욕 값은 다시 걸어도 그대로다) 여러 번 적용해도 결과가 같은 <code>startOfDayInTimezone</code>으로 바꾸면 밀림은 없지만 좌표를 시점으로 바꾸는 원래 일을 못 한다.</p>
<p>그래서 정규화를 포기하고 계약을 문서로 고정했다. 소비자는 picker가 내보낸 값을 그대로 넘겨야 한다. 솔직히 소비자에게 떠넘긴 비용이고, <code>ISODateString</code>이 <code>string</code>의 별칭이라 컴파일러도 막아 주지 못한다.</p>
<h3 id="intl만으로-dst를-푸는-방법"><a class="anchor" href="#intl만으로-dst를-푸는-방법">Intl만으로 DST를 푸는 방법</a></h3>
<p>좌표를 시점으로 바꾸려면 "그 존의 그날 00:00"이 UTC로 언제인지 알아야 하는데, 오프셋은 시점을 알아야 구할 수 있다. 게다가 DST 전환일에는 현지 시각이 없거나(spring forward) 두 번 있다(fall back).</p>
<p>Kalyx는 <code>date-fns-tz</code> 같은 라이브러리 없이 이것을 푼다. <code>Intl.DateTimeFormat(...).formatToParts</code>로 "이 UTC 순간이 그 존에서 몇 시인가"를 물어 오프셋을 재고, 그 오프셋을 요청 시각의 하루 전과 하루 뒤 두 지점에서 읽는다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:251-254, 259</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">probeEpoch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getTimezoneOffsetMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probeEpoch).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), timeZone) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochBefore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochAfter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (epochBefore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> epochAfter) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(epochBefore).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>civilEpoch</code>는 원하는 현지 시각을 UTC인 것처럼 읽은 값이다. 오프셋은 ±14시간 안이라 하루 전과 하루 뒤는 근처 전환의 앞과 뒤 오프셋을 하나씩 준다. 둘이 같으면 전환이 없으니 <code>formatToParts</code> 두 번으로 끝나고, 그리드 42칸마다 한 번씩 부르는 이 빠른 경로가 비용을 결정한다. (48시간 안에 전환이 하나라는 전제는 2020~2045년 모든 존에서 성립한다)</p>
<p>둘이 다르면 두 후보를 그 존에서 다시 읽어 요청한 시각과 맞는지 본다(<code>timezone.ts:261-279</code>). fall back의 겹침에서는 둘 다 맞아 전환 전 오프셋으로 만든 이른 쪽을 고르고, spring forward의 틈에서는 둘 다 맞지 않아 같은 쪽을 골라 틈 길이만큼 앞으로 민다. 실제로 돌린 결과는 이렇다.</p>
<table>
<thead>
<tr>
<th>요청</th>
<th>상황</th>
<th>1.4.7</th>
<th>1.4.8</th>
</tr>
</thead>
<tbody>
<tr>
<td>New_York 2026-03-08 02:30</td>
<td>없는 시각</td>
<td><code>2026-03-08T07:30:00.000Z</code></td>
<td>같음 (03:30 EDT, 앞으로)</td>
</tr>
<tr>
<td>New_York 2026-11-01 01:30</td>
<td>두 번 있는 시각</td>
<td><code>2026-11-01T05:30:00.000Z</code></td>
<td>같음 (01:30 EDT, 이른 쪽)</td>
</tr>
<tr>
<td>London 2026-10-25 01:30</td>
<td>두 번 있는 시각</td>
<td><code>2026-10-25T01:30:00.000Z</code> (늦은 쪽)</td>
<td><code>2026-10-25T00:30:00.000Z</code> (01:30 BST, 이른 쪽)</td>
</tr>
</tbody>
</table>
<p>"틈은 앞으로, 모호함은 이른 쪽"은 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime" target="_blank" rel="noopener noreferrer">MDN의 Temporal.ZonedDateTime 문서</a>가 설명하는 <code>disambiguation</code> 기본값 <code>"compatible"</code>과 같다. 1.4.7의 런던 행이 어긋난 이유는 뒤에서 다룬다.</p>
<h3 id="adapter-경계는-문자열-메서드-21개다"><a class="anchor" href="#adapter-경계는-문자열-메서드-21개다">adapter 경계는 문자열 메서드 21개다</a></h3>
<p>timezone을 core가 계산한다면 date-fns나 dayjs는 무엇을 맡을까? 날짜 산술과 파싱이다. 그 경계인 <code>DateAdapter</code>는 메서드 21개이고 날짜 인자와 반환값이 전부 ISO 문자열이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/types.ts:71-102 (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DateAdapter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  parse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">formatStr</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  addDays</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  isSameDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  startOfDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  today</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // addMonths, isBefore, startOfMonth, getYear 등 15개</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>timezone을 받는 메서드는 <code>format</code>, <code>isSameDay</code>, <code>startOfDay</code>, <code>today</code> 넷이고, 그 넷도 계산은 core로 넘긴다. date-fns adapter의 <code>format</code>은 <code>timezone</code>이 있으면 core의 <code>formatInTimezone</code>을 바로 부른다(<code>packages/adapter-date-fns/src/index.ts:112-115</code>). 그래서 어떤 adapter를 쓰든 timezone 답은 같은 코드에서 나오고, 세 adapter(date-fns, dayjs, luxon)는 <code>@kalyx/core/test-helpers</code>의 <code>runAdapterConformanceTests</code>를 각자 돌려 같은 답을 내는지 확인한다.</p>
<p>두 엔트리도 이 경계에서 갈린다. 기본 엔트리 <code>@kalyx/react</code>는 모듈 로드 시 <code>setDefaultAdapter(DateFnsAdapter)</code>를 호출해 설치하면 바로 동작한다(<code>packages/react/src/index.ts:9-11</code>). <code>@kalyx/react/headless</code>는 이 호출이 없고 번들이 분리돼 date-fns 코드가 들어가지 않는다.</p>
<p>이 경계 모양에는 대가가 있었다. 필자는 2026년 6월에 Temporal adapter를 접었다. Temporal의 가치는 <code>PlainDate</code>, <code>ZonedDateTime</code>처럼 <strong>타입이 의미를 들고 다니는 데</strong> 있는데, 경계가 문자열이면 그 의미가 통과하지 못한다. 감싸도 문자열로 평탄화될 뿐이라 정확성 이득이 없다고 판단했다. 돌아보면 앞 절의 "좌표와 시점이 같은 <code>string</code>" 문제는 Temporal이 <code>PlainDate</code>와 <code>Instant</code>로 타입에서 푸는 바로 그 문제다. 문자열 경계가 adapter 교체를 쉽게 만든 대신 타입으로 풀 길은 막은 셈이다.</p>
<h3 id="일곱-picker는-컨텍스트-세-개의-조합이다"><a class="anchor" href="#일곱-picker는-컨텍스트-세-개의-조합이다">일곱 picker는 컨텍스트 세 개의 조합이다</a></h3>
<p>Kalyx의 picker는 DatePicker, RangePicker, TimePicker, DateTimePicker, MonthPicker, YearPicker, WeekPicker 일곱 종이다. 중요한 것은 개수보다 <strong>일곱 개가 독립 구현이 아니라는 점</strong>이다. 컨텍스트는 <code>DatePickerContext</code>, <code>RangePickerContext</code>, <code>TimePickerContext</code> 세 개뿐이다.</p>
<table>
<thead>
<tr>
<th>picker</th>
<th>상태를 가진 Root</th>
<th>컨텍스트</th>
<th>차이를 만드는 곳</th>
</tr>
</thead>
<tbody>
<tr>
<td>DatePicker</td>
<td><code>DatePickerRoot</code></td>
<td>Date</td>
<td></td>
</tr>
<tr>
<td>MonthPicker, YearPicker</td>
<td>DatePicker의 Root 구현</td>
<td>Date</td>
<td><code>selectionGranularity</code></td>
</tr>
<tr>
<td>RangePicker</td>
<td><code>RangePickerRoot</code></td>
<td>Range</td>
<td></td>
</tr>
<tr>
<td>WeekPicker</td>
<td><code>RangePickerRoot</code> 그대로</td>
<td>Range</td>
<td>Calendar에 <code>selectionMode="week"</code></td>
</tr>
<tr>
<td>TimePicker</td>
<td><code>TimePickerRoot</code></td>
<td>Time</td>
<td></td>
</tr>
<tr>
<td>DateTimePicker</td>
<td>자체 Root</td>
<td>Date와 Time 중첩</td>
<td>두 Provider를 겹친다</td>
</tr>
</tbody>
</table>
<p>MonthPicker의 Root는 표시 형식 기본값을 <code>yyyy-MM</code>으로 바꾸고 DatePicker의 Root 구현에 <code>selectionGranularity="month"</code>를 넘길 뿐이다(<code>MonthPicker/Root.tsx:11-20</code>). DatePicker의 <code>selectDate</code>는 granularity가 <code>month</code>면 좌표를 그 달 1일로 접은 뒤 앞에서 본 변환과 제약 검사를 그대로 탄다. DateTimePicker는 두 컨텍스트를 겹쳐 제공한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DateTimePicker/Root.tsx:401-402</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{dateContext}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{timeContext}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>그래서 <code>DateTimePicker.Calendar</code>는 <code>DatePicker.Calendar</code>와 같은 컴포넌트이고, 자기가 어느 picker 안에 있는지 모른다. 한 곳의 수정이 같은 컴포넌트를 쓰는 picker 전부에 닿고, 한 곳의 결함도 전부에 닿는다. 앞 도표에서 DatePicker 하나가 전부의 74% 였던 이유도 이 공유 기반이다.</p>
<h2 id="계약을-지키는-데-든-비용"><a class="anchor" href="#계약을-지키는-데-든-비용">계약을 지키는 데 든 비용</a></h2>
<p>1.0 이후 석 달은 새 기능보다 이 계약을 확인하는 데 썼고, 예제 테스트로는 지나갔을 결함이 세 번 나왔다. 첫 번째는 속성 테스트가, 두 번째는 코드 교차 검토가, 세 번째는 이 글의 검증이 찾았다.</p>
<h3 id="sydney-10월-1일의-한-시간"><a class="anchor" href="#sydney-10월-1일의-한-시간">Sydney 10월 1일의 한 시간</a></h3>
<p>첫 번째는 "<code>startOfDayInTimezone</code>의 결과를 그 존에서 읽으면 00:00:00 이다"는 속성이 Australia/Sydney에서 깨진 것이다. 당시 구현은 오프셋을 한 번만 쟀다. Sydney는 2034년 10월 1일 02:00에 +10에서 +11로 넘어가는데, 그날 00:00은 아직 +10이라 정답은 <code>2034-09-30T14:00:00.000Z</code>다. 그런데 "10월 1일 00:00을 UTC로 읽은 지점"은 전환 이후라 +11을 돌려줬고, 결과는 전날 23:00이었다. 이 반례는 회귀 테스트로 남아 있다(<code>timezone.property.test.ts:276</code>).</p>
<h3 id="서울에서만-통과하던-테스트"><a class="anchor" href="#서울에서만-통과하던-테스트">서울에서만 통과하던 테스트</a></h3>
<p>두 번째는 2026년 8월 3일 교차 검토에서 나왔다. calendar에서 선택 표시할 칸을 정하는 코드가 변환을 두 번 하고 있었다. 칸은 이미 좌표인데 칸과 저장값을 둘 다 그 존의 날짜로 변환한 것이다. "1월 15일"을 고른 결과는 이랬다.</p>
<table>
<thead>
<tr>
<th>존</th>
<th>저장값</th>
<th>선택 표시된 칸</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Asia/Seoul</code> (+9)</td>
<td><code>2026-01-14T15:00:00.000Z</code></td>
<td>15일 (맞음)</td>
</tr>
<tr>
<td><code>America/New_York</code> (-5)</td>
<td><code>2026-01-15T05:00:00.000Z</code></td>
<td>16일 (틀림)</td>
</tr>
</tbody>
</table>
<p>양수 오프셋 존에서는 두 번의 밀림이 상쇄돼 우연히 맞았고, 기존 테스트는 서울만 덮고 있었다. 같은 점검에서 반대 방향 위반도 나왔다. 보여줄 월을 정할 때 시점에 <code>startOfMonth</code>를 바로 걸어서, 서울에서 1월 1일을 값으로 주면 12월 달력이 열렸다.</p>
<p>기전은 달라도 어긴 규칙은 하나다. 변환은 방향마다 정해진 함수로 한 번만 한다.</p>
<p>이 사고로 core의 왕복 속성 테스트가 "대표 존 몇 개"에서 "런타임이 아는 존 전부"로 넓어졌다. (React 쪽 컴포넌트 테스트는 아직 <code>America/New_York</code> 같은 대표 존을 쓴다) <strong>부호가 바뀌는 자리에서만 드러나는 결함은 표본으로 잡히지 않는다.</strong></p>
<h3 id="런던에서-늦게-잡히던-0130"><a class="anchor" href="#런던에서-늦게-잡히던-0130">런던에서 늦게 잡히던 01:30</a></h3>
<p>세 번째는 이 글의 DST 표를 다른 존으로 넓히다 나왔다. 1.4.7은 요청 시각을 UTC로 읽은 지점부터 오프셋을 쟀는데, 전환 뒤 오프셋이 0 이상인 존에서는 그 지점이 이미 전환 뒤라 늦은 오프셋으로 수렴했다. temporal-polyfill로 418개 존의 2020~2045년 전환을 전수 대조하니 겹침 전환 3,395건 중 1,908건에서 늦은 쪽을 골랐고 84개 존에 걸쳐 있었다. (틈 전환 3,394건은 모두 맞았고, 오프셋이 음수인 뉴욕은 우연히 맞았다)</p>
<p><a href="https://github.com/jiji-hoon96/kalyx/pull/226" target="_blank" rel="noopener noreferrer">#226</a>에서 하루 전과 뒤를 읽도록 고쳐 <code>@kalyx/core</code> 1.4.8로 배포했고, 같은 대조를 <code>timezone.dst-oracle.test.ts</code>로 남겼다. (temporal-polyfill은 테스트에서만 쓴다) <code>'earlier'</code>라고 잘못 적었던 소스 주석도 <code>'compatible'</code>로 고쳤다. 이번에도 <strong>표본이 한쪽 부호에 몰려 있었다.</strong></p>
<h3 id="정확성에-쓴-3kb"><a class="anchor" href="#정확성에-쓴-3kb">정확성에 쓴 3KB</a></h3>
<p>이 수정들에는 코드가 들었다. Kalyx는 기본 엔트리 번들에 CI 천장을 두고, 넘기는 PR은 필수 체크에서 실패한다. 12KB에서 시작해 기능이 들어올 때마다 1KB씩 올리던 그 천장을, 2026년 8월 timezone과 제약 정확성을 전면 수정하면서 17KB에서 20KB로 한 번에 올렸다.</p>
<p>크기는 README 배지에 나가 있던 셀링 포인트였다. 음수 오프셋 존에서 하루씩 밀리는 날짜 picker는 작든 크든 쓸 수 없다.</p>
<p><strong>작다와 맞다 중 하나를 골라야 한다면 맞는 쪽이다.</strong> 지금 천장은 빠듯하다. 레포의 2026-09-11 번들 바이트 지도 문서에 따르면 <code>dist/index.cjs</code>를 Node 기본 gzip으로 잰 값이 20,259B, 천장이 20,480B로 여유가 221B다. (의존성을 외부로 남긴 자기 파일 크기라 앞 도표와는 다른 양이다) 다음 기능은 바이트를 먼저 회수해야 들어온다.</p>
<h2 id="처음-생각과-달라진-결론"><a class="anchor" href="#처음-생각과-달라진-결론">처음 생각과 달라진 결론</a></h2>
<p>필자가 원한 것은 복잡한 날짜 라이브러리를 선언적으로 쓰는 것이었다. 만들고 나서 보니 선언적인 합성 API와 headless 완성형은 이미 있었다. 남은 차이는 더 안쪽에 있었다. <strong>값을 시점 하나로 고정하고, 좌표와 시점 사이의 변환을 두 함수로 좁히고, 그 왕복을 모든 timezone에서 테스트로 지키는 것.</strong> Intl 기반 DST 처리, 문자열 adapter 경계, 컨텍스트 세 개로 만든 일곱 picker는 그 결정의 결과다.</p>
<p>공부라는 목표로 보면 가장 크게 배운 것은 "맞다"를 주장하는 방법이었다. 처음 글의 번들 비교는 다른 양을 재고 있었고, 서울에서 통과하던 테스트는 뉴욕에서 틀리고 있었다. 무엇을 쟀고 어떤 표본으로 확인했는지를 함께 적지 않으면 숫자는 쉽게 자랑이 된다.</p>
<p>한계도 분명하다. 메인테이너는 필자 한 명이고, React 19 전용이며, <code>@kalyx/react</code>의 npm 다운로드는 2026년 9월 5일부터 11일까지 200회였고 그중 156회가 1.4.7 배포일 하루에 몰려 있다. 좌표와 시점을 타입으로 가르지 못하는 문제는 문서로만 막고 있고, 스타일 접점인 <code>classNames</code>와 <code>data-*</code> 속성을 공개 API로 보장할지도 정하지 못했다. 접근성은 calendar <code>grid</code>, 입력 칸 <code>combobox</code>, 시간 목록 <code>listbox</code> 역할과 방향키, Home/End, PageUp/PageDown 탐색을 갖췄고 테스트 파일 8개의 <code>jest-axe</code> 검사를 CI에서 돌리지만, React Aria 만큼 검증됐다고 말할 근거는 아직 없다.</p>
<p>그래서 이미 MUI 앱이라면 MUI X를, 세그먼트 입력과 검증된 접근성이 우선이라면 React Aria를, calendar 하나면 react-day-picker를 먼저 보는 편이 맞다. 값이 JSON으로 오가는 폼에서 날짜가 하루씩 밀리는 문제를 겪어 봤다면 그때 Kalyx를 살펴봐 주시면 좋겠고, 더 좋은 풀이를 알고 있다면 GitHub Issue로 알려 주시면 감사하겠다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">pnpm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @kalyx/react</span></span></code></pre></figure>
<p>문서 사이트의 <a href="https://kalyx-docs-site.vercel.app/playground" target="_blank" rel="noopener noreferrer">Playground</a>에서 일곱 picker와 locale, timezone 설정을 직접 바꿔 볼 수 있다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://kalyx-docs-site.vercel.app/" target="_blank" rel="noopener noreferrer">Kalyx 공식 문서 사이트</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://mui.com/x/react-date-pickers/timezone/" target="_blank" rel="noopener noreferrer">MUI, Date and Time Pickers Timezone</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://floating-ui.com/" target="_blank" rel="noopener noreferrer">Floating UI 공식 문서</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>라이브러리</category>
            <category>React</category>
            <category>DatePicker</category>
            <category>오픈소스</category>
        </item>
        <item>
            <title><![CDATA[토큰 절약법]]></title>
            <link>https://hooninedev.com/260611</link>
            <guid isPermaLink="false">https://hooninedev.com/260611</guid>
            <pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 AI 토큰을 절약하는 방법에 대한 이야기를 해보려고 한다. 옛날에는 성능과 비용보다는 결과와 과정에 집중했다. AI가 산출한 결과물에 허점이 많다 보니 그것들을 검증해야 했고, 빠르게 결과물을 뽑아야 하다 보니 나를 비롯한 수많은 사람들이 토큰이 부족하면 결제하고 더 높은 구독제를 활용했다. 필자도 그랬다. (사실 처음 몇 달은 토큰 비용...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 AI 토큰을 절약하는 방법에 대한 이야기를 해보려고 한다.</p>
<p>옛날에는 성능과 비용보다는 결과와 과정에 집중했다. AI가 산출한 결과물에 허점이 많다 보니 그것들을 검증해야 했고, 빠르게 결과물을 뽑아야 하다 보니 나를 비롯한 수많은 사람들이 토큰이 부족하면 결제하고 더 높은 구독제를 활용했다. 필자도 그랬다. (사실 처음 몇 달은 토큰 비용이 얼마나 들었는지조차 신경 쓰지 않았다.)</p>
<p>하지만 어느 시점이 지나가니 점점 토큰 사용에 대한 경각심이 생기게 되었다. 개인은 월 결제에 대한 부담감이 컸고, 기업은 인건비와 비용에 대한 고민이 깊어졌다. <a href="/260524" target="_blank" rel="noopener noreferrer">MCP와 function calling</a>에서는 에이전트를 외부 시스템에 잇는 프로토콜을, <a href="/260526" target="_blank" rel="noopener noreferrer">Code intelligence의 네 계층</a>에서는 코드 탐색 도구와 GitHub Trending을, <a href="/260529" target="_blank" rel="noopener noreferrer">Context file</a>에서는 에이전트가 읽는 규칙 파일을 정리했다. 이렇게 지금까지의 글은 어떤 도구가 있고 그것을 어떻게 잘 활용할지에 더 집중해왔다. 필자도 그 방향이 필요하다고 생각하지만 시간이 지나면 결국 비용에 대한 이야기를 가장 궁금해할 것이라고 생각한다.</p>
<p>토큰이 정확히 무엇이고 어떻게 만들어지는지(BPE), prompt caching 이 단가를 깎는 트랜스포머 안쪽 동작은 무엇인지 같은 토큰의 동작 원리는 <a href="/260610" target="_blank" rel="noopener noreferrer">토큰의 원리</a> 글에서 따로 정리해 두었다. 이 글은 그 토대 위에서, 비용이 어떻게 청구되고 어디서 비효율이 만들어지는지를 먼저 살펴본 뒤 검증된 절약 패턴을 정리한다. 주요 패턴에는 같은 작업을 여러 전략으로 호출해 직접 측정한 토큰 사용량을 붙였다.</p>
<h2 id="토큰-비용은-어떻게-발생하는가"><a class="anchor" href="#토큰-비용은-어떻게-발생하는가">토큰 비용은 어떻게 발생하는가?</a></h2>
<p>토큰 비용이 어떻게 발생하는지, 어떤 원리로 청구되는지를 아주 가볍고 핵심적으로 살펴보자.</p>
<p>우리가 보낸 모든 텍스트(시스템 프롬프트, 도구 정의, 대화 기록, 사용자 메시지)가 입력 토큰이 되고, 모델이 만들어낸 응답이 출력 토큰이 된다. 모델 입장에서는 매번 호출이 처음 보는 입력이다. 우리가 어제 한 대화든 1분 전 호출이든, 새 호출에서는 같은 내용을 다시 통째로 입력으로 보낸다. (이 단순한 사실이 토큰 비용의 핵심이다. 모델은 기억이 없고, 우리가 매번 다시 들려준다.)</p>
<p>여기에 한 가지 변수가 더 붙는다. 같은 호출을 반복할 때 정적인 부분을 매번 새로 전송하는 비용이 너무 크다 보니, 주요 LLM 제공자들은 prompt caching 이라는 장치를 도입했다. 정적인 입력을 캐시에 한 번 저장해두고 다음 호출에서는 캐시에서 읽은 토큰만 훨씬 싼 가격으로 청구한다. (토큰이 BPE 로 어떻게 만들어지고, 캐시가 트랜스포머의 KV 캐시를 어떻게 재활용해 단가를 깎는지 같은 동작 원리는 토큰의 원리 글에서 따로 다뤘다. 여기서는 그 원리가 비용으로 어떻게 환산되는지에 집중한다.)</p>
<h3 id="입력-토큰-출력-토큰-그리고-캐시-토큰"><a class="anchor" href="#입력-토큰-출력-토큰-그리고-캐시-토큰">입력 토큰, 출력 토큰, 그리고 캐시 토큰</a></h3>
<p>Anthropic SDK 가 응답으로 돌려주는 <code>usage</code> 객체에서 네 가지 필드를 살펴보자.</p>
<p><picture><source type="image/webp" srcset="/content/260611/1.720w.webp 720w, /content/260611/1.916w.webp 916w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/1.png" alt="Anthropic SDK 응답의 usage 객체. input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens 필드가 보인다" width="916" height="638" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<ul>
<li><code>input_tokens</code> 보낸 입력 중 캐시 읽기를 제외한 부분</li>
<li><code>output_tokens</code> 모델이 생성한 응답</li>
<li><code>cache_creation_input_tokens</code> 이번 호출에서 처음 캐시에 저장된 토큰</li>
<li><code>cache_read_input_tokens</code> 기존 캐시에서 다시 읽은 토큰</li>
</ul>
<p>이 네 필드에 각각 다른 단가가 곱해진다. 단가가 가장 비싼 것은 출력 토큰이고, 가장 싼 것은 캐시에서 읽은 토큰이다. Anthropic 공식 문서 기준으로 캐시 읽기는 기본 입력 단가의 0.1배, 즉 정확히 10% 수준이다. 캐시 쓰기는 5분 TTL(Time To Live, 캐시 유효 시간) 기준 1.25배, 1시간 TTL 기준 2배가 붙는다. 첫 호출에서 살짝 더 내고, 두 번째 호출부터 90%를 깎는 구조인 셈이다.</p>
<p>다만 캐시에는 잘 알려지지 않은 제약이 몇 가지 있다. 캐시 가능한 최소 토큰 수가 모델마다, 또 같은 패밀리 안에서도 버전마다 다르다. Anthropic 공식 문서 기준으로 Sonnet 4.6 과 Opus 4.8 은 1,024 토큰, Opus 4.7 은 2,048 토큰, Haiku 4.5 와 구세대 Opus 4.5/4.6 은 4,096 토큰이다. 그보다 짧은 프롬프트는 <code>cache_control</code> 을 걸어도 조용히 캐시되지 않는다. <code>cache_control</code> 브레이크포인트는 한 요청에 최대 4개까지만 둘 수 있고, 캐시는 <code>tools</code> → <code>system</code> → <code>messages</code> 순서의 계층으로 읽힌다. 그래서 앞쪽의 도구 정의 하나만 바뀌어도 그 뒤 캐시 전체가 무효화된다.</p>
<p><img src="/content/260611/2.webp" alt="프롬프트 캐시가 앞쪽부터 순서대로 일치해야 하며 앞부분 변경 시 이후 캐시가 무효화되는 구조" width="1680" height="840" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>캐시가 어떻게 단가의 90% 를 깎는지는 트랜스포머의 KV 캐시를 호출 사이에서 재활용하는 구조에서 나오는데, 그 안쪽 동작은 토큰의 원리 글에서 자세히 다뤘다. 비용 관점에서 기억해 둘 핵심은 하나다. 캐시 적중은 프리픽스가 정확히 같을 때만 일어나므로, <strong>정적인 부분은 앞에, 매번 바뀌는 동적인 부분은 뒤에</strong> 두어야 캐시가 깨지지 않는다. 타임스탬프 한 글자가 프롬프트 앞부분에 들어가도 그 뒤 캐시 전체가 무효화된다.</p>
<p>이쯤에서 자연스럽게 떠오르는 의문이 있다. "그런데 멀티턴 대화는 매 턴 입력이 달라지는데, 그럼 거의 매번 캐시가 깨지는 것 아닌가?" 결론부터 말하면 그렇지 않다. 대화의 입력은 매번 새로 쓰는 한 덩어리가 아니라, <strong>앞에 쌓인 내용을 그대로 두고 뒤에 새 발화만 덧붙이는(append) 구조</strong>이기 때문이다. 시스템 프롬프트와 도구 정의, 그리고 지난 턴의 질문과 답변은 한번 확정되면 그대로 박제되고, 이번에 새로 친 질문만 맨 끝에 붙는다. 그래서 이번 턴 입력을 지난 턴 입력과 앞에서부터 비교하면, 처음 달라지는 지점은 늘 맨 끝의 새 질문이다. 전체로 보면 입력이 매번 다르지만, 프리픽스로 보면 앞쪽 대부분이 그대로라 캐시는 살아남는다. (캐시가 매 턴 깨지는 건 오히려 동적인 값을 프롬프트 앞쪽에 박아 넣는 잘못된 설계일 때뿐이다. 그래서 Claude Code 나 opencode 같은 도구는 앞쪽을 고정하고 뒤에만 덧붙이도록 설계되어 있고, 사용자가 캐시를 위해 따로 손댈 설정도 없다. 입력의 오타나 맞춤법이 캐시에 영향을 주지 않는 것도 같은 이유다. 그 변동은 늘 캐시 사정권 밖인 맨 뒤에서 일어난다.)</p>
<h3 id="ai-비교"><a class="anchor" href="#ai-비교">AI 비교</a></h3>
<p>2026년 6월 기준 Anthropic, OpenAI, Google 의 공식 가격표를 한 표에 모아보면 흐름이 한눈에 들어온다. (단위는 백만 토큰당 USD. 코딩 워크플로에서 일상적으로 쓰는 모델 위주로 추렸다.)</p>
<table>
<thead>
<tr>
<th>프로바이더</th>
<th>모델</th>
<th>input</th>
<th>cached input</th>
<th>output</th>
</tr>
</thead>
<tbody>
<tr>
<td>Anthropic</td>
<td>Claude Opus 4.8</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$25.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Sonnet 4.6</td>
<td>$3.00</td>
<td>$0.30</td>
<td>$15.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Haiku 4.5</td>
<td>$1.00</td>
<td>$0.10</td>
<td>$5.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$30.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5 Pro</td>
<td>$30.00</td>
<td>$3.00</td>
<td>$180.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.1 Pro</td>
<td>$2.00</td>
<td>$0.20</td>
<td>$12.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.5 Flash</td>
<td>$1.50</td>
<td>$0.15</td>
<td>$9.00</td>
</tr>
</tbody>
</table>
<p>세 프로바이더 모두 출력 단가가 입력의 5~6배다. 즉 출력이 길어지면 같은 답이라도 비용이 가파르게 오른다. 같은 티어 안에서도 모델 간 단가 차이가 3~6배 벌어진다. 단순 계산만 봐도, <strong>출력을 짧게 받고, 같은 답이면 더 싼 모델을 쓰고, 정적인 입력은 캐시에 올린다</strong> 는 세 가지 원칙이 토큰 비용 절감의 큰 줄기다. (계산을 한 번 더 해보면 흥미롭다. 매 호출 1만 토큰짜리 정적 컨텍스트를 100번 재사용한다고 했을 때, GPT-5.5 기본 단가로는 $5, 캐시에서 읽은 토큰으로는 $0.50. 즉 한 컨텍스트를 100번 다시 쓸 때 캐시가 효과적으로 깎아주는 비용이 $4.5 인데, 첫 호출의 캐시 쓰기 부가비가 거의 없으니 두 번째 호출부터 바로 흑자다.)</p>
<h3 id="캐시-메커니즘의-차이"><a class="anchor" href="#캐시-메커니즘의-차이">캐시 메커니즘의 차이</a></h3>
<p>비용 절감 비율이 비슷해도 안쪽 구조는 프로바이더마다 다르다. 어떤 차이가 왜 생기는지 알아두면 정책 결정이 쉬워진다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>Anthropic</th>
<th>OpenAI</th>
<th>Google Gemini</th>
</tr>
</thead>
<tbody>
<tr>
<td>트리거 방식</td>
<td>명시적 <code>cache_control</code> 브레이크포인트</td>
<td>자동 (코드 변경 불요)</td>
<td>자동(implicit) + 명시적(explicit) 동시 지원</td>
</tr>
<tr>
<td>최소 캐시 토큰</td>
<td>1,024 ~ 4,096 (모델별)</td>
<td>1,024+ (128 단위 증가)</td>
<td>2,048 ~ 4,096 (모델별)</td>
</tr>
<tr>
<td>캐시 쓰기 비용</td>
<td>1.25x(5분) / 2x(1h) input 단가</td>
<td>무료</td>
<td>무료 (대신 시간당 저장료)</td>
</tr>
<tr>
<td>캐시 읽기 비용</td>
<td>0.1x input 단가</td>
<td>약 0.1x input 단가</td>
<td>0.1x input 단가</td>
</tr>
<tr>
<td>TTL</td>
<td><a href="https://github.com/anthropics/claude-code/issues/46829" target="_blank" rel="noopener noreferrer">5분 또는 1시간 (사용자 선택)</a></td>
<td>5~10분 inactivity 기본, 최대 1시간 (확장 시 24시간)</td>
<td>사용자 지정 (시간당 저장료 과금)</td>
</tr>
<tr>
<td>추가 비용</td>
<td>없음</td>
<td>없음</td>
<td>저장료 Flash $1/M·시간, Pro $4.50/M·시간</td>
</tr>
</tbody>
</table>
<p>세 모델의 설계 철학이 그대로 드러난다. <strong>Anthropic</strong> 은 캐시할 부분을 사용자가 명시적으로 잡아두는 대신, 첫 호출에서 약간의 프리미엄(1.25배)을 받고 그 뒤로는 깊이 깎아준다. 프리픽스 위치를 통제하기 쉬워 캐시 읽기율이 예측 가능하다. <strong>OpenAI</strong> 는 정반대로 모든 걸 자동에 맡긴다. 1,024 토큰 이상이면 자동으로 캐시되고 추가 비용도 없는 대신, 사용자가 캐시 동작에 개입할 여지가 적다. <strong>Google</strong> 은 두 방식을 동시에 제공하면서, 캐시를 사용자가 명시적으로 제어할 때 저장료를 따로 매긴다. 짧고 빈번한 캐시 사용이라면 암묵적 캐싱이 유리하고, 1시간 이상 보관되는 큰 컨텍스트라면 명시적 캐싱 + 저장료 모델이 적합하다는 식이다.</p>
<h3 id="도구-정의와-토크나이저"><a class="anchor" href="#도구-정의와-토크나이저">도구 정의와 토크나이저</a></h3>
<p>여기에 의외로 큰 영향을 주는 변수가 두 가지 더 있다.</p>
<p>첫째는 도구 정의다. MCP 서버를 여러 개 붙이는 환경에서는 매 호출마다 도구 이름과 파라미터 스키마 전부가 입력에 함께 실리는데(이 비용을 실제로 얼마나 불어나게 하는지는 뒤에서 직접 측정해본다), 같은 도구를 그대로 쓰더라도 모델만 바꾸면 비용이 또 달라진다는 점은 여기서 짚어둘 만하다. Anthropic 공식 가격 문서를 보면 도구용 시스템 프롬프트의 길이 자체가 모델마다 다르다. Sonnet 4.6 과 Haiku 4.5 가 <code>tool_choice: auto</code> 기준 약 497 토큰인데, Opus 4.7 은 같은 자리에서 675 토큰을 쓰고 Opus 4.8 은 다시 290 토큰으로 내려와 있다. 모델 라우팅 전에 "현재 모델이 도구 정의를 얼마나 길게 풀어내는가" 부터 한번 확인해두면 의외로 큰 자릿수에서 차이가 난다.</p>
<p>둘째는 토크나이저(사람이 쓴 텍스트를 모델이 처리할 수 있는 토큰 단위로 잘게 쪼개는 도구) 자체의 효율이다. 토큰의 원리 글에서 짚었듯이 같은 텍스트라도 토크나이저에 따라 토큰 수가 달라진다. OpenAI 의 o200k_base 는 cl100k_base 대비 비영어권에서 토큰을 크게 절약하고, Anthropic 도 Opus 4.7 부터 새 토크나이저로 갈아타면서 같은 텍스트에 최대 35% 더 많은 토큰을 쓸 수 있다고 명시했다. 단가만 비교해 모델을 고르면 토크나이저 효율 차이에서 비용이 다시 새는 일이 생긴다. 모델 비교는 단가 × 예상 토큰 수의 곱으로 봐야 정직하다.</p>
<p>차이를 직접 확인해보면 한글에서 특히 두드러진다. OpenAI 의 두 토크나이저로 같은 문장을 토큰화해봤더니, 영어 기술 문장은 구버전과 신버전이 토큰 수가 똑같았지만 한글은 구버전(cl100k_base)이 신버전(o200k_base)보다 31~43% 더 많은 토큰을 썼다. 특히 눈여겨볼 것은 한글 문단이다. 167자짜리 한 문단이 구버전에서는 169토큰으로, <strong>글자 수보다 토큰이 더 많게</strong> 나왔다. 한글 한 글자가 평균적으로 한 토큰을 넘게 잡아먹은 셈이다.</p>
<p><picture><source type="image/webp" srcset="/content/260611/3.720w.webp 720w, /content/260611/3.1440w.webp 1440w, /content/260611/3.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/3.png" alt="토크나이저별 한글/영어 토큰 수 비교: cl100k vs o200k" style="max-width:min(720px, 100%);height:auto;" width="1680" height="697" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>구버전이 한글을 바이트 단위로 잘게 부수는 반면, 신버전은 "개발", "입니다" 같은 자주 쓰이는 덩어리를 통째로 한 토큰으로 묶기 때문이다. 단가가 같아도 한글 위주의 작업이라면 토크나이저 효율만으로 청구액이 1.5배 이상 갈릴 수 있다는 뜻이다.</p>
<p>여기까지 보면 자연스럽게 떠오르는 질문이 하나 있다. "그러면 우리는 도대체 어디서 비효율을 만들어내고 있는 걸까?"</p>
<h2 id="토큰을-낭비하는-흔한-패턴"><a class="anchor" href="#토큰을-낭비하는-흔한-패턴">토큰을 낭비하는 흔한 패턴</a></h2>
<p>AI 를 잘 쓴다고 생각하면서도 토큰이 비효율적으로 흘러나가는 상황은 의외로 흔하다. 필자 본인을 포함해 주변 개발자들의 패턴을 모아보면 몇 가지로 정리된다.</p>
<h3 id="너무-많은-mcp-서버와-도구-정의"><a class="anchor" href="#너무-많은-mcp-서버와-도구-정의">너무 많은 MCP 서버와 도구 정의</a></h3>
<p>앞 절에서 짚었지만 다시 강조하고 싶다. MCP 서버(Linear, GitHub, Notion, Figma, Slack, Sentry)는 한 번 붙여두면 잘 떼지 않는다. 사용하지 않는 도구 스키마가 매 호출마다 입력 토큰으로 부풀어 올라간다. Claude Code 가 기본 활성화한 MCP Tool Search 가 이 문제를 대응하기 위해 세션 시작 시에는 도구 이름과 서버 설명만 올라가고, 전체 스키마는 그 도구를 실제로 호출할 때 그제야 로드된다.</p>
<p>이 차이가 얼마나 큰지 직접 재봤다. 필자가 작업 중인 Claude Code 세션의 MCP 도구 27개(serena 10, claude.ai OAuth 4세트 8, figma 2, agentmemory 7)를 두고, <strong>같은 사용자 메시지를 두 설정으로 호출</strong>했다. 한 쪽은 MCP 가 하나도 안 깔린 상태, 다른 한 쪽은 27개가 다 attached 됐지만 모델이 호출할 수는 없는 상태이고 도구 호출 횟수는 양쪽 모두 0회로 맞췄다.</p>
<p><picture><source type="image/webp" srcset="/content/260611/6.720w.webp 720w, /content/260611/6.1440w.webp 1440w, /content/260611/6.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/6.png" alt="같은 질문을 MCP 없이와 MCP 27개를 붙이고 각각 호출한 결과. 입력 토큰이 41 에서 10,335 로, 1회 비용이 약 11.7배로 늘었다" width="1680" height="638" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>같은 질문, 같은 모델, 응답 의미도 동일한데, 입력 토큰만 <strong>41 → 10,335 (+10,294)</strong> 늘어났다 (Opus 4.7 기준). 1회 비용으로 환산하면 <strong>$0.0048 → $0.0563, 약 12배</strong> 비싸진다. 입력이 250배 부풀면서 prefill 부담이 함께 늘어 응답 시간도 <strong>+783ms</strong> 가 따라왔다. 액수 자체보다 와닿은 건, <strong>사용자가 그 턴에 MCP 도구를 한 번도 부르지 않았는데도 매 호출마다 내는 비용</strong> 이라는 점이다. 앞 문단의 Tool Search 가 막아주는 게 이 비용이다. (필자는 이 측정 후로 평소 안 쓰는 MCP 서버를 지속적으로 관리중이다.)</p>
<h3 id="컨텍스트-누적과-lost-in-the-middle"><a class="anchor" href="#컨텍스트-누적과-lost-in-the-middle">컨텍스트 누적과 Lost in the Middle</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/4.720w.webp 720w, /content/260611/4.840w.webp 840w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/4.jpg" alt="&#x22;Lost in the Middle&#x22; 논문 첫 페이지. 정답이 든 문서가 컨텍스트 중간에 있을 때 정확도가 가장 낮아지는 U 자 곡선이 실려 있다" width="840" height="727" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>긴 대화를 그대로 끌고 가면 호출당 입력 토큰만 늘어나는 게 아니라 모델의 정답률 자체가 떨어진다. Stanford 의 Liu 연구팀이 발표한 "Lost in the Middle" 논문은 핵심 정보가 컨텍스트의 시작이나 끝에 있을 때 가장 잘 찾고, 가운데에 묻혀 있을 때 성능이 눈에 띄게 떨어지는 U자형 곡선을 정량적으로 보였다. 토큰을 더 많이 쓰면서 답은 더 나빠지는 최악의 조합이 만들어지는 셈이다. 트랜스포머의 셀프 어텐션은 토큰 수의 제곱에 비례해 연산이 늘어나는 구조라, 컨텍스트가 길어질수록 한 토큰이 받는 어텐션의 절대량 자체가 묽어진다. 그리고 학습 분포상 시작과 끝에 중요한 정보가 몰려 있던 관성 때문에 가운데가 가장 먼저 약해진다.</p>
<p>좀 더 안쪽으로 들어가면, 모델이 토큰의 "위치" 를 다루는 방식 자체에 가운데를 묻는 두 가지 습성이 깔려 있다. 어렵게 들리지만 비유로 풀면 단순하다.</p>
<p>첫째, 모델은 <strong>가까이 있는 토큰에 더 귀를 기울인다.</strong> 요즘 공개 모델 대부분이 채택한 위치 표현 방식인 RoPE(Rotary Position Embedding, 회전 위치 임베딩)는 두 토큰이 멀리 떨어질수록 서로를 약하게 연결하도록 설계돼 있어서(거리가 멀수록 신호가 흐려지는 decay effect), 멀리 떨어진 토큰일수록 자연히 덜 주목받는다.</p>
<p>둘째, 모델은 <strong>맨 앞 토큰에 쓸데없이 많은 주의를 흘려보낸다.</strong> 모델은 매 순간 자기 주의력을 100% 로 꽉 채워 나눠 써야 한다는 제약(소프트맥스가 어텐션 총합을 1 로 강제한다)을 갖는데, 정작 지금 딱히 집중할 곳이 없을 때 남는 주의력을 어디론가 버려야 한다. 그 버리는 곳이 대개 문장 맨 앞의 첫 토큰이다. 이렇게 첫 토큰이 잉여 주의력을 빨아들이는 현상을 attention sink(어텐션 싱크, 주의력 배수구)라 부른다. (MIT 의 Xiao 연구팀이 StreamingLLM 논문에서 처음 정량적으로 분석한 구조인데, 첫 토큰에 중요한 정보가 있어서가 아니라 남는 주의력을 흘려보내는 배수구로 쓰이기 때문에 생기는 부산물에 가깝다.)</p>
<p>이 두 습성이 겹치면 주의력이 양 끝(가까운 최근 토큰 + 맨 앞 토큰)에 쏠리고, 정작 가운데에 묻힌 정보가 가장 약하게 다뤄진다. 중요한 건 이게 특정 모델의 버그가 아니라는 점이다. LLaMA, Mistral, Qwen 같은 공개 모델 대부분이 RoPE 계열을 쓰고 Claude·GPT 같은 비공개 모델도 비슷한 계열을 쓰는 것으로 알려져 있어, lost-in-the-middle 은 현대 트랜스포머 구조에 공통으로 깔린 편향에 가깝다.</p>
<p><picture><source type="image/webp" srcset="/content/260611/5.720w.webp 720w, /content/260611/5.1200w.webp 1200w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/5.png" alt="context rot 그래프. 입력 길이가 늘어날수록 Claude, Qwen, OpenAI, Gemini 네 모델의 정확도가 모두 떨어진다" width="1200" height="622" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>최근에는 이 현상을 <strong>context rot</strong> 이라는 이름으로 부른다. <a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma 연구팀</a>이 GPT-4.1, Claude 4, Gemini 2.5, Qwen3 를 포함한 18개 프론티어 모델을 같은 NIAH(needle in a haystack, 건초 더미에서 바늘 찾기) 과제에 던져본 분석은, 입력 토큰이 10k 에서 100k 이상으로 늘어났을 때 정확도가 모델에 따라 20~50% 까지 떨어지는 것을 정량적으로 보였다. 18개 모델 모두 길이가 늘면 성능이 떨어졌고, 가장 천천히 떨어진 것이 Claude 계열이었다. Anthropic 도 이를 트랜스포머의 n² 어텐션에서 비롯된 "어텐션 예산" 이 토큰마다 소진되는 문제로 설명한다. 결국 컨텍스트를 가볍게 유지하는 일은 비용 절감인 동시에 정답률을 지키는 일이기도 하다.</p>
<h3 id="무지성-서브에이전트-호출"><a class="anchor" href="#무지성-서브에이전트-호출">무지성 서브에이전트 호출</a></h3>
<p>서브에이전트가 좋다고 모든 작업을 위임하는 것도 함정이다. 서브에이전트는 부모와 별도의 컨텍스트에서 시작하므로 시스템 프롬프트와 도구 정의를 처음부터 다시 싣는 고정 비용이 든다. 짧은 셸 명령이나 단순 git 조회처럼 가벼운 작업을 서브에이전트에 위임하면, 본문 정리 효과보다 시작 비용이 더 커지는 손해 구조가 된다. Anthropic 의 multi-agent research system 공개 보고에 따르면 일반 채팅 대비 단일 에이전트가 약 4배, 멀티 에이전트 시스템이 약 15배의 토큰을 쓴다. 이 4배에서 15배의 추가 비용을 정당화할 만한 정답률 향상이 있을 때만 위임이 의미가 있다는 뜻이다.</p>
<p>같은 함정이 MCP 도구 부착에도 똑같이 적용된다. 도구 하나가 켜질 때마다 그 스키마가 매 턴 시스템 프롬프트에 실리고, 모델이 그 도구를 안 쓰는 턴에도 비용은 동일하게 청구된다. "안 쓸지도 모르니 다 켜두자" 가 직관적으로 안전해 보이지만, 실제 측정해보면 그렇지 않다.</p>
<p>필자가 facebook/react v19 의 reconciler 소스에 대해 같은 질문 배치를 아래 3가지 유형으로 분리해 정답률과 비용을 측정해봤다.</p>
<ul>
<li>도구 없이</li>
<li>단일 도구로 (CodeGraph, Serena, ripgrep, bare grep)</li>
<li>네 도구를 동시에 부착</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260611/9.720w.webp 720w, /content/260611/9.1440w.webp 1440w, /content/260611/9.1680w.webp 1680w" sizes="(max-width: 532px) calc(100vw - 32px), 500px"><img src="/content/260611/9.png" alt="codesearch-bench 도구 시나리오별 정답률과 토큰 비교" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1927" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>세 가지가 그대로 드러난다. (여기서 재현율은 정답을 얼마나 빠뜨리지 않고 찾았는가를 의미한다.)</p>
<ul>
<li><strong>도구 없음</strong> 은 평균 재현율 0.31 에 그쳤다. 도구의 가치 자체는 분명하다는 것을 확인했다.</li>
<li><strong>Serena (LSP) 단독</strong> 이 재현율 1.00, 비용 $0.38 로 모든 단독 전략 중 가장 효율적이었다.</li>
<li><strong>네 도구를 전부 부착</strong> 했을 때 재현율은 오히려 0.89 로 떨어지고 비용은 $0.47 로 늘었다. 특히 다중 hop 질문에서 전부 부착의 점수가 CodeGraph 단독의 점수 (0.78 / 0.88) 와 소수점 둘째 자리까지 똑같이 나왔다. 모델이 네 도구 중 하나에 쏠려 그 도구의 약점을 그대로 받은 결과다.</li>
</ul>
<p>서브에이전트와 도구 부착에 공통으로 작동하는 원칙은 하나다. 추가 비용이 정답률 향상으로 정당화될 때만 의미가 있다는 것이다. <strong>작업 도메인에 맞는 도구 하나를 정확히 고르는 게, 안 쓸지도 모르니 다 켜두는 것보다 더 싸고 더 정확하다.</strong></p>
<p>그래서, 검증된 절약법은 무엇일까? 절약할 수 있는 방법이 정말로 존재할까?</p>
<h2 id="검증된-토큰-절약법"><a class="anchor" href="#검증된-토큰-절약법">검증된 토큰 절약법</a></h2>
<p>각 절약 패턴은 서로 다른 비용 축을 공격한다. 어떤 건 입력을 줄이고, 어떤 건 같은 입력의 단가를 떨어뜨리며, 어떤 건 같은 작업을 더 싼 모델에 맡긴다. 하나씩 살펴보자.</p>
<h3 id="prompt-caching"><a class="anchor" href="#prompt-caching">Prompt caching</a></h3>
<p>가장 즉각적인 효과를 내는 방법이다. 앞에서 본 대로 캐시 읽기는 기본 입력 단가의 0.1배라, 첫 호출에서 약간의 쓰기 프리미엄(1.25배)을 내고 나면 두 번째 호출부터는 같은 정적 부분을 1/10 가격으로 다시 쓸 수 있다.</p>
<p>절약법 관점에서 다시 강조하고 싶은 건 한 가지다. 어느 방식이든 <strong>호출마다 거의 같은 부분</strong>(도구 정의, 코드 스니펫, RAG 컨텍스트)이 정확히 캐시의 사정권이고, 그 정적 블록을 동적인 내용(이번 차례의 질문, 방금 받은 도구 결과)과 섞지만 않으면 효과가 그대로 나온다는 점이다. 직접 API 를 다룬다면 정적 블록을 앞에 모으고 동적인 부분을 그 뒤로 빼는 것만으로 절약의 대부분이 실현되고, Claude Code 나 opencode 같은 완성된 도구를 쓴다면 이 배치를 도구가 알아서 처리한다.</p>
<h3 id="batch-api-로-비동기-작업-모으기"><a class="anchor" href="#batch-api-로-비동기-작업-모으기">Batch API 로 비동기 작업 모으기</a></h3>
<p>호출이 즉시 끝나야 하는 작업이 아니라면 Batch API 는 단가 자체를 깎는 가장 단순한 장치다. Anthropic, OpenAI, Google 모두 입력과 출력 토큰에 똑같이 50% 할인을 적용하고 그 대가로 결과는 최대 24시간 안에 받기로 한다. Anthropic 의 경우 실측상 대부분의 배치가 한 시간 안에 끝나지만 SLA 자체는 24시간이라는 의미다. 같은 호출을 그대로 둔 채 단가만 절반으로 내려가는 셈이라 적용 비용 자체가 거의 없다.</p>
<p>이 50% 가 흥미로운 건 다른 절약 장치와 곱셈으로 쌓인다는 점이다. Anthropic 공식 문서는 캐싱과 배치의 할인이 서로 곱해진다고 명시한다(stack). 정적 입력 단가로 따지면 표준 × 0.5(배치) × 0.1(캐시 읽기) = 0.05배, 즉 정적 부분만 보면 표준의 5% 수준까지 떨어질 수 있다. 두 할인이 더해지는 게 아니라 곱해진다는 게 핵심이다.</p>
<p>얼마나 차이가 나는지 직접 계산해봤다. 정적 컨텍스트 1만 토큰 + 동적 입력 500토큰 + 출력 1,000토큰짜리 작업을 100건 처리한다고 가정하고, Opus 4.8 의 단가로 네 전략의 비용을 비교했다.</p>
<p><picture><source type="image/webp" srcset="/content/260611/12.720w.webp 720w, /content/260611/12.1440w.webp 1440w, /content/260611/12.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/12.png" alt="Batch API 와 Prompt caching 의 토큰 비용 절감 비교: 두 할인은 곱셈으로 쌓인다" style="max-width:min(720px, 100%);height:auto;" width="1680" height="670" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>캐싱만 쓰면 57%, 배치만 쓰면 50% 가 빠지는데, 둘을 함께 쓰면 79% 까지 떨어진다. 두 할인이 단순히 더해진 게 아니라 정적 입력 구간에서 곱해졌기 때문이다. (출력 토큰은 캐시 대상이 아니라 배치 50% 할인만 받으므로, 출력 비중이 큰 작업일수록 전체 절감폭은 79% 보다 완만해진다. 정적 입력 비중이 클수록 곱셈 효과가 더 크게 나타난다.) 야간 코드베이스 색인, 발행 전 글 평가, 데이터 추출, 정기 리포트처럼 사람이 화면 앞에서 결과를 기다리지 않아도 되는 영역이 의외로 넓다.</p>
<p>다만 모든 작업이 비동기로 미뤄지지는 않는다. 사용자가 답을 보면서 다음 액션을 결정해야 하는 코딩 에이전트 본 세션이나 대화형 챗 UI 처럼 응답 시간 자체가 가치인 영역에서는 Batch 가 어울리지 않는다. 작업을 "결과를 곧장 봐야 한다"와 "다음 출근까지 받으면 된다" 두 줄로 분류해두는 것만으로도 청구액의 절반이 빠진다.</p>
<h3 id="subagent-로-verbose-작업-격리하기"><a class="anchor" href="#subagent-로-verbose-작업-격리하기">Subagent 로 verbose 작업 격리하기</a></h3>
<p><img src="/content/260611/7.webp" alt="Subagent 컨텍스트 격리 구조" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1680" loading="lazy" fetchpriority="low" decoding="async"></p>
<p><a href="https://www.anthropic.com/engineering/multi-agent-research-system" target="_blank" rel="noopener noreferrer">Claude Code 공식 문서 표현을 그대로 옮기면,</a> 서브에이전트는 "자신의 격리된 컨텍스트 윈도우에서 실행되며, 중간 도구 호출과 결과는 서브에이전트 안에 머물고 최종 메시지만 부모로 돌아간다." 즉 verbose 한 작업을 통째로 위임해두면 부모 컨텍스트에는 깔끔한 요약만 들어온다. <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic 의 context engineering 글</a>에 따르면 서브에이전트는 수만 토큰을 쓰며 탐색하더라도 부모에게는 보통 1,000~2,000 토큰의 압축된 요약만 돌려준다. 부모 컨텍스트를 가볍게 유지하는 효과는 분명하다.</p>
<blockquote>
<p>verbose 한 작업은 정답 한 줄 얻으려고 그 과정에 수많은 토큰을 토해내는 종류의 작업을 의미하는데, 테스트 실행/문서 검색/로그 분석등의 작업이 여기에 속한다.</p>
</blockquote>
<p>다만 한 가지 오해를 짚어둘 필요가 있다. 서브에이전트가 곧 총비용 절감인 것은 아니다. Anthropic 자체 보고에 따르면 에이전트는 일반 채팅 대비 약 4배, 멀티 에이전트 시스템은 약 15배의 토큰을 쓴다. 서브에이전트는 비싼 장기 컨텍스트에서 verbose 한 출력을 빼내 정답률과 부모 비용을 지키는 장치이지, 전체 토큰 지출을 무조건 깎아주는 마법은 아니라는 것이다. 그래서 앞서 짚은 대로 "절약된 본문 정리 비용이 시작 비용보다 클 때만" 위임한다는 원칙이 필요하다. 짧은 작업은 그냥 부모가 직접 처리하는 편이 싸다.</p>
<p>여기에 적용 도메인에 대한 한 가지 더 있다. Anthropic 의 multi-agent research system 보고는 자신들의 내부 평가에서 Opus 4 단일 에이전트 대비 Opus 4(리더) + Sonnet 4(서브에이전트들) 구성이 90.2% 의 성능 향상을 보였다고 밝혔다. 다만 이 향상은 모든 도메인에서 동일하지 않다. 같은 글에서 Anthropic 은 "에이전트들이 같은 컨텍스트를 공유해야 하거나 에이전트 간 의존성이 많은 도메인은 멀티 에이전트에 적합하지 않다" 고 명시했고, 코딩 작업이 정확히 그런 경우라고 못 박았다. 리서치는 독립적인 방향으로 병렬 탐색이 가능하지만, 코드는 한 함수의 변경이 다른 함수에 영향을 주는 의존성 그래프 위에서 움직이기 때문이다. (그래서 본인이 코딩 작업에 멀티 에이전트 구성을 쓰고 있다면, 정말 그 워크플로가 병렬 탐색에 가까운지 한 번 돌아볼 가치가 있다. 단일 에이전트 + 격리된 탐색 서브에이전트 하나 정도가 코딩 도메인에는 더 안전한 기본값일 수 있다.)</p>
<h3 id="compact-와-progressive-disclosure"><a class="anchor" href="#compact-와-progressive-disclosure">compact 와 progressive disclosure</a></h3>
<p>Claude Code 의 <code>/compact</code> 는 현재까지의 대화 전체를 요약본으로 압축해 새 컨텍스트로 재시작하는 명령이다. Anthropic 의 공식 설명에 따르면 압축은 단순한 토막내기가 아니라 의미 단위의 요약이라, 진행 중인 작업 맥락과 최근 변경 사항은 남고 반복적인 도구 출력처럼 다시 참조할 필요가 적은 부분은 버려진다. <code>/clear</code> 가 모든 것을 날린다면 <code>/compact</code> 는 요약을 남긴다는 점이 다르고, 컨텍스트 윈도우가 95% 가까이 차면 auto-compact 가 자동으로 같은 일을 한다. 길어진 세션을 일정 지점에서 정리해주면 입력 토큰 누적이 끊긴다.</p>
<p>좀 더 안쪽을 들여다보면, <code>/compact</code> 는 사용자가 명시적으로 부르는 마지막 단계일 뿐 그 앞에 자동으로 동작하는 4단계의 컨텍스트 압축 파이프라인이 더 있다. <a href="https://github.com/VILA-Lab/Dive-into-Claude-Code" target="_blank" rel="noopener noreferrer">Claude Code 의 내부 동작을 분석한 외부 연구("Dive into Claude Code")</a> 에 따르면 모든 호출 직전에 <code>query.ts</code> 가 다음 다섯 단계를 차례로 점검한다.</p>
<p><picture><source type="image/webp" srcset="/content/260611/8.720w.webp 720w, /content/260611/8.1440w.webp 1440w, /content/260611/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/8.png" alt="에이전트 루프 다이어그램. 사용자 프롬프트에서 시작해 도구 요청과 권한 확인을 반복하고, 컨텍스트 압박이 오면 compact 를 거쳐 다음 반복으로 넘어간다" width="1680" height="1591" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<ul>
<li><strong>Budget Reduction</strong> 은 개별 도구 출력의 크기 한도를 넘은 부분을 잘라낸다.</li>
<li><strong>Snip</strong> 은 시간 축에서 오래된 히스토리를 끊는다.</li>
<li><strong>Microcompact</strong> 는 캐시 인식을 유지한 채 미세 압축을 수행한다.</li>
<li><strong>Context Collapse</strong> 는 매우 긴 히스토리를 read-time 으로 다시 투영해 차원을 줄인다.</li>
<li><strong>Auto-Compact</strong> 는 의미 단위의 압축을 95% 시점에 마지막 수단으로 발동한다.</li>
</ul>
<p>위로 갈수록 가볍고 싸며, 아래로 갈수록 무겁지만 효과가 크다. 단일 압축 전략으로 모든 컨텍스트 압력을 해소할 수 없다는 인식이 이 5단 구조의 설계 근거다. 사용자가 <code>/compact</code> 를 명시적으로 부르는 건 이 자동 파이프라인의 마지막 칸을 앞당겨 발동하는 행위에 가깝다.</p>
<p>비슷한 사고방식이 Claude Code 의 Skills 구조에도 들어 있다. <code>/compact</code> 가 이미 쌓인 컨텍스트를 사후에 줄이는 장치라면 <code>/skills</code> 의 3단 로딩은 애초에 안 쌓이게 하는 장치이다.</p>
<p>Anthropic 문서 기준으로 스킬은 세 단계로 로드된다. 이름과 한 줄 설명(약 100 토큰)만 세션 시작 시 컨텍스트에 들어가고, 본문(<code>SKILL.md</code>, 5천 토큰 미만)은 그 스킬이 발동되어야 그제야 로드되며, 번들된 스크립트나 리소스는 bash 로 실행될 때 출력만 돌아올 뿐 코드 자체는 컨텍스트에 들어오지 않는다. 그래서 수십 개의 스킬을 설치해도 시작 컨텍스트는 거의 늘지 않는다.</p>
<h3 id="모델-라우팅-같은-답이면-더-싼-모델로"><a class="anchor" href="#모델-라우팅-같은-답이면-더-싼-모델로">모델 라우팅, 같은 답이면 더 싼 모델로</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/13.720w.webp 720w, /content/260611/13.1172w.webp 1172w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/13.png" alt="비용 대비 모델 성능 분포도. 로그 비용 축에서 저비용 고성능 지점에 이상적인 라우터가 놓인다" width="1172" height="1039" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Opus 4.8 와 Haiku 4.5 의 입력 단가 차이는 5배다. 단순 검색, 탐색, 짧은 요약 같은 작업까지 무조건 가장 큰 모델로 처리하는 것은 비용 면에서 큰 손해다. 작업의 난이도에 따라 Haiku → Sonnet → Opus 순으로 라우팅하고, 정말 추론이 무거운 단계에서만 Opus 를 부르는 패턴이 점점 표준이 되어 가고 있다. <a href="https://lmsys.org/blog/2024-07-01-routellm/" target="_blank" rel="noopener noreferrer">LMSYS 의 RouteLLM 연구</a>는 GPT-4 품질의 95% 를 유지하면서 강한 모델 호출을 14% 로 줄이는 라우터를 보였다(벤치마크는 코딩 특화가 아닌 일반 추론 기준이라는 점은 감안해야 한다).</p>
<p>도구 지형도도 빠르게 정착했다. 상용 게이트웨이로는 OpenRouter, Martian, NotDiamond 가 있고, 오픈소스 셀프호스트 진영에는 LMSYS 의 RouteLLM 과 LiteLLM·Bifrost 같은 비용 가시화 레이어가 자리 잡았다. Anthropic 생태계 안에서는 Agent SDK 의 모델 선택 인자, Claude Code 서브에이전트의 <code>model</code> 필드(Explore 는 기본이 Haiku 다), <code>/model</code> 슬래시 명령이 모두 이 라우팅을 위한 장치다. 다만 여기서 한 가지 짚어야 할 부분이 있다. API 를 직접 호출하면 입력 난이도를 보고 모델을 자동으로 골라주는 일은 일어나지 않는다. Anthropic·OpenAI·Google API 모두 사용자가 모델 이름을 명시한 그대로 도는 것이 기본이고, 자동 라우팅은 제품(Cursor 의 Auto, ChatGPT 의 auto 모드, OpenRouter 의 <code>openrouter/auto</code>)에서만 켜져 있는 옵션이다. 라우팅으로 비용을 줄이고 싶다면, 게이트웨이를 붙이든 분류기를 깔든 어쨌든 직접 구축해야 한다는 뜻이다.</p>
<p>그렇다고 외부 자동 라우터를 무작정 붙이는 것이 답이냐 하면 또 그렇지 않다. OpenRouter 의 Auto Router 처럼 호출마다 모델을 동적으로 바꿔주는 라우터는 앞에서 본 prompt caching 과 정면으로 충돌한다. Anthropic 의 ephemeral cache 는 같은 모델 + 같은 프리픽스가 일치할 때만 적중하기 때문에, 호출마다 모델 키가 바뀌면 캐시 키 자체가 매번 어긋난다. 캐시 읽기로 단가를 9할 깎던 효과를 그대로 잃는다는 뜻이고, 라우팅으로 절반 가까이 아끼다가 캐시 미스로 다시 토해내는 경우도 흔하다. OpenRouter 가 이를 인지하고 <code>session_id</code> 기반 스티키니스 옵션을 권장하는 것도 같은 이유다.</p>
<p>그래서 실무가 결국 합의한 형태는 의외로 단순하다. 호출마다 동적으로 분류하지 말고, 작업 타입별로 정적으로 분기하라는 쪽이다. 앞에서 본 서브에이전트의 <code>model</code> 필드가 이 패턴이다. Explore 서브에이전트는 늘 Haiku 로 도는 코드 탐색 레인을 가지고, 코드 리뷰 서브에이전트는 늘 Opus 로 도는 리뷰 레인을 가진다. 각 레인 안에서는 같은 모델·같은 시스템 프롬프트·같은 도구 정의가 반복되니 각 모델마다 자기만의 캐시가 따로 누적되며 적중한다. 결국 "라우팅이 표준이 되어 가고 있다" 는 말은, 호출마다 분류기가 돌아가는 동적 라우팅이 아니라 "코디네이터 + 실행자" 형태의 작업 타입별 정적 분기가 표준이 되었다는 의미에 가깝다. 라우팅과 캐싱이 같은 글에 모순 없이 공존할 수 있는 이유도 거기에 있다.</p>
<h3 id="cursor-composer-25"><a class="anchor" href="#cursor-composer-25">Cursor Composer 2.5</a></h3>
<p><img src="/content/260611/10.webp" alt="Cursor 가 공개한 CursorBench 3.1 점수 대비 작업당 평균 비용 그래프. Composer 2.5 가 가장 싼 구간에서 상위 점수에 근접한다" width="1680" height="1120" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>조금 결이 다른 절약법이지만 Cursor 와 같은 에이전트를 활용할 수 있다. <a href="https://cursor.com/blog/composer-2-5" target="_blank" rel="noopener noreferrer">Cursor 가 2026년 5월 18일 공개한 자체 모델 Composer 2.5</a> 는 Moonshot AI 의 오픈소스 체크포인트 Kimi K2.5 를 기반으로 코딩 작업에 특화 파인튜닝된 모델이다. Cursor 팀은 자체 벤치마크에서 Claude Opus 4.7 과 비등한 코딩 성능을 약 1/10 가격으로 낸다고 밝혔다. 공개된 가격을 보면 기본 단가가 입력 $0.50 / 출력 $2.50 로, Opus 4.8 의 입력 $5.00 / 출력 $25.00 과 비교하면 정확히 자릿수가 하나 다르다.</p>
<p>특화 모델의 벤치마크 수치는 Cursor 가 자체 측정한 것이라 절대값을 그대로 옮기기보다, "코딩 전용으로 학습한 작은 모델이 범용 프론티어 모델과 같은 작업에서 비등하게 쓸 만한 성능을 내면서 비용은 자릿수 단위로 떨어진다" 는 흐름으로 읽는 편이 안전하다. 이 흐름은 2026년 들어 가장 뚜렷한 절약 트렌드 중 하나다.</p>
<p>이 흐름이 흥미로운 진짜 이유는 단가만 깎는 게 아니라 <strong>IDE 와 같이 설계되었을 때 입력 토큰 자체도 줄어든다는 점</strong>이다. Composer 나 Cascade 같은 모델은 IDE 가 보내는 코드베이스 컨텍스트(현재 파일, 인접 파일, 인덱스된 심볼)를 효율적으로 소비하도록 학습돼 있어, 같은 작업을 시켜도 범용 모델이 "관련 파일을 더 보여달라" 며 grep·read 를 더 자주 도는 만큼의 입력 토큰 부풀음이 줄어든다. 단가가 자릿수 단위로 떨어진 위에 토큰 수까지 살짝 깎이는 셈이라 실제 비용 절감폭은 단가 차이 그 이상이 된다.</p>
<h3 id="컨텍스트를-컨텍스트-밖으로"><a class="anchor" href="#컨텍스트를-컨텍스트-밖으로">컨텍스트를 컨텍스트 밖으로</a></h3>
<p>2026년 들어 가장 흥미로운 흐름은 "필요한 것만, 필요할 때" 컨텍스트에 올리는 방향이다. Anthropic 은 이를 <strong>just in time(JIT) 컨텍스트</strong>라고 부른다. 모든 자료를 미리 컨텍스트에 박아두는 대신, 파일 경로나 쿼리 같은 가벼운 참조만 들고 있다가 실제로 필요할 때 도구로 끌어오는 방식이다. Claude Code 가 코드베이스 전체를 인덱싱해 통째로 싣지 않고 glob/grep 으로 그때그때 파일을 읽어 들이는 것이 바로 이 패턴이다.</p>
<p>Anthropic 이 Sonnet 4.5 와 함께 공개한 memory tool 과 context editing 도 같은 철학에서 나왔다. 여기서 한 가지 짚고 갈 것이 있다. memory tool 의 "메모리" 는 흔히 떠올리는 것들과 다르다. 세션 안에서 쌓이는 대화 기록(컨텍스트 윈도우 안에 있다가 세션이 끝나면 사라지는)도 아니고, <code>CLAUDE.md</code> 처럼 사람이 미리 작성해 시작할 때 자동으로 실리는 설정 파일도 아니다. memory tool 은 <strong>모델이 직접 파일을 쓰고 읽는 도구</strong>다.</p>
<p>좀 더 깊이 들여다보면 두 도구의 설계는 의외로 단순한 인터페이스다. memory tool 은 Claude 가 사용자 인프라에 마련된 전용 메모리 디렉토리에 대해 파일을 생성·읽기·갱신·삭제하는 네 가지 작업을 수행하는 식이다. 즉 모델은 "텍스트 어딘가에 적어두라" 가 아니라 일반적인 파일 시스템처럼 메모를 다룬다. 저장 위치를 사용자 인프라에 두는 설계가 핵심인데, 이로써 Claude 가 컨텍스트 윈도우 밖에 영구적으로 들고 다닐 수 있는 메모리가 생기는 동시에 그 데이터의 저장과 보관 방식은 사용자가 통제한다. 세션 메모리가 컨텍스트 안에서 휘발된다면, 이 메모리는 컨텍스트 밖에 남아 다음 세션까지 이어진다. context editing 은 반대 방향에서 같은 일을 한다. 토큰 한도에 다가가면 오래되어 더는 참조되지 않는 도구 호출 결과를 자동으로 비워 대화 흐름은 유지하면서 에이전트가 더 오래 돌 수 있게 한다.</p>
<p>Anthropic 자체 평가에서는 이 둘을 함께 적용했을 때 기준선 대비 39%, context editing 단독으로는 29% 의 성능 향상이 있었고, 100턴짜리 웹 검색 평가에서는 토큰 소비가 84% 줄었다고 보고한다. (벤더 자체 벤치마크라는 점은 감안하되, 방향성만큼은 분명하다. 컨텍스트는 채우는 것보다 비우는 설계가 중요해지고 있다.) 이 흐름은 Claude Code 의 <code>/compact</code> 5단 파이프라인과도 결이 같다. 다른 점은 <code>/compact</code> 가 컨텍스트 내부에서 줄이는 일이고, memory tool 은 컨텍스트 바깥에 별도 저장소를 두는 일이라는 것. 둘은 충돌하지 않고 보완한다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p><img src="/content/260611/14.webp" alt="프롬프트 엔지니어링과 컨텍스트 엔지니어링 비교 도식. 단발 질의는 시스템 프롬프트와 사용자 메시지만 담지만, 에이전트는 문서와 도구와 메모리를 골라 넣고 도구 결과를 되돌린다" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>여기까지 정리하고 보면, 토큰 절약은 결국 세 가지 축으로 환원된다. <strong>같은 일이면 더 적게 보내기, 같은 입력이면 더 싸게 보내기, 같은 답이면 더 싼 모델에 맡기기</strong>. Prompt caching, Batch API, subagent 격리, <code>/compact</code>, memory tool 과 context editing, 모델 라우팅, 특화 모델 같은 패턴들은 결국 이 세 축 중 어디를 공격하느냐의 차이일 뿐이다. 그리고 2026년의 흐름을 한마디로 줄이면, 컨텍스트는 채우는 기술에서 비우고 골라내는 기술(context engineering)로 무게중심이 옮겨가고 있다. context rot 이 보여주듯 가벼운 컨텍스트는 비용뿐 아니라 정답률에도 이로운 쪽이기 때문이다.</p>
<p>물론 정답은 없다. 팀마다 작업 패턴이 다르고, 같은 사람 안에서도 글을 쓸 때와 코드를 짤 때 비용 구조는 달라진다. 다만 한 가지 확실한 건, 생태계가 빠르게 움직이는 만큼 "어제 맞았던 절약 패턴이 오늘도 맞을 거라는 가정" 만큼은 점점 위험해지고 있다는 점이다. 새로 나오는 모델·도구·가격표를 꾸준히 따라가면서 각자의 작업 위에서 작은 POC 로 검증해보는 습관이, 결국 가장 비싸 보이지 않으면서도 가장 오래 가는 절약법이 될 것이라 생각한다. 이 글을 읽는 독자분들도 자기만의 토큰 가계부를 한 번쯤 꺼내보시길 권하고 싶다. (절약 전략들이 왜 그렇게 동작하는지 그 뿌리가 궁금하다면, 토큰의 원리 글에서 BPE 와 KV 캐시 이야기를 함께 읽어보면 좋겠다.)</p>
<p>그리고 한 가지 질문이 남는다. 컨텍스트를 비우고 골라내는 기술까지 왔다면, 그 다음은 어디일까. 이미 공개된 자료들을 따라가 보면 무게중심은 컨텍스트에서 한 칸 더 바깥으로, 에이전트라는 시스템 전체를 어떻게 굴리고(harness 설계) · 잘 굴렀는지 어떻게 측정하며(eval) · 잘못 굴렀을 때 어떻게 가둘지(containment)로 넓어지고 있다. 흥미롭게도 이 글이 줄곧 강조한 "직접 POC 로 측정하라" 는 태도가 시스템 단위로 커지면 정확히 eval 이 된다. 이 다음 방향에 대한 이야기는 <a href="/260622" target="_blank" rel="noopener noreferrer">컨텍스트 다음</a> 글에서 따로 자세히 다뤄보려고 한다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/batch-processing" target="_blank" rel="noopener noreferrer">Anthropic Batch Processing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/about-claude/pricing" target="_blank" rel="noopener noreferrer">Anthropic Claude API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/sub-agents" target="_blank" rel="noopener noreferrer">Anthropic Claude Code Subagents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/claude-code/mcp" target="_blank" rel="noopener noreferrer">Anthropic Claude Code MCP Tool Search</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview" target="_blank" rel="noopener noreferrer">Anthropic Agent Skills</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developers.openai.com/api/docs/guides/prompt-caching" target="_blank" rel="noopener noreferrer">OpenAI Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://openai.com/api/pricing/" target="_blank" rel="noopener noreferrer">OpenAI API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/gemini-api/docs/caching" target="_blank" rel="noopener noreferrer">Google Gemini Context Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/pricing" target="_blank" rel="noopener noreferrer">Google Gemini API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/news/context-management" target="_blank" rel="noopener noreferrer">Anthropic, Managing context on the Claude Developer Platform</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2601.07206" target="_blank" rel="noopener noreferrer">LLMRouterBench (Findings of ACL 2026)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2604.14228" target="_blank" rel="noopener noreferrer">VILA-Lab, Dive into Claude Code</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>토큰</category>
        </item>
        <item>
            <title><![CDATA[토큰의 원리]]></title>
            <link>https://hooninedev.com/260610</link>
            <guid isPermaLink="false">https://hooninedev.com/260610</guid>
            <pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 AI 토큰이 도대체 무엇이고 어떤 원리로 동작하는지에 대한 이야기를 해보려고 한다. 필자는 그동안 AI 도구를 어떻게 잘 쓸지, 어떤 도구가 유행이고 왜 유행하는지를 주로 다뤄왔다. 그러다 토큰 절약법 글을 정리하면서 한 가지를 새삼 느꼈다. 비용을 줄이는 방법을 이야기하려면 결국 "토큰이 무엇이고 어떻게 청구되는가"를 먼저 알아야 하는데...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 AI 토큰이 도대체 무엇이고 어떤 원리로 동작하는지에 대한 이야기를 해보려고 한다.</p>
<p>필자는 그동안 AI 도구를 어떻게 잘 쓸지, 어떤 도구가 유행이고 왜 유행하는지를 주로 다뤄왔다. 그러다 <a href="/260611" target="_blank" rel="noopener noreferrer">토큰 절약법</a> 글을 정리하면서 한 가지를 새삼 느꼈다. 비용을 줄이는 방법을 이야기하려면 결국 "토큰이 무엇이고 어떻게 청구되는가"를 먼저 알아야 하는데, 정작 그 토대를 제대로 짚고 넘어간 적이 없었다는 점이다. (절약법을 쓰다 보니 토큰 동작 원리만 따로 떼어 한 편으로 다룰 분량이 나왔다.)</p>
<p>그래서 이 글은 절약법으로 들어가기 전의 토대에 해당한다. 토큰이 단어도 글자도 아니라면 정확히 무엇인지(BPE), 토큰이 어떤 모습으로 모델에 들어가는지(임베딩), 입력보다 출력이 왜 비싼지(prefill/decode), prompt caching 이 단가를 깎는 이유가 트랜스포머(신경망 설계도) 안쪽 어디에서 나오는지(KV 캐시), 그리고 컨텍스트가 길어질수록 왜 비싸지는지(어텐션의 제곱 비용)까지를 차례로 정리한다. 절약의 실천법이 궁금하다면 이 글 다음에 토큰 절약법으로 이어 읽으면 된다.</p>
<h2 id="토큰은-단어도-글자도-아니다"><a class="anchor" href="#토큰은-단어도-글자도-아니다">토큰은 단어도 글자도 아니다</a></h2>
<p>먼저 가장 기본이 되는 사실부터 짚는다. <strong>토큰은 단어도 아니고 글자도 아니다.</strong> 모델이 학습 데이터에서 자주 등장한 문자 시퀀스를 압축해 만든 어휘의 단위다.</p>
<p>우리가 흔히 "이 문장은 몇 단어야?" 라고 세는 직관과 모델이 텍스트를 쪼개는 방식은 다르다. 모델은 자주 같이 등장하는 글자 조합을 하나의 단위로 묶어 두고, 텍스트가 들어오면 그 어휘 사전에 맞춰 잘게 나눈다. 그래서 같은 한 단어라도 어떤 건 토큰 하나로, 어떤 건 토큰 여러 개로 쪼개진다.</p>
<p>이 어휘 사전을 만드는 알고리즘이 거의 모든 현대 LLM 의 공통 토대인 BPE 다.</p>
<h3 id="bpe-알고리즘"><a class="anchor" href="#bpe-알고리즘">BPE 알고리즘</a></h3>
<p>BPE(Byte Pair Encoding, 바이트 쌍 부호화)는 자주 같이 등장하는 인접 심볼 쌍을 반복적으로 하나의 새 심볼로 병합해서 어휘를 키워가는 알고리즘이다.</p>
<p>흥미로운 건 이게 원래 자연어 처리용으로 태어난 알고리즘이 아니라는 점이다. BPE 는 1994년 Philip Gage 가 <a href="/240706" target="_blank" rel="noopener noreferrer">데이터 압축</a> 기법으로 처음 제안했다. 데이터에서 가장 자주 등장하는 바이트 쌍을, 데이터에 쓰이지 않은 바이트 하나로 치환하고 그 치환 규칙을 따로 표로 저장하는 압축 방식이었다.</p>
<p>이걸 신경망 번역의 어휘 문제에 끌어온 것이 에든버러 대학의 Sennrich 연구팀이다. 2015년에 공개되고 ACL 2016 에서 발표된 "Neural Machine Translation of Rare Words with Subword Units" 논문에서, 이들은 고정된 어휘 사전으로는 희귀어나 처음 보는 단어를 다룰 수 없다는 한계를 BPE 로 풀었다. 단어를 통째로 외우는 대신 더 작은 부분단어(subword) 조각의 조합으로 표현하면, 사전에 없던 단어도 조각들로 인코딩할 수 있다는 발상이다.</p>
<p>작동 과정 자체는 의외로 단순하다.</p>
<ol>
<li>처음에는 글자 하나하나가 토큰 하나다.</li>
<li>말뭉치에서 가장 자주 인접해 등장하는 토큰 쌍을 찾는다.</li>
<li>그 쌍을 하나의 새 토큰으로 병합하고, 말뭉치 전체에서 해당 쌍을 새 토큰으로 치환한다.</li>
<li>어휘 크기 한도에 도달할 때까지 2~3 을 반복한다.</li>
</ol>
<p>그래서 처음엔 글자 하나가 토큰 하나지만, "th", "the", "tion" 처럼 자주 등장하는 조합이 점점 하나의 토큰으로 합쳐진다. 자주 쓰이는 패턴일수록 더 큰 덩어리로 묶이는 셈이다.</p>
<p><img src="/content/260610/2.webp" alt="BPE 토크나이저가 자주 등장하는 인접 문자 쌍을 반복 병합해 어휘를 키워가는 과정" style="max-width:min(500px, 100%);height:auto;" width="1024" height="1536" loading="eager" fetchpriority="high" decoding="async"></p>
<p>GPT 계열은 여기서 한 단계 더 들어간 <strong>byte-level BPE</strong> 를 쓴다. 텍스트를 글자가 아니라 UTF-8 바이트 스트림으로 먼저 변환한 뒤 BPE 를 적용하는 방식이다. 이렇게 하면 기본 어휘가 256개(바이트의 가짓수)로 작게 출발하면서도, UTF-8 로 표현 가능한 모든 텍스트를 처음 보는 문자(unknown token) 없이 인코딩할 수 있다. 이모지든 한자든 특수문자든, 최소한 바이트 단위로는 무조건 표현 가능하다는 보장이 생기는 것이다.</p>
<h3 id="같은-텍스트-다른-토큰-수"><a class="anchor" href="#같은-텍스트-다른-토큰-수">같은 텍스트, 다른 토큰 수</a></h3>
<p>이 구조 때문에 같은 텍스트라도 토크나이저(사람이 쓴 텍스트를 모델이 다룰 수 있는 토큰 단위로 쪼개는 도구)에 따라 토큰 수가 크게 달라진다. 어휘 사전이 어떻게 학습됐느냐에 따라 같은 문장이 더 잘게도, 더 큰 덩어리로도 쪼개지기 때문이다.</p>
<p><picture><source type="image/webp" srcset="/content/260610/3.720w.webp 720w, /content/260610/3.1440w.webp 1440w, /content/260610/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/3.png" alt="같은 텍스트도 토크나이저에 따라 토큰 수가 달라지는 비교. o200k_base 가 cl100k_base 보다 비영어권에서 더 적은 토큰을 쓴다" width="1680" height="997" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>OpenAI 가 GPT-4o 와 함께 공개한 자료를 보면 이 차이가 잘 드러난다. GPT-4o 계열의 새 토크나이저 o200k_base 는 GPT-4 계열의 cl100k_base 대비 같은 텍스트를 더 적은 토큰으로 표현한다. 영어는 약 1.1배 효율적인 정도지만, 비영어권으로 갈수록 격차가 벌어진다. 공개된 짧은 예문 기준으로 중국어와 일본어는 약 1.4배, 한국어는 약 1.7배, 힌디어는 약 2.9배까지 토큰이 줄어든다. (한국어 사용자 입장에서 토크나이저 세대 차이가 영어보다 더 크게 체감된다는 게 흥미로운 지점이다.)</p>
<p>조금 더 넓게 보면 언어 자체에 따른 격차도 있다. cl100k_base 를 기준으로 여러 언어를 비교한 Yennie Jun 의 분석을 보면, 같은 의미의 문장도 영어가 거의 항상 최소 토큰이고 고유 문자 체계를 쓰는 언어일수록 토큰이 길어진다. 힌디어나 벵골어는 영어의 5배, 버마어는 10배가 넘는 토큰을 쓰기도 한다. 한국어나 중국어도 영어보다는 길게 토큰화되지만, 그 극단적인 언어들만큼은 아니다.</p>
<p>여기서 실무적으로 중요한 함의가 하나 나온다. Anthropic 도 Opus 4.7 부터 새 토크나이저를 도입하면서 공식 가격 문서에 "같은 텍스트가 이전 모델 대비 최대 35% 더 많은 토큰으로 청구될 수 있다" 고 명시했다. <strong>단가가 같아도 토큰 수가 늘면 청구액은 그대로 늘어난다는 것이다.</strong> 모델 비교는 단가만 볼 게 아니라 단가 × 예상 토큰 수의 곱으로 봐야 정직하다.</p>
<p>그런데 이렇게 쪼개진 토큰이 모델 안으로 들어갈 때, 글자나 단어 그대로 들어가는 건 아니다. 그렇다면 토큰은 어떤 모습으로 모델에 입력되는 걸까?</p>
<h2 id="토큰이-모델에-들어가기까지"><a class="anchor" href="#토큰이-모델에-들어가기까지">토큰이 모델에 들어가기까지</a></h2>
<p>신경망은 텍스트를 직접 다루지 못한다. 모델이 계산할 수 있는 건 결국 숫자, 정확히는 벡터(여러 숫자를 늘어놓은 배열)뿐이다. 그래서 토큰은 모델에 들어가기 전에 몇 단계를 거쳐 숫자로 바뀐다.</p>
<p>순서대로 정리하면 이렇다.</p>
<ol>
<li><strong>텍스트 → 토큰</strong>: BPE 토크나이저가 텍스트를 토큰 조각으로 쪼갠다.</li>
<li><strong>토큰 → 토큰 ID</strong>: 각 토큰은 어휘 사전에서 자기 자리를 가리키는 정수 ID 하나로 매핑된다. 예를 들어 <code>" the"</code> 가 사전의 1,234번째 토큰이라면 그 토큰은 ID <code>1234</code> 가 된다.</li>
<li><strong>토큰 ID → 임베딩 벡터</strong>: 이 ID 로 임베딩 행렬(embedding matrix)의 해당 행을 꺼내 온다. 임베딩 행렬은 <code>vocabulary size × model dimension (d_model)</code> 크기의 거대한 표인데, 토큰 ID 하나가 그 표의 한 행, 즉 벡터 하나에 대응된다. 이 벡터가 토큰의 "의미" 를 담은 좌표라고 보면 된다.</li>
<li><strong>위치 정보 더하기</strong>: 셀프 어텐션은 그 자체로는 토큰의 순서를 모른다. "나는 너를 좋아해" 와 "너를 나는 좋아해" 를 똑같이 본다는 뜻이다. 그래서 각 토큰 벡터에 위치 정보를 주입해 순서를 알려준다.</li>
</ol>
<p>말로만 보면 추상적이니, 이 네 단계를 직접 만져볼 수 있는 위젯을 하나 붙여둔다. 입력 문장을 바꾸거나 토큰을 클릭하면, 그 토큰이 ID 를 거쳐 임베딩 벡터가 되고 위치 정보가 더해져 최종 입력 벡터가 되기까지의 흐름을 따라갈 수 있다. (표시 차원 수를 줄여놨을 뿐 실제 GPT-4 의 벡터는 12,288 차원이라는 점을 떠올리며 보면 좋다.)</p>
<p>여기서 모델 차원(d_model)은 토큰 하나를 표현하는 벡터의 길이다. 원조 트랜스포머 논문에서는 이 값이 512였지만, 요즘 LLM 은 훨씬 크다. (Llama 2 의 7B 모델만 해도 4,096 차원이다.) 모델 차원이 클수록 토큰의 미묘한 의미 차이를 더 풍부하게 표현할 수 있는 대신, 그만큼 계산량과 메모리도 함께 늘어난다.</p>
<p><picture><source type="image/webp" srcset="/content/260610/4.704w.webp 704w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/4.png" alt="토큰 ID 가 임베딩 행렬의 한 행을 조회해 d_model 차원의 벡터로 변환되는 구조" width="704" height="435" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>위치 정보를 주입하는 방식도 세대에 따라 다르다. 원조 트랜스포머는 사인/코사인 함수로 만든 위치 벡터를 임베딩에 그대로 더했지만, 요즘 공개 LLM 들이 사실상 표준으로 쓰는 RoPE(Rotary Position Embedding, 회전 위치 임베딩)는 Query/Key 벡터를 회전시키는 방식으로 어텐션 계산 단계에서 상대 위치를 주입한다. (방식은 달라도 "트랜스포머에 순서 감각을 심어준다" 는 목적은 같다.)</p>
<p>정리하면, <strong>텍스트 → 토큰 → 토큰 ID → 임베딩 벡터(+위치 정보) → 트랜스포머 입력</strong> 이라는 흐름이다. 우리가 청구서에서 보는 "토큰 수" 는 이 파이프라인의 두 번째 칸, 즉 텍스트가 몇 개의 토큰으로 쪼개졌는가의 개수인 셈이다.</p>
<p>여기까지 오면 토큰이 어떻게 만들어지고 어떤 모습으로 모델에 들어가는지가 정리된다. 그렇다면 이 토큰들이 모델 안에서 처리될 때, 입력과 출력의 비용은 왜 그렇게 차이가 나고, 같은 입력을 반복해서 보내는 비용은 어떻게 깎을 수 있는 걸까?</p>
<h2 id="입력은-싸고-출력은-비싼-이유"><a class="anchor" href="#입력은-싸고-출력은-비싼-이유">입력은 싸고 출력은 비싼 이유</a></h2>
<p>토큰을 다뤄본 사람이라면 한 번쯤 의아했을 지점이 있다. 가격표를 보면 <strong>출력 토큰이 입력 토큰보다 몇 배씩 비싸다.</strong> Anthropic 의 경우 모든 모델에서 출력 단가가 입력 단가의 정확히 5배다. (Opus 는 입력 $5 / 출력 $25, Haiku 는 입력 $1 / 출력 $5 식이다.) 똑같은 토큰 하나인데 왜 들어올 때와 나갈 때의 값이 다른 걸까?</p>
<p>답은 모델이 토큰을 처리하는 방식이 입력과 출력에서 완전히 다르기 때문이다. LLM 추론은 두 단계로 나뉜다.</p>
<ul>
<li><strong>prefill (입력 처리)</strong>: 프롬프트로 들어온 모든 토큰을 <strong>한꺼번에 병렬로</strong> 처리한다. 1,000개 토큰이든 1만개 토큰이든, GPU 가 한 번에 쭉 훑어서 각 토큰의 K/V(Key/Value) 를 계산한다. 연산량은 많지만 병렬로 처리되니 토큰당 효율이 좋다.</li>
<li><strong>decode (출력 생성)</strong>: 응답 토큰은 <strong>한 번에 하나씩 순차적으로</strong> 만든다. 토큰 하나를 생성하면 그걸 다시 입력에 붙이고, 또 그다음 토큰을 만든다. 이걸 응답이 끝날 때까지 반복한다.</li>
</ul>
<p>이 비대칭으로 비용 차이가 발생한다. Databricks 의 추론 성능 분석에 따르면, prefill 은 연산이 병목인 compute-bound 단계이고 decode 는 메모리 대역폭이 병목인 memory-bound 단계다. decode 에서는 토큰 하나를 만들 때마다 모델 가중치 전체를 GPU 메모리에서 다시 읽어 와야 하는데, 정작 그 한 번에 토큰은 딱 하나만 나온다. GPU 의 막대한 연산 능력이 거의 놀고 있는 셈이다. (Databricks 의 표현을 빌리면, 돈을 내고 GPU 를 켜두지만 가용 연산을 못 쓰고 있는 상황이다.)</p>
<p>즉 <strong>입력 토큰은 병렬 prefill 로 한 번에 효율적으로 처리되는 반면, 출력 토큰은 한 토큰씩 비효율적으로 짜내야 한다.</strong> 출력이 비싸게 책정되는 데에는 수요나 마진 같은 상업적 요인도 섞여 있겠지만, 적어도 기술적으로는 decode 단계의 이런 비효율이 그 배경에 깔려 있다. 그래서 "출력을 짧게 받는다" 는 절약 원칙은 단순한 절약 팁이 아니라 가장 비싼 단계를 줄이는 일인 것이다.</p>
<p>그렇다면 이 비효율적인 decode 단계를 조금이라도 덜 비효율적으로 만들 방법은 없을까? 바로 그 지점에서 KV 캐시가 등장한다.</p>
<h2 id="캐시가-단가를-깎는-원리"><a class="anchor" href="#캐시가-단가를-깎는-원리">캐시가 단가를 깎는 원리</a></h2>
<p>prompt caching 은 정적인 입력을 캐시에 한 번 저장해두고 다음 호출에서는 훨씬 싼 가격으로 다시 읽는 장치다. Anthropic 공식 문서 기준으로 캐시 읽기는 기본 입력 단가의 0.1배, 즉 정확히 10% 수준이다. 캐시 쓰기는 5분 TTL(Time To Live, 캐시 유효 시간) 기준 1.25배, 1시간 TTL 기준 2배가 붙는다. 첫 호출에서 살짝 더 내고, 두 번째 호출부터 90% 를 깎는 구조인 셈이다.</p>
<p>그런데 어떻게 단가의 90% 가 깎이는 걸까? 그리고 왜 "프리픽스가 정확히 같아야 한다" 는 조건이 그렇게 까다로운 걸까? 이 두 질문의 답은 트랜스포머의 안쪽 동작을 한 번 들여다보면 분명해진다.</p>
<h3 id="kv-캐시"><a class="anchor" href="#kv-캐시">KV 캐시</a></h3>
<p>모델은 입력 토큰을 처리할 때 각 토큰에 대응되는 Query/Key/Value(Q/K/V) 벡터를 만든다. 셀프 어텐션은 Query 와 Key 의 내적으로 어텐션 점수를 계산하고, 그 점수로 Value 의 가중합을 만들어 출력을 낸다. 한 토큰이 다른 토큰들을 "얼마나 참고할지" 를 정하는 계산이라고 보면 된다.</p>
<p>앞서 본 decode 단계가 바로 이 지점에서 문제가 된다. 새 토큰을 하나 만들 때마다 그 앞의 모든 토큰에 대한 K/V 가 필요한데, 매번 처음부터 다시 계산하면 같은 K/V 를 끝없이 중복 계산하게 된다.</p>
<p><img src="/content/260610/1.webp" alt="트랜스포머 KV 캐시의 동작 구조" width="1536" height="1024" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>KV 캐시는 바로 이 중복을 없애려고 도입된 구조다. 한 번 계산한 K/V 를 저장해두고, 새 토큰을 생성할 때 앞 토큰들의 K/V 는 다시 계산하지 않고 캐시에서 읽어 재사용한다. 덕분에 한 시퀀스 안에서 토큰을 하나 더 생성하는 비용이, 매 스텝 전체를 다시 계산하는 O(n²) 에서 캐시를 읽기만 하는 O(n) 으로 떨어진다. (엄밀히는 스텝당 비용 기준의 이야기지만, 핵심은 "이미 계산한 건 다시 계산하지 않는다" 는 단순한 발상이다.) decode 가 memory-bound 인 이유도 여기에 있다. 매 스텝 이 KV 캐시를 메모리에서 다시 읽어 와야 하기 때문이다.</p>
<h3 id="호출-사이의-재사용"><a class="anchor" href="#호출-사이의-재사용">호출 사이의 재사용</a></h3>
<p>KV 캐시가 원래 한 번의 응답 생성 안에서 디코딩을 가속하려고 만들어진 구조라면, prompt caching 은 이 메모리 구조를 <strong>하나의 호출 안이 아니라 호출과 호출 사이에서도 재활용하자</strong> 는 발상이다.</p>
<p>정적인 프리픽스(시스템 프롬프트, 도구 정의, 코드 스니펫처럼 매번 거의 같은 앞부분)의 K/V 를 5분 또는 1시간 TTL 동안 GPU 메모리에 들고 있다가, 다음 호출이 같은 프리픽스로 시작하면 그 부분의 K/V 계산을 통째로 건너뛰고 캐시에서 곧장 읽어 쓴다. 그래서 정확히 말하면, 입력 토큰의 단가가 마법처럼 깎이는 게 아니라 <strong>그 토큰을 계산하는 GPU 일 자체가 사라지는 것이다.</strong> 단가의 90% 인하는 그 절약된 연산을 가격에 반영한 결과다.</p>
<p>이 메커니즘을 이해하면 "프리픽스가 정확히 같아야 한다" 는 조건이 왜 그렇게 엄격한지도 자연스럽게 풀린다. 캐시 적중 여부는 프리픽스를 누적적으로 해싱한 값이 일치하는지로 판정되는데, 셀프 어텐션은 인과적(causal)이라 앞 토큰이 하나라도 바뀌면 그 뒤 토큰들의 K/V 가 전부 달라진다. 그래서 프롬프트 맨 앞에 타임스탬프 한 줄만 들어가 있어도, 매 호출마다 그 값이 달라지면서 프리픽스 해시가 어긋나 그 뒤 캐시 전체가 무효화된다.</p>
<p>Anthropic 의 캐시는 <code>tools</code> → <code>system</code> → <code>messages</code> 순서의 계층으로 읽힌다. 그래서 앞쪽의 도구 정의 하나만 바뀌어도 그 뒤 캐시 전체가 무효화된다. 결론은 단순하다. <strong>정적인 것은 앞에, 매번 바뀌는 동적인 것은 뒤에 두어야 캐시가 산다.</strong></p>
<p>캐시에는 잘 알려지지 않은 제약도 몇 가지 있다. 캐시 가능한 최소 토큰 수가 모델마다, 또 같은 패밀리 안에서도 버전마다 다르다. 그보다 짧은 프롬프트는 캐시 설정을 걸어도 조용히 캐시되지 않는다. (이 최소 토큰 수와 프로바이더별 캐시 정책 차이는 비용 설계와 직결되는 부분이라, 토큰 절약법 글에서 가격표와 함께 자세히 다뤘다.)</p>
<h2 id="컨텍스트-윈도우가-토큰-한도를-만드는-이유"><a class="anchor" href="#컨텍스트-윈도우가-토큰-한도를-만드는-이유">컨텍스트 윈도우가 토큰 한도를 만드는 이유</a></h2>
<p>마지막으로 토큰을 이야기할 때 빠질 수 없는 개념이 컨텍스트 윈도우(context window)다. 모델이 한 번에 받아들일 수 있는 토큰 수의 한도를 가리키는 말인데, 왜 이런 한도가 존재하는지를 알면 "컨텍스트가 길어질수록 비싸진다" 는 흔한 경고의 정체도 분명해진다.</p>
<p>핵심은 셀프 어텐션의 연산 구조에 있다. 어텐션은 모든 토큰이 다른 모든 토큰을 한 번씩 참고하는 계산이다. 토큰이 n개라면 토큰 쌍의 수는 n × n, 즉 <strong>셀프 어텐션의 연산량과 메모리가 시퀀스 길이 n의 제곱에 비례해 늘어난다.</strong> 토큰이 2배가 되면 어텐션 비용은 4배가 되는 식이다. (모델 전체가 O(n²)인 건 아니고, 어텐션 단계가 그렇다는 점은 짚어둘 필요가 있다.)</p>
<p><picture><source type="image/webp" srcset="/content/260610/5.330w.webp 330w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/5.png" alt="모든 토큰이 다른 모든 토큰을 참고하는 셀프 어텐션 행렬 구조. 토큰 수 n에 대해 토큰 쌍이 n×n 으로 늘어난다" width="330" height="304" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>이 제곱 증가가 얼마나 가파른지 <a href="https://huggingface.co/docs/transformers/en/llm_tutorial_optimization" target="_blank" rel="noopener noreferrer">HuggingFace 의 추정 수치</a>가 잘 보여준다. 최적화 없는 표준 어텐션 기준으로, 어텐션 점수 행렬을 저장하는 데만 입력이 1,000 토큰일 때는 약 50MB 면 충분하지만, 16,000 토큰에서는 약 19GB, 10만 토큰에서는 거의 1TB 가 필요하다.</p>
<p>여기에 앞서 본 KV 캐시도 토큰 수에 비례해 메모리를 차지한다. 컨텍스트가 길어질수록 들고 있어야 할 K/V 가 선형으로 쌓이는 것이다. 결국 컨텍스트 윈도우 한도는 "이보다 길어지면 메모리와 연산이 감당이 안 된다" 는 물리적 한계를 모델이 정해둔 선인 셈이다.</p>
<p>그래서 긴 대화를 그대로 끌고 가는 게 단순히 입력 토큰 수만 늘리는 문제가 아니라는 점이 중요하다. 어텐션 비용이 제곱으로 붙고, 토큰이 한도에 가까워질수록 모델이 정보를 다루는 정확도까지 떨어진다. (이 정확도 저하 현상은 절약법 글에서 다룬 context rot 과 직결되는데, 컨텍스트를 가볍게 유지하는 일이 비용과 정답률 양쪽에 이로운 이유가 바로 여기에 있다.)</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>여기까지 정리하고 보면, 토큰의 동작 원리는 몇 개의 단순한 사실로 압축된다. <strong>토큰은 BPE 가 자주 쓰이는 패턴을 묶어 만든 어휘의 단위</strong>이고, 토큰 ID 를 거쳐 임베딩 벡터로 바뀌어 모델에 들어간다. <strong>입력은 병렬 prefill 로 한 번에 처리되지만 출력은 한 토큰씩 짜내야 해서 더 비싸고</strong>, 그 비효율을 덜어주는 게 K/V 를 재사용하는 KV 캐시다. <strong>캐시가 단가를 깎는 건 마법이 아니라 트랜스포머가 이미 계산해 둔 K/V 를 다시 계산하지 않기 때문이며</strong>, 프리픽스가 정확히 같아야 한다는 까다로운 조건도 이 구조에서 곧장 따라 나온다. 그리고 컨텍스트가 길어질수록 비싸지는 건 어텐션 비용이 토큰 수의 제곱으로 붙기 때문이다.</p>
<p>이 토대를 알고 나면 비용 이야기가 훨씬 또렷하게 보인다. 출력이 왜 비싼지, 캐시를 깨지 않으려면 프롬프트를 어떻게 배치해야 하는지, 컨텍스트를 왜 가볍게 유지해야 하는지, 같은 답이면 왜 더 싼 모델로 옮겨야 하는지 같은 절약 전략들이 전부 이 원리 위에 서 있기 때문이다. 동작 원리가 궁금해서 여기까지 읽은 독자라면, 이어서 토큰 절약법 에서 그 원리들이 실제 비용을 어떻게 깎는지 확인해보시길 권하고 싶다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://en.wikipedia.org/wiki/Byte-pair_encoding" target="_blank" rel="noopener noreferrer">Philip Gage, A New Algorithm for Data Compression (1994)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/1508.07909" target="_blank" rel="noopener noreferrer">Sennrich, Haddow, Birch, Neural Machine Translation of Rare Words with Subword Units (ACL 2016)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://jalammar.github.io/illustrated-transformer/" target="_blank" rel="noopener noreferrer">Jay Alammar, The Illustrated Transformer</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/learn/nlp-course/en/chapter6/5" target="_blank" rel="noopener noreferrer">HuggingFace, Byte-Pair Encoding tokenization</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/docs/transformers/en/kv_cache" target="_blank" rel="noopener noreferrer">HuggingFace, Transformers KV Cache strategies</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/openai/tiktoken" target="_blank" rel="noopener noreferrer">OpenAI, tiktoken</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://openai.com/index/hello-gpt-4o/" target="_blank" rel="noopener noreferrer">OpenAI, Hello GPT-4o (Language tokenization)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.artfish.ai/p/all-languages-are-not-created-tokenized" target="_blank" rel="noopener noreferrer">Yennie Jun, All languages are NOT created (tokenized) equal</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices" target="_blank" rel="noopener noreferrer">Databricks, LLM Inference Performance Engineering Best Practices</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.baseten.co/blog/llm-transformer-inference-guide/" target="_blank" rel="noopener noreferrer">Baseten, A guide to LLM inference and performance</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic, Prompt Caching</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>토큰</category>
        </item>
        <item>
            <title><![CDATA[Context file]]></title>
            <link>https://hooninedev.com/260529</link>
            <guid isPermaLink="false">https://hooninedev.com/260529</guid>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 AI 코딩 에이전트가 읽는 context file에 대한 이야기를 해보려고 한다. 한 프로젝트에 CLAUDE.md, AGENTS.md, SKILL.md, .cursor/rules가 함께 쌓여 있는데, 각각 언제 읽히고 무엇을 적어야 하는지 헷갈리는 개발자를 위한 글이다. 끝까지 읽으면 파일별 차이와 에이전트가 그 파일을 읽어 들이는 방식,...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>AI 코딩 에이전트가 읽는 context file</strong>에 대한 이야기를 해보려고 한다.</p>
<p>한 프로젝트에 <code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>SKILL.md</code>, <code>.cursor/rules</code>가 함께 쌓여 있는데, 각각 언제 읽히고 무엇을 적어야 하는지 헷갈리는 개발자를 위한 글이다. 끝까지 읽으면 파일별 차이와 에이전트가 그 파일을 읽어 들이는 방식, 그리고 ETH Zurich 연구를 근거로 한 "추론할 수 없는 정보만 적는다"는 판단 기준을 얻을 수 있다.</p>
<p>필자는 프론트엔드 개발자로 일하면서 일상적으로 Claude를 활용한다. 그러다 보니 어느 순간부터 프로젝트 루트에 <code>CLAUDE.md</code>가 생기고, 옆에 누가 만들어둔 <code>AGENTS.md</code>가 있고, <code>.cursorrules</code>도 한쪽에 남아있고, 어디선가 본 글을 따라 <code>.claude/skills/</code> 폴더를 만들기도 했다. (정신을 차려보니 비슷한 내용을 적어둔 파일이 다섯 개쯤 되어 있었다.)</p>
<h2 id="지속-메모리가-없는-에이전트"><a class="anchor" href="#지속-메모리가-없는-에이전트">지속 메모리가 없는 에이전트</a></h2>
<p>AI 코딩 에이전트에는 <strong>지속 메모리가 없다는</strong> 근본적인 한계가 있다. 모든 세션은 빈 상태로 시작되고, 어제 합의한 컨벤션이나 한 시간 전에 알려준 폴더 구조 같은 것을 다음 대화에서는 기억하지 못한다. Context file은 이 문제를 해결하기 위한 가장 단순한 장치다. 세션이 시작될 때마다 자동으로 읽히는 파일을 프로젝트에 두면, 매번 같은 설명을 반복할 필요가 없다.</p>
<p>문제는 같은 발상에서 출발한 파일이 도구마다 따로 만들어졌다는 점이다. Claude Code는 <code>CLAUDE.md</code>를, Cursor는 <code>.cursorrules</code>(현재는 depreacted가 되어 <code>.cursor/rules</code> 사용을 권장하고있다)를, GitHub Copilot은 <code>.github/copilot-instructions.md</code>를, OpenAI Codex는 <code>AGENTS.md</code>를 읽는다. 한 팀이 여러 도구를 쓰면 같은 내용을 네 군데에 복사해 두어야 하는 상황이 벌어지는 것이다.</p>
<h3 id="claudemd"><a class="anchor" href="#claudemd">CLAUDE.md</a></h3>
<p><code>CLAUDE.md</code>는 Claude Code가 세션 시작 시 자동으로 읽어 들이는 파일이다. Anthropic 공식 문서(<code>code.claude.com/docs/en/memory</code>)에 따르면, Claude Code는 다음 세 계층에서 <code>CLAUDE.md</code>를 찾는다.</p>
<ul>
<li><strong>사용자 메모리</strong> (<code>~/.claude/CLAUDE.md</code>): 머신의 모든 프로젝트에 적용되는 전역 기본값</li>
<li><strong>프로젝트 메모리</strong> (프로젝트 루트의 <code>CLAUDE.md</code>): 깃에 커밋되어 팀 전체가 공유</li>
<li><strong>로컬 메모리</strong> (서브디렉토리의 <code>CLAUDE.md</code>): 해당 디렉토리에서 작업할 때만 추가로 로딩</li>
</ul>
<p>세 계층이 모두 존재하면 Claude는 <strong>모두 읽어 연결(concatenate)</strong> 한다. 우선순위에 따라 하나만 선택되는 게 아니라, CSS의 cascade처럼 더 구체적인 것이 추가로 얹히는 구조다. (오버라이드가 아니라 병합이다.) 따라서 같은 주제의 규칙을 여러 계층에 흩어두면 충돌이 날 수 있다.(Anthropic 공식 문서는 충돌 시 동작이 보장되지 않는다고 명시하고있다.)</p>
<p>여기서 한 가지 자주 놓치는 부분이 있다. <strong>현재 작업 디렉토리에서 레포 루트까지 거슬러 올라가며 만나는 모든 <code>CLAUDE.md</code>를 읽는다</strong>는 점이다. 그래서 모노레포에서 <code>packages/ui/</code>에 들어가서 작업하면 루트의 <code>CLAUDE.md</code>와 <code>packages/ui/CLAUDE.md</code>가 둘 다 로딩된다. (이건 강력하지만 동시에 컨텍스트가 부지불식간에 부풀 수 있다는 뜻이기도 하다.)</p>
<h3 id="agentsmd"><a class="anchor" href="#agentsmd">AGENTS.md</a></h3>
<p><code>AGENTS.md</code>는 위에서 말한 도구별 파일 난립을 풀기 위해 만들어진 표준이다. 2025년 12월, OpenAI는 이 표준을 Linux Foundation 산하 <strong>Agentic AI Foundation(AAIF)</strong> 에 기증했다. <a href="https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Linux Foundation의 발표</a>에 따르면 AAIF는 Anthropic·Block·OpenAI가 공동 창립했고, 창립 프로젝트는 Anthropic의 MCP(에이전트를 외부 시스템에 연결하는 프로토콜), Block의 goose, OpenAI의 <code>AGENTS.md</code> 셋이다. 공식 사이트(<code>agents.md</code>)에서 <strong>6만 개 이상의 오픈소스 저장소가 이 파일을 채택하고 있다</strong>고 명시하고 있다.</p>
<p>지원하는 도구 목록을 보면 더 분명해진다. OpenAI Codex, Google Jules, VS Code, GitHub Copilot, Cursor, JetBrains Junie, Aider, Devin, Zed, Factory, Warp, goose, opencode, Amp, RooCode, Gemini CLI, Kilo Code, Phoenix, Semgrep, Ona, Windsurf, Augment Code 까지 수많은 도구들을 지원한다. GitHub Copilot은 2025년 8월부터 <code>AGENTS.md</code>를 네이티브로 지원하기 시작했다. 한 가지 흥미로운 점은 <strong>Claude Code의 네이티브 <code>AGENTS.md</code> 지원은 아직 active feature request 상태</strong>라는 것이다. Claude Code는 여전히 <code>CLAUDE.md</code>를 1차 파일로 본다.</p>
<p>표준이라고는 하지만 정말 채택되고 있는지 의심스러울 수도 있다. 가장 강력한 증거는 <strong>dogfooding</strong>(자신이 만든 표준을 자신이 직접 쓴다는 의미)이다.</p>
<ul>
<li><strong>Vercel/Next.js</strong>의 canary 브랜치 루트에는 <code>AGENTS.md</code>가 있다. 사실은 <code>CLAUDE.md</code>를 가리키는 심볼릭 링크인데, 그 안에 모노레포 구조, <code>pnpm --filter=next dev</code>로 1-2초 단위 반복, Turbopack/Webpack 양쪽 테스트 가이드, <code>pr-status</code> 스크립트, 환경 변수·시크릿 처리 규칙까지 들어 있다. <code>create-next-app</code>이 신규 프로젝트에 <code>AGENTS.md</code>와 <code>CLAUDE.md</code>를 함께 생성하도록 바뀐 것도 같은 흐름이다.</li>
<li><strong>OpenAI/codex</strong> 레포 자체가 자신의 <code>AGENTS.md</code>를 운영한다.</li>
</ul>
<p>전략적으로는 이렇게 운용하는 게 정석으로 굳어지고 있다. <strong><code>AGENTS.md</code>를 단일 정보 출처(single source of truth)로 두고</strong>, <code>CLAUDE.md</code>는 최소화해서 <code>AGENTS.md</code>를 참조하는 한 줄과 Claude Code 전용 지시만 적어두는 방식이다. 이렇게 하면 중복이 사라지고, Claude Code는 두 파일을 모두 읽기 때문에 잃는 것이 없다.</p>
<h3 id="skillmd"><a class="anchor" href="#skillmd">SKILL.md</a></h3>
<p><code>SKILL.md</code>는 위 두 파일과는 결이 다르다. <code>CLAUDE.md</code>와 <code>AGENTS.md</code>가 <strong>항상 컨텍스트에 존재하는 영속적 지시</strong>라면, 스킬(Skill)은 <strong>필요할 때만 호출되는 온디맨드 능력</strong>이다.</p>
<p>스킬은 폴더 단위로 구성된다. 폴더 안에 <code>SKILL.md</code> 한 개와 그 스킬이 실행할 스크립트, 추가 마크다운 문서들이 들어있다. Claude는 현재 태스크가 스킬의 <code>description</code>과 매칭될 때만 그 폴더를 로딩한다. 이걸 <strong>progressive disclosure(점진적 노출)</strong> 라고 부르는데, 1995년 UX 분야의 Jakob Nielsen이 정립한 개념으로 고급·드물게 쓰이는 기능을 보조 화면으로 미뤄두고 사용자가 한 번에 하나의 작업에만 집중하게 해서 인지 부하와 에러를 줄이는 기법이다. Claude Skills 맥락에서는 "필요할 때만 그 스킬의 본문을 컨텍스트로 가져오는" 메커니즘을 가리킨다. 결과적으로 컨텍스트 윈도우 비용을 극적으로 아낄 수 있다.</p>
<p><code>SKILL.md</code>의 frontmatter에는 몇 가지 고유한 필드가 있다.</p>
<ul>
<li><strong><code>description</code></strong>: 어떤 상황에서 이 스킬이 필요한지 설명. 모델이 호출 여부를 판단하는 트리거가 된다</li>
<li><strong><code>allowed-tools</code></strong>: 스킬 안에서 사용 가능한 도구를 제한 (예: <code>"Read, Glob, Grep, Bash(python:*)"</code>)</li>
<li><strong><code>disable-model-invocation: true</code></strong>: 모델은 호출 불가, 사용자만 슬래시 명령으로 트리거 가능하다. 부작용 있는 작업(배포·커밋 등)에 사용한다</li>
<li><strong><code>user-invocable: false</code></strong>: 사용자에게는 슬래시 메뉴에서 안 보이고 Claude만 자율 호출하여 배경 지식용에 사용한다.</li>
</ul>
<p>Claude Skills는 2025년 10월 16일 Claude.ai, Claude Code, API, Agent SDK에 동시 출시되었다. 그리고 2025년 12월 18일, Anthropic은 Skills 사양 자체를 오픈 표준(<code>agentskills.io</code>)으로 발표했다. Simon Willison은 "<strong>Skills are awesome, maybe a bigger deal than MCP</strong>"라는 평가를 내놓기도 했는데, 그 이유는 형식이 MCP보다 극적으로 단순하면서 컨텍스트 윈도우 비용 문제를 progressive disclosure로 해결한다는 점에 있었다.</p>
<p>여기서 Skills와 비교된 MCP(Model Context Protocol)는 에이전트가 Slack, GitHub, DB 같은 외부 시스템을 호출할 수 있게 연결하는 표준 프로토콜이다. Context file이 에이전트에게 무엇을 알려줄지의 문제라면, MCP는 무엇을 할 수 있게 해줄지의 문제다. MCP가 function calling과 무엇이 다른지는 <a href="/260524" target="_blank" rel="noopener noreferrer">MCP와 function calling</a>에 따로 정리해 두었다.</p>
<p>에이전트가 관련 코드를 찾는 비용을 줄이는 도구들(Repomix, Aider, CodeGraph, Serena)은 코드를 얼마나 깊이 이해하느냐에 따라 줄이는 비용이 다르다. 그 비교는 <a href="/260526" target="_blank" rel="noopener noreferrer">Code intelligence의 네 계층</a>에 따로 정리해 두었다.</p>
<h3 id="다른-도구들의-파일"><a class="anchor" href="#다른-도구들의-파일">다른 도구들의 파일</a></h3>
<p>Cursor의 <code>.cursorrules</code>는 <strong>0.43 버전부터 deprecated</strong> 되었다. 현재 공식 권장은 <code>.cursor/rules/</code> 디렉토리 안에 여러 <code>.mdc</code> 파일을 두는 것이다. 각 <code>.mdc</code> 파일은 YAML frontmatter를 가진다.</p>
<ul>
<li><strong><code>description</code></strong>: 에이전트가 이 룰의 관련성을 판단할 때 참고</li>
<li><strong><code>globs</code></strong>: 매칭되는 파일이 대화에 포함될 때 자동 첨부(auto-attach)</li>
<li><strong><code>alwaysApply</code></strong>: <code>true</code>면 모든 대화에 무조건 포함 (이 경우 <code>globs</code>는 무시)</li>
</ul>
<p>GitHub Copilot도 비슷한 방향으로 진화했다. 저장소 전역 지시는 <code>.github/copilot-instructions.md</code>에 두고, 경로별 스코프가 필요한 지시는 <code>.github/instructions/*.instructions.md</code> 파일을 만들어 frontmatter의 <code>applyTo:</code> 키로 glob을 지정한다. (Copilot code review는 2025년 9월부터 path-scoped instructions를 공식 지원한다.)</p>
<p>Cursor·Copilot 외의 도구들도 모두 비슷한 패턴으로 수렴하고 있다. 표로 정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>파일/디렉토리</th>
<th>특징</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Claude Code</strong></td>
<td><code>CLAUDE.md</code> (3계층)</td>
<td>디렉토리 트리 따라 병합</td>
</tr>
<tr>
<td><strong>Cursor</strong></td>
<td><code>.cursor/rules/*.mdc</code></td>
<td><code>globs</code>로 파일 패턴 스코핑</td>
</tr>
<tr>
<td><strong>GitHub Copilot</strong></td>
<td><code>.github/copilot-instructions.md</code> + <code>.github/instructions/*.instructions.md</code></td>
<td><code>applyTo</code> glob 지원</td>
</tr>
<tr>
<td><strong>Cline</strong></td>
<td><code>.clinerules/</code> 디렉토리</td>
<td>모든 <code>.md</code>/<code>.txt</code> 통합, <code>paths</code> glob 조건부 활성화</td>
</tr>
<tr>
<td><strong>Continue.dev</strong></td>
<td><code>.continue/rules/*.md</code></td>
<td><code>name</code>/<code>globs</code>/<code>alwaysApply</code> frontmatter</td>
</tr>
<tr>
<td><strong>Aider</strong></td>
<td><code>CONVENTIONS.md</code> + <code>.aider.conf.yml</code></td>
<td>매 요청마다 포함, <strong>200줄 이내 권장</strong></td>
</tr>
<tr>
<td><strong>Windsurf</strong></td>
<td><code>.windsurfrules</code> + <code>global_rules.md</code></td>
<td>글로벌과 프로젝트 2단계</td>
</tr>
<tr>
<td><strong>표준</strong></td>
<td><code>AGENTS.md</code> (AAIF)</td>
<td>60,000+ 저장소 채택</td>
</tr>
</tbody>
</table>
<p>특히 <strong>Aider의 <code>CONVENTIONS.md</code>가 흥미롭다</strong>. 공식 문서는 매 요청마다 이 파일을 통째로 컨텍스트에 포함하기 때문에 <strong>"200줄 이내로 유지하라"</strong> 고 명시한다. (Aider는 이 한계를 일찍 인지하고 사용자에게 명시적으로 알려주는 셈이다.)</p>
<h3 id="memorymd"><a class="anchor" href="#memorymd">MEMORY.md</a></h3>
<p>위 파일들과는 별개로, 점점 자주 보이는 패턴이 하나 더 있다. <code>MEMORY.md</code>다. 공식 표준은 아니지만 커뮤니티에서 자생적으로 생겨난 컨벤션인데, <strong>시간이 흐르는 동안의 의사결정과 실수를 기록</strong>하는 용도다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="markdown" data-theme="github-dark github-light"><code data-language="markdown" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-10</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Pages Router에서 App Router로 이전. 신규 라우트는 App Router 컨벤션 사용.</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-22</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Prisma 쿼리 결과에 optional chaining 쓰지 말 것 — null은 if-check로 명시적 처리.</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(이전에 옵셔널 체이닝으로 null을 흘려보내 프로덕션 이슈 발생.)</span></span></code></pre></figure>
<p><code>CLAUDE.md</code>나 <code>AGENTS.md</code>가 <strong>현재 시점의 규칙</strong>을 적는 곳이라면, <code>MEMORY.md</code>는 <strong>그 규칙이 왜 만들어졌는지의 역사</strong>를 적는 곳이다. (둘은 보완 관계지 대체 관계가 아니다.)</p>
<h3 id="에이전트는-이-파일들을-어떻게-읽는가"><a class="anchor" href="#에이전트는-이-파일들을-어떻게-읽는가">에이전트는 이 파일들을 어떻게 읽는가</a></h3>
<p>지금까지 어떤 파일이 있는지를 정리했다. 그런데 의외로 자주 빠지는 질문이 하나 있다. <strong>이 파일들을 에이전트는 정확히 어디로, 어떻게 읽어들이는 걸까?</strong> 사실 이 메커니즘을 알면 뒤에 이어질 ETH Zurich 결과(context file이 잘 안 따라진다는 결과)가 왜 나왔는지가 좀 더 깔끔하게 이해된다.</p>
<p>가장 먼저 짚어둘 사실 하나. <strong><code>CLAUDE.md</code>는 system prompt가 아니라 user message로 주입된다.</strong> Anthropic 공식 문서가 아래와 같이 명시한다</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>CLAUDE.md 콘텐츠는 시스템 프롬프트의 일부가 아니라 시스템 프롬프트 뒤에 사용자 메시지로 전달된다. Claude는 이를 읽고 따르려 하지만, 엄격히 준수한다는 보장은 없다.</p></div><div class="quote-original" lang="en"><p>CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance.</p></div></blockquote>
<p>즉 강제 규칙이 아니라 "참고용 컨텍스트"인 셈이다. 정확히 어떤 동작을 강제하고 싶다면 <code>PreToolUse</code> 훅 같은 별도 장치를 써야 한다고 공식 가이드도 권장한다.</p>
<p>로드 순서는 broad → specific 으로 쌓인다. 구체적으로는 managed policy(조직 차원의 설정) → 사용자 글로벌(<code>~/.claude/CLAUDE.md</code>) → 프로젝트(<code>./CLAUDE.md</code>) → 로컬(<code>./CLAUDE.local.md</code>) 순이다. 같은 디렉토리에서는 <code>CLAUDE.md</code> 다음 <code>CLAUDE.local.md</code> 순. <strong>가장 가까운 곳의 지시가 가장 나중에 읽힌다</strong>는 점을 활용하면 (LLM의 recency bias 덕에) 더 구체적인 규칙이 더 강하게 작용하는 효과를 노릴 수 있다.</p>
<p>여기서 흥미로운 게 <code>@import</code> 문법이다. CLAUDE.md 본문 어디에 <code>@path/to/file</code>을 적으면 그 파일이 자리에 그대로 펼쳐져서 같이 로드된다. <strong>최대 재귀 깊이는 4 hops</strong>까지 허용되고, 상대경로는 import문이 적힌 파일 기준으로 풀린다. 그래서 공식 권장이 <code>@AGENTS.md</code>로 브리지하는 방식이다. <code>CLAUDE.md</code>는 거의 비워두고 <code>@AGENTS.md</code> 한 줄만 쓰면 Claude Code도 자연스럽게 AGENTS.md를 읽는다. (CLAUDE.md가 아직 AGENTS.md를 네이티브 지원하지 않는 현 상황에서 가장 깔끔한 우회다.)</p>
<p>토큰 측면도 짚고 가자. CLAUDE.md 자체에는 명시적 토큰 한도가 없어서 <strong>있으면 전부 로드된다</strong>. 다만 공식 권고는 <strong>파일당 200줄 이내</strong>다. 200줄을 넘기면 "consume more context and may reduce adherence"라고 명시되어 있다. 흥미롭게도 Claude 4.x에서는 <strong>tool use를 활성화하는 것만으로 special system prompt가 자동 +346토큰</strong>(<code>tool_choice: auto</code> 기준) 추가된다. 컨텍스트는 알게 모르게 새고 있는 셈이다.</p>
<p>Cursor는 또 다른 방식이다. <code>.cursor/rules/*.mdc</code>의 룰은 네 가지 타입으로 작동한다.</p>
<ul>
<li><strong>Always Apply</strong>: 모든 채팅에 무조건 포함. globs/description 무시</li>
<li><strong>Apply Intelligently</strong>(Agent Requested): 에이전트가 <code>description</code>을 읽고 관련성을 판단해 끌어다 씀</li>
<li><strong>Apply to Specific Files</strong>(Auto Attached): glob 패턴 매칭 파일이 컨텍스트에 들어왔을 때 활성화</li>
<li><strong>Apply Manually</strong>: <code>@rule-name</code>으로 사용자가 명시 호출</li>
</ul>
<p>다른 도구들은 또 다르다. OpenAI Codex는 git 레포 루트에서 cwd 방향으로 walk하며 모든 <code>AGENTS.md</code>를 수집해 <strong>사용자 프롬프트 직전에</strong> 주입하고, GitHub Copilot은 <code>.github/copilot-instructions.md</code>를 "edit context와 explicit references 다음, loosely related open files보다는 앞"이라는 컨텍스트 윈도우의 중간 우선순위로 끼워 넣는다. 같은 <code>AGENTS.md</code> 파일이라도 로드 시점, 우선순위, 머지 규칙이 도구마다 다르기 때문에 <strong>세 도구가 정확히 같은 방식으로 그 파일을 본다는 보장은 없다.</strong></p>
<p>그런데 여기서 한 가지 근본적인 질문이 남는다. <strong>왜 모델은 컨텍스트에 있는 지시를 일부만 따르는 걸까?</strong> 단순히 "지시가 길어서"라는 설명은 충분하지 않다. 이 현상의 밑에는 LLM의 구조적 한계가 있다.</p>
<h3 id="환각과-context-망각"><a class="anchor" href="#환각과-context-망각">환각과 context 망각</a></h3>
<p>AI 에이전트가 대화 맥락을 혼동하거나 앞에서 분명히 말한 내용을 뒤에서 잊어버리는 경험을 해본 적이 있다면, 그게 바로 <strong>환각(Hallucination)</strong> 의 한 형태다. 일반적으로 환각이라고 하면 "없는 사실을 지어내는 것"을 먼저 떠올리지만, 학술적으로는 세 가지로 구분한다. Yue Zhang 외 연구팀의 2023년 설문("Siren's Song in the AI Ocean")은 이를 <strong>입력 충돌형</strong>(사용자가 명시한 내용과 다르게 생성), <strong>맥락 충돌형</strong>(이전에 자신이 생성한 내용과 모순), <strong>사실 충돌형</strong>(세계 지식과 불일치)으로 나눈다. Context file 지시를 무시하는 현상은 세 번째가 아니라 <strong>첫 번째 유형</strong>이다. 모델이 입력을 처리하면서 그 안의 정보 일부를 "없던 것처럼" 다루는 것이다.</p>
<p>더 근본적인 문제는 이 환각이 <strong>원천적으로 제거 불가능</strong>하다는 점이다. 싱가포르 국립대학교 연구팀은 학습 이론을 이용해 이를 수학적으로 증명했다. 어떤 LLM도 모든 계산 가능한 함수를 학습할 수 없고, 따라서 일반 문제 해결기로 쓰이는 한 반드시 어느 지점에서 환각이 발생한다는 것이다.</p>
<p>위치 효과도 중요하다. Stanford 연구팀은 관련 정보가 <strong>컨텍스트 윈도우의 앞이나 끝에 위치할 때</strong> 모델이 가장 잘 참조하고, <strong>중간에 묻혔을 때</strong> 성능이 크게 떨어진다는 것을 실험으로 보였다. 이게 context file에 직접 연결된다. <code>CLAUDE.md</code>가 로드 순서상 중간 어딘가에 끼어 들어가고, 그 뒤로 대화가 길어질수록 파일의 지시는 점점 컨텍스트의 "중간"으로 밀려난다. 앞서 언급한 recency bias(최근 것을 더 잘 따르는 경향)의 반대편, 즉 <strong>primacy-recency 효과의 중간 구간이 가장 취약하다</strong>는 사실과도 맞닿아 있다.</p>
<p>결국 이 현상들을 한데 놓으면 하나의 그림이 된다. Context file은 LLM의 <strong>첫 user 턴 이전에 시스템 외부에서 추가로 끼어 들어가는 텍스트</strong>일 뿐이다. 모델의 결정을 강제하는 메커니즘이 아니라 그저 컨텍스트 윈도우에 떨어지는 또 하나의 토큰 덩어리인 것이다. 길수록, 그리고 대화가 길어질수록 그 지시는 점점 "중간"으로 밀려나며 참조율이 떨어진다. ETH Zurich 결과는 이 구조적 한계를 정량적으로 확인한 셈이다.</p>
<h3 id="eth-zurich의-연구"><a class="anchor" href="#eth-zurich의-연구">ETH Zurich의 연구</a></h3>
<p>많은 사람들은 "그럼 이 파일들에 최대한 많이 적어두면 좋겠네?" 하는 생각을 했을 것이다. 그런데 이 직관을 정면으로 반박하는 연구가 최근에 나왔다. 바로 앞에서 계속 이야기한 ETH Zurich 연구이다.</p>
<p>ETH Zurich 연구팀이 2026년 2월에 발표한 논문("Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents?")이다. 138개의 실제 Python 소프트웨어 엔지니어링 태스크 벤치마크(AGENTBENCH)와 SWE-bench Lite에서 Claude Code(Sonnet-4.5), Codex(GPT-5.2 / GPT-5.1 mini), Qwen Code 네 가지 에이전트로 측정했는데, 결과가 의외였다.</p>
<ul>
<li><strong>LLM이 자동 생성한 context file</strong>은 SWE-bench Lite에서 0.5%, AGENTBENCH에서 2% 가량 <strong>태스크 성공률을 오히려 낮췄다</strong></li>
<li><strong>사람이 직접 작성한 파일</strong>조차 평균 4% 정도의 마이너 개선에 그쳤다</li>
<li>Context file을 추가했을 때 <strong>추론 비용이 인스턴스당 20% 이상 증가</strong>했다</li>
<li>더 강력한 모델(GPT-5.2)에서는 context file의 효과가 더 미미했다 (강력한 모델일수록 파라메트릭 지식이 충분해서 추가 컨텍스트가 노이즈로 작용)</li>
</ul>
<p>다만 한 가지 예외가 있었다. <strong>비표준 도구를 명시했을 때</strong>다. 예를 들어 Python 패키지 매니저인 <code>uv</code>를 컨텍스트에 명시하니, 에이전트가 <code>uv</code>를 사용하는 빈도가 인스턴스당 0.01회 => 1.6회로 <strong>약 160배 증가했다</strong></p>
<p>앞서 다룬 Aider의 "200줄 권고", "매번 컨텍스트에 들어가니까 짧게 유지하라"라는 실용성을 안내한 거고, ETH Zurich 연구는 "긴 context file이 통계적으로 성능을 떨어뜨린다"는 걸 정량적으로 보여줬다. 필자가 생각하는 이 연구의 실천적 함의는 아래와같다.</p>
<ul>
<li><strong>자동 생성된 거대한 context file은 도움보다 해가 될 수 있다</strong>. 300줄짜리 <code>CLAUDE.md</code>에 코딩 표준·아키텍처·워크플로우를 다 욱여넣으면, 에이전트는 일부만 따르고 나머지를 무시한다. 그 불일관성은 차라리 컨텍스트가 없는 것보다 나쁜 결과로 이어진다.</li>
<li><strong>반드시 적어야 하는 것은 "추론할 수 없는 정보"</strong> 다. 비표준 도구, 프로젝트 고유의 컨벤션, 과거 실패 사례 같은 것들이 여기에 속한다. 일반적인 코딩 베스트 프랙티스는 모델이 이미 안다.</li>
<li>AGENTS.md를 단일 출처로, CLAUDE.md는 도구 전용 짧은 지시만, 세한 워크플로우는 Skill로 분리한다.</li>
</ul>
<h2 id="그래서"><a class="anchor" href="#그래서">그래서</a></h2>
<p>Context file은 도구마다 이름도, 놓이는 위치도, 읽히는 시점도 다르다. 이 글에서 본 것만 해도 CLAUDE.md, AGENTS.md, SKILL.md, Cursor rules, copilot-instructions.md, MEMORY.md가 있고, 어떤 도구가 어떤 파일을 지원하는지도 계속 바뀐다. 파일 목록을 외워 두는 것만으로는 금세 낡는다.</p>
<p>그래서 필자가 이 글에서 하려 했던 것은 특정 파일 형식을 추천하는 것이 아니라, <strong>파일이 에이전트에게 어떻게 읽히는지 보는 눈</strong>을 만드는 것이었다. CLAUDE.md가 왜 user message로 주입되는지, 같은 AGENTS.md를 도구마다 왜 다르게 읽는지, 지시가 왜 컨텍스트 중간에서 약해지는지를 알고 나면, 새로운 context file 형식이 등장했을 때 "이건 언제 로드되고 얼마나 강하게 작용하는구나"가 빠르게 읽힌다.</p>
<p>결국 남는 건 ETH Zurich 연구가 건넨 한 가지 직관이다. <strong>모델은 이미 많은 것을 안다.</strong> context file에 온갖 것을 욱여넣는다고 에이전트가 더 잘 따르지 않는다. 모델이 모를 가능성이 높은 것, 즉 프로젝트 고유의 컨벤션·비표준 도구·과거의 실수만 남기고 나머지는 걷어내는 것이 오히려 낫다. Context file을 길게 쓰는 것과 잘 쓰는 것은 다른 문제다.</p>
<p>이 글을 읽는 독자들도 지금 당장 CLAUDE.md를 수백 줄로 늘리기보다는, 지금 쓰고 있는 도구가 그 파일을 언제, 어느 자리에, 얼마나 강하게 읽어 들이는지 한 번쯤 파고들어 보기를 권한다. 그게 파일 형식이 어떻게 바뀌든 흔들리지 않는 기반이 된다고 생각한다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/memory" target="_blank" rel="noopener noreferrer">Claude Code Memory, Anthropic</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">Anthropic Tool Use Overview</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://cursor.com/docs/context/rules" target="_blank" rel="noopener noreferrer">Cursor Rules Documentation</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2602.11988" target="_blank" rel="noopener noreferrer">ETH Zurich, "Evaluating AGENTS.md" (2602.11988)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2309.01219" target="_blank" rel="noopener noreferrer">Yue Zhang et al., "Siren's Song" (2309.01219)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2401.11817" target="_blank" rel="noopener noreferrer">Ziwei Xu et al., "Hallucination is Inevitable" (2401.11817)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2307.03172" target="_blank" rel="noopener noreferrer">Nelson F. Liu et al., "Lost in the Middle" (2307.03172)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://simonwillison.net/2025/Oct/16/claude-skills/" target="_blank" rel="noopener noreferrer">Simon Willison, "Claude Skills are awesome"</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>개발도구</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[Code intelligence의 네 계층]]></title>
            <link>https://hooninedev.com/260526</link>
            <guid isPermaLink="false">https://hooninedev.com/260526</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 AI 코딩 에이전트가 관련 코드를 찾는 비용을 줄이는 도구들이 서로 무엇이 다른지에 대한 이야기를 해보려고 한다. 큰 코드베이스에서 에이전트가 grep과 파일 읽기를 되풀이하며 토큰을 쓰는 것을 보고, Repomix나 CodeGraph, Serena 같은 도구 중 무엇을 붙여야 할지 고민하는 개발자를 위한 글이다. 이 도구들은 코드를 얼마...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>AI 코딩 에이전트가 관련 코드를 찾는 비용을 줄이는 도구들이 서로 무엇이 다른지</strong>에 대한 이야기를 해보려고 한다.</p>
<p>큰 코드베이스에서 에이전트가 grep과 파일 읽기를 되풀이하며 토큰을 쓰는 것을 보고, Repomix나 CodeGraph, Serena 같은 도구 중 무엇을 붙여야 할지 고민하는 개발자를 위한 글이다. 이 도구들은 코드를 얼마나 깊이 이해하는지에 따라 context packing, tree-sitter repo map, 지식 그래프, LSP의 네 계층으로 갈리고, 계층마다 탐색 비용을 줄이는 지점이 다르다. 지식 그래프는 제작사의 벤치마크를, 나머지 세 계층은 이 블로그 리포에서 잰 결과를 싣는다.</p>
<p><code>codegraph</code>가 GitHub Trending에 오른 걸 보고 따라 설치해 본 뒤로, 필자는 새 도구를 볼 때마다 그것이 어떤 원리로 토큰을 아끼는지가 궁금했다. 이 글에 나오는 도구 상당수도 필자가 보통 weekly 기준으로 TypeScript와 Python을 골라 훑는 GitHub Trending에서 처음 알게 됐다.</p>
<h2 id="code-intelligence-도구"><a class="anchor" href="#code-intelligence-도구">Code intelligence 도구</a></h2>
<p>에이전트는 코드를 고치기 전에 관련 코드가 어디 있는지부터 찾는다. grep으로 찾고, 파일을 읽고, 걸러내고, 다시 grep 하는 루프다. 뒤에서 볼 CodeGraph 벤치마크에서 도구 없이 답한 쪽은 질문 하나에 tool call을 최대 43번 썼다. Code intelligence 도구는 이 탐색 비용을 줄이려는 시도들이다.</p>
<p>다만 비용이라는 말이 가리키는 것이 하나가 아니다. 모델이 처리한 토큰, tool call 횟수, 그리고 작업이 끝난 뒤에도 context window에 남아 있는 토큰은 따로 움직인다. 이 글은 계층마다 이 셋 중 무엇을 줄이는지를 본다.</p>
<p>필자는 도구가 코드를 얼마나 깊이 이해하는지를 기준으로 이 시도들을 아래 네 개의 계층(tier)으로 나눠 본다. 업계에 정해진 분류는 없고, 이 구분은 필자가 정리한 것이다. 이 글의 실측값은 전부 이 리포의 커밋 <code>36e5cfa</code> 에서 2026-10-08 에 얻었고, 토큰은 tiktoken의 <code>o200k_base</code> 로 셌다. Claude의 tokenizer와는 값이 다르므로 절대값보다 비교에 쓴다.</p>
<h3 id="context-packing"><a class="anchor" href="#context-packing">Context packing</a></h3>
<p>가장 단순한 해법은 "<strong>전부 다 한 context window에 넣어버리자</strong>"는 발상이다. 그래프도 안 만들고, 인덱싱도 안 한다. 그냥 리포 전체를 텍스트 덩어리로 직렬화해서 모델에게 통째로 던진다.</p>
<p>대표 도구가 <strong>Repomix</strong>다. 기본 출력 형식이 XML이고, README는 Anthropic의 XML 태그 문서를 함께 링크한다. CLI, 웹, 브라우저 확장, MCP 서버를 다 갖췄다.</p>
<p><strong>GitIngest</strong>는 설치 없이 쓸 수 있다는 점이 강점이다. GitHub URL의 <code>github.com</code>을 <code>gitingest.com</code>으로 바꾸면 리포 전체가 한 텍스트 페이지가 된다. Rust CLI인 <strong>code2prompt</strong>(Mufeed VH 제작)는 템플릿으로 출력 형식을 바꿀 수 있다.</p>
<p><strong>rtk</strong>(<code>rtk-ai/rtk</code>)는 방향이 조금 다르다. 위 도구들이 리포 전체를 한 번에 패킹한다면, rtk는 에이전트가 실행한 CLI 명령의 출력을 압축한다. Rust로 만든 단일 바이너리이고, Claude Code, Cursor, Copilot, Gemini CLI, Codex 등 여러 에이전트의 hook에 등록되어 에이전트가 <code>git status</code>를 호출하면 <code>rtk git status</code>로 바꿔 실행한다. 100개 이상의 명령에 filtering, grouping, truncation, deduplication을 적용한다. rtk의 hook은 Bash tool call만 대상으로 잡는다. Claude Code의 <code>Read</code>, <code>Grep</code>, <code>Glob</code> 은 그대로 지나간다.</p>
<p>줄어드는 양은 조심해서 읽어야 한다. <a href="https://github.com/rtk-ai/rtk/blob/8533612180c60efbcb5827c7db4e910aba096705/README.md#L66-L70" target="_blank" rel="noopener noreferrer">rtk README</a>는 Bash 출력을 최대 90% 줄인다고 쓰면서, 그것이 청구액을 90% 줄인다는 뜻은 아니라고 바로 덧붙인다. 명령 출력은 모델 입장에서 입력 토큰의 일부이고, 입력 토큰은 청구액의 일부라서 단계마다 희석된다. <a href="https://www.rtk-ai.app/" target="_blank" rel="noopener noreferrer">공식 사이트</a>가 내세우는 숫자는 rtk가 바꿔 실행한 명령 기준 평균 56%다. 위 도구들이 들어가는 텍스트를 줄인다면, rtk는 tool call 결과로 돌아오는 텍스트를 줄인다.</p>
<p>이 계층의 한계는 <strong>대형 리포가 토큰 한도에 걸린다</strong>는 것이다. 이 블로그의 <code>src/</code> 파일 109개만 패킹해도 94,596 tokens다. 이 정도는 창에 들어가지만, 파일이 수천 개인 리포라면 한도에 바로 닿는다. Repomix는 이 문제에 <code>--compress</code> 로 답한다. <a href="https://github.com/yamadashy/repomix/blob/8d6429121e98ed178e4d3a975c2bdbbecc958c4a/README.md#L797-L831" target="_blank" rel="noopener noreferrer">README</a>에 따르면 Tree-sitter로 함수와 클래스 시그니처를 남기고 구현 본문을 버린다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># repomix 1.18.1, 이 리포 커밋 36e5cfa, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">              # Total Tokens: 94,596 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --compress</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # Total Tokens: 30,320 tokens</span></span></code></pre></figure>
<p>68%가 줄었다. 주석은 그대로 남기 때문에, JSDoc이 긴 <code>visit-counter.ts</code> 는 1,642에서 약 1,220 tokens로 26%만 줄었다. 압축본에는 import 문과 시그니처가 남지만 함수 본문 안의 호출은 사라진다. 어느 파일이 무엇을 가져오는지는 텍스트로 보이지만, 누가 누구를 부르는지는 물을 수 없다. 그래서 이 계층과 다음 계층의 경계는 구문을 보느냐보다 관계를 물을 수 있느냐에 있다.</p>
<h3 id="tree-sitter-repo-map"><a class="anchor" href="#tree-sitter-repo-map">tree-sitter repo map</a></h3>
<p>다음 계층은 <strong>tree-sitter</strong>를 활용해 코드의 구조를 분석하되, 별도의 인덱스 서버를 띄우진 않는 방식이다.</p>
<p>이 계층의 도구들이 쓰는 tree-sitter가 만드는 것은 소스 코드의 구조를 트리로 표현한 <strong>CST(Concrete Syntax Tree)</strong> 다. <a href="https://tree-sitter.github.io/tree-sitter/" target="_blank" rel="noopener noreferrer">tree-sitter 공식 문서</a>도 concrete syntax tree를 만든다고 쓴다. 괄호와 구두점까지 노드로 남긴 트리이고, 아래 도구들이 다루는 것도 이 트리다.</p>
<p><strong>tree-sitter</strong>는 오픈소스 파서 생성기이자 증분(incremental) 파싱 라이브러리다. <a href="https://docs.github.com/en/repositories/working-with-files/using-files/navigating-code-on-github" target="_blank" rel="noopener noreferrer">GitHub의 code navigation</a>이 tree-sitter를 쓴다. 편집된 부분만 다시 파싱하므로, 에디터에서 한 줄을 고쳐도 파일 전체를 다시 파싱하지 않고 바뀐 트리만 고친다. 이 이점은 편집이 계속 일어나는 에디터의 것이다. 아래의 Aider는 파일 수정 시각으로 캐시를 두어 바뀌지 않은 파일을 다시 파싱하지 않는다.</p>
<p>터미널에서 쓰는 AI 페어 프로그래밍 도구 <strong>Aider</strong>가 이 접근의 대표 사례다. Aider는 tree-sitter로 파일마다 함수, 클래스, 메서드의 정의와 참조를 뽑는다. 그리고 파일을 노드로 하는 그래프를 만든다. A 파일이 B 파일에 정의된 식별자를 참조하면 A에서 B로 엣지가 생긴다.</p>
<p>이 그래프에서 중요한 파일을 고를 때 Aider는 PageRank를 쓴다. PageRank는 링크를 많이, 그리고 무겁게 받는 노드일수록 높은 점수를 주는 알고리즘이다. Aider가 쓰는 것은 그 변형인 personalized PageRank로, 지정한 노드 쪽으로 점수를 기울인다. 그렇게 순위가 높은 파일부터 정의와 시그니처를 토큰 예산만큼 넣는다.</p>
<p>무엇이 예산 안에 들어갈지는 지금의 대화에 따라 바뀐다. Aider의 <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/repomap.py#L487-L525" target="_blank" rel="noopener noreferrer"><code>repomap.py</code></a>에서 엣지 가중치는 참조 횟수의 제곱근에 배수를 곱한 값이다. 대화에 나온 식별자는 10배, 8자 이상인 camelCase, snake_case, kebab-case 식별자도 10배다. <code>_</code> 로 시작하는 식별자와 5개를 넘는 파일에서 정의된 식별자는 각각 0.1배이고, 지금 채팅에 올린 파일에서 나가는 엣지는 50배다. 채팅에 올린 파일, 대화에서 언급한 파일, 그리고 경로나 파일 이름이 대화 속 식별자와 겹치는 파일은 PageRank의 personalization 점수도 받는다.</p>
<p>예산도 고정값이 아니다. <a href="https://aider.chat/docs/repomap.html" target="_blank" rel="noopener noreferrer">Aider 문서</a>는 <code>--map-tokens</code> 기본값을 1k로 적지만, <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/models.py#L782-L789" target="_blank" rel="noopener noreferrer">코드</a>는 모델 입력 한도의 1/8을 1,024에서 4,096 사이로 자른다. 그리고 채팅에 파일이 없으면 <code>--map-multiplier-no-files</code>(기본 2)배까지 키운다. 이 리포의 <code>src/</code> 로 확인했다. 실행한 0.86.1과 위에 링크한 커밋은 랭킹 코드가 같다. 출력된 map은 Aider가 붙이는 안내 문구 세 줄을 포함해 o200k_base로 셌다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># aider-chat 0.86.1, 리포 루트에서 시작한다, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mkdir</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cp</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -R</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">cd</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -q</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -A</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> commit</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -qm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 키는 더미 값이다. --show-repo-map 은 map 만 출력하고 LLM 을 부르지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> OPENAI_API_KEY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dummy</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">uvx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --python</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3.12</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider-chat==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.86.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-check-update</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --analytics-disable</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-gitignore</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --model</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> gpt-4o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">$@</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                                              # Repo-map: using 4096 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                            # Repo-map: using 1024 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src/lib/filter-posts.ts</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    # 이 파일을 채팅에 올린 상태</span></span></code></pre></figure>
<table>
<thead>
<tr>
<th>조건</th>
<th>Aider가 잡은 예산</th>
<th>출력된 map</th>
<th>들어간 파일</th>
</tr>
</thead>
<tbody>
<tr>
<td>기본값, 채팅에 파일 없음</td>
<td>4,096</td>
<td>7,370 tokens</td>
<td>61개</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>, 채팅에 파일 없음</td>
<td>1,024</td>
<td>2,019 tokens</td>
<td>27개</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>, <code>filter-posts.ts</code> 를 채팅에 올림</td>
<td>1,024</td>
<td>983 tokens</td>
<td>12개</td>
</tr>
</tbody>
</table>
<p>gpt-4o의 입력 한도는 128k라서 1/8이 4,096으로 잘렸고, 채팅이 비어 있으니 그 두 배 안에서 map이 나왔다. 파일 하나를 채팅에 올리자 map은 예산 안으로 줄었고, 앞의 27개 중 10개만 남은 자리에 <code>PostList.tsx</code> 와 <code>SearchModal.tsx</code> 가 새로 들어왔다. repo map은 리포의 고정된 요약이 아니라 그 순간의 대화에 맞춘 발췌다.</p>
<p>같은 계층에서 짚을 만한 도구가 하나 더 있다. <strong>ast-grep</strong>(<code>ast-grep/ast-grep</code>)이다. tree-sitter 기반 구조 검색과 rewrite를 하는 CLI인데, 텍스트 대신 구문 트리의 노드를 매칭한다. 예를 들어 <code>console.log($A)</code> 패턴은 줄바꿈이나 공백이 어떻게 생겼든 인자 하나로 <code>console.log</code> 를 부르는 호출을 모두 잡는다. 잡는 것은 같은 구문 구조다. <code>$A</code> 의 타입이나 <code>console</code> 이 어디서 온 이름인지는 모른다. <code>ast-grep-mcp</code> 서버도 있어서 에이전트가 텍스트 grep 대신 구조 검색을 쓰게 만들 수 있다.</p>
<h3 id="지식-그래프"><a class="anchor" href="#지식-그래프">지식 그래프</a></h3>
<p>세 번째 계층은 한 발 더 나아간다. <strong>사전에 코드베이스 전체를 파싱해서 지식 그래프를 만들어 디스크에 저장</strong>해두고, 에이전트는 저장해둔 그래프에 쿼리를 던지는 방식이다. 가장 화제가 되는 사례가 <strong>CodeGraph</strong> 라는 도구이다.</p>
<p><a href="https://github.com/colbymchenry/codegraph/blob/b635dd467f0578926a9c01a37b9d28d2b26689f1/README.md" target="_blank" rel="noopener noreferrer">CodeGraph README</a>가 설명하는 구조는 단순하다. tree-sitter로 코드를 파싱해 심볼, 엣지, 파일 정보를 뽑고, 이것을 로컬 SQLite 데이터베이스에 저장한다. 이름 검색은 SQLite의 FTS5 인덱스로 한다. 에이전트는 MCP를 통해 이 그래프에 묻는다. README가 설명하는 단계에는 LLM이 등장하지 않는다. 그래서 필자는 이 추출이 결정론적(deterministic), 즉 같은 코드에서 늘 같은 결과가 나온다고 본다.</p>
<p>LLM이 코드를 요약해서 그래프를 만들면 같은 코드에서도 결과가 달라질 수 있고 환각이 섞일 위험이 있다. 반면 구문 트리를 직접 파싱하면 언어 문법이 정한 규칙대로만 심볼 관계를 뽑으므로 그런 해석이 끼어들 틈이 없다.</p>
<p>여기 등장하는 <strong>FTS5(SQLite Full-Text Search 5)</strong> 는 SQLite의 가상 테이블 형태로 제공되는 전문 검색 확장이다. <a href="https://www.sqlite.org/fts5.html" target="_blank" rel="noopener noreferrer">SQLite 문서</a>에 따르면 3.9.0(2015-10-14)부터 amalgamation에 포함됐고, <code>CREATE VIRTUAL TABLE ... USING fts5(...)</code> 로 테이블을 만들어 <code>MATCH</code> 연산자로 질의한다. Elasticsearch 같은 별도 검색 엔진을 띄우지 않고도 SQLite 파일 하나로 전문 검색 인덱스를 둘 수 있다.</p>
<p>다만 결정론적인 것과 빠짐없는 것은 다르다. 같은 README는 관례와 reflection에 기대는 프레임워크의 route 인식률을 Django 74.1%에서 ASP.NET 83.9%까지로 적고, 이를 정적 분석의 한계(honest static-analysis ceiling)라고 부른다. 같은 코드에서 같은 결과가 나오지만, 그 결과에서 빠진 엣지가 있을 수 있다.</p>
<p>벤치마크는 CodeGraph가 직접 잰 것이다. 같은 README의 2026-08-05 재측정은 Claude Opus 4.8을 headless로 돌려 7개 오픈소스 리포에 아키텍처 질문을 하나씩 던졌다. CodeGraph MCP를 켠 쪽이 평균 비용 44%, 처리 토큰 62%, tool call 88%를 줄였다. 이 재측정은 양쪽 모두 Bash로 <code>codegraph</code> CLI를 부르지 못하게 막았다. 막지 않은 측정 환경에서는 도구 없는 쪽이 28회 중 26회 CLI를 찾아 썼고, README는 이전에 발표한 수치가 이 차단 없이 나왔다고 밝힌다.</p>
<p>절감 폭은 리포 크기를 따르지 않았다. 도구 없는 쪽이 tool call을 28~43번 쓴 질문에서는 비용이 57~78% 줄었고, 7번으로 끝난 Gin에서는 거의 같았다. 파일이 약 11k개인 VS Code가 71%, 약 640개인 Excalidraw가 78%였다. README는 이를 질문이 요구한 탐색량으로 설명한다. 리포마다 질문이 하나뿐이라 필자는 방향만 읽는다.</p>
<p>README는 반대 방향의 숫자도 적는다. 처리한 토큰은 줄지만, 여러 턴짜리 세션이 끝났을 때 context window에 남아 있는 retrieval 결과는 7개 리포를 통틀어 CodeGraph 쪽이 약 80% 많다. 리포마다 차이가 커서, VS Code 한 곳에서는 67k 대 18k tokens로 약 3.7배였다. 한 번에 원문을 촘촘히 돌려주고 그것이 창에 그대로 남기 때문이다. CodeGraph는 tool call과 처리 토큰을 줄이는 대신 창에 남는 양을 늘렸다.</p>
<p><strong>Cursor</strong>는 다른 길로 갔다가 방향을 바꿨다. <a href="https://cursor.com/blog/secure-codebase-indexing" target="_blank" rel="noopener noreferrer">2026년 1월 Cursor 블로그</a>가 설명한 인덱싱은 구문 그래프가 아니라 <strong>벡터 임베딩 기반 의미 검색</strong>이었다. 로컬에서 파일을 chunk로 나누고, Merkle tree 해시로 서버와 동기화하고, chunk를 임베딩으로 바꿔 의미 검색에 썼다. 2026년 7월 Cursor의 Community Support Engineer는 <a href="https://forum.cursor.com/t/what-do-you-think-about-cursor-removing-the-codebase-indexing-settings/165899" target="_blank" rel="noopener noreferrer">포럼</a>에서 "Semantic/embeddings indexing is being turned down in favor of grep-based retrieval" 이라고 답했다. 같은 스레드에서 다른 직원은 모델이 grep을 잘 쓰게 되면서 예전 의미 검색 경로가 더는 의미 있게 돕지 못했다고 썼다. 지금 <a href="https://cursor.com/docs/context/codebase-indexing" target="_blank" rel="noopener noreferrer">Cursor 문서</a>는 Instant Grep이 인덱스를 로컬에서 만들고 질의하며, 검색용으로 코드베이스의 임베딩을 저장하지 않는다고 적는다.</p>
<h3 id="lsp"><a class="anchor" href="#lsp">LSP</a></h3>
<p>마지막 계층은 <strong>언어 서버에 직접 의존</strong>하는 방식이다. tree-sitter가 "심볼이 존재한다는 것"을 안다면, LSP는 "그 심볼이 무엇인지"를 안다.</p>
<p><strong><a href="https://microsoft.github.io/language-server-protocol/" target="_blank" rel="noopener noreferrer">LSP(Language Server Protocol)</a></strong> 는 에디터와 언어 분석 도구(코드 완성, 정의로 이동, 참조 찾기, 리팩토링 등) 사이의 통신을 표준화한 JSON-RPC 기반 개방형 프로토콜이다. 2016년 <a href="https://www.redhat.com/en/about/press-releases/red-hat-codenvy-and-microsoft-collaborate-language-server-protocol" target="_blank" rel="noopener noreferrer">Microsoft, Red Hat, Codenvy가 협력을 발표했다</a>. 핵심 아이디어는 "에디터마다 언어 분석기를 재구현하지 말고, 언어별 서버 하나를 두고 모든 에디터가 그 서버에 질의하자"는 것이다. rust-analyzer와 Python의 pyright가 LSP 서버이고, TypeScript는 자체 프로토콜을 쓰는 <code>tsserver</code> 를 LSP로 감싼 typescript-language-server를 쓴다.</p>
<p><strong>Serena</strong>(<code>oraios/serena</code>)가 이 계층에 속하는 MCP 서버다. 2026-10-08 기준 30,093 stars이고, 저장소는 2025년 3월에 만들어졌다. Serena의 핵심 아이디어는 한 줄로 요약된다. <strong>에이전트에게 코드를 심볼 단위로 보여주자.</strong> 핵심 도구는 <code>find_symbol</code>, <code>find_referencing_symbols</code>, <code>get_symbols_overview</code> 등이다. 백엔드는 두 가지 중 하나를 선택할 수 있다. 기본값은 LSP를 구현한 언어 서버(무료/오픈소스), 다른 옵션은 JetBrains IDE의 코드 분석을 활용하는 유료 플러그인(무료 체험 제공)이다.</p>
<p>이 리포에서 재 보면 차이가 어디서 나는지 보인다. 비공개 글을 거르는 <code>isHiddenPost</code>(<code>src/lib/filter-posts.ts:12</code>)의 사용처를 두 방식으로 찾았다. 텍스트 쪽은 grep이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 커밋 36e5cfa, 2026-10-08. 이 글도 같은 이름을 담고 있어 content/ 는 뺐다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 16줄, 파일 9개</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -C1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 같은 16곳을 앞뒤 1줄과 함께</span></span></code></pre></figure>
<p>LSP 쪽은 Serena의 <a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/tools/symbol_tools.py#L169-L172" target="_blank" rel="noopener noreferrer"><code>find_referencing_symbols</code></a>를 불렀다. LSP의 참조 찾기는 이름이 아니라 위치(파일과 줄, 열)로 묻기 때문에, 이 도구도 심볼 이름인 <code>name_path</code> 와 정의가 있는 파일인 <code>relative_path</code> 를 함께 받는다. Serena를 MCP 서버로 띄우지 않고 같은 커밋에서 Python으로 도구를 바로 호출했다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># serena 3b99f8b, 커밋 36e5cfa, 2026-10-08. 리포 루트에서 실행한다</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># uvx --python 3.12 --from git+https://github.com/oraios/serena@3b99f8b024dafd58c962ea6e74f37c8a730ef532 python refs.py</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.config.serena_config </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaConfig</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.tools.symbol_tools </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FindReferencingSymbolsTool</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">project</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">serena_config</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SerenaConfig().with_headless_mode_overrides())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">None</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 언어 서버가 뜰 때까지 기다린다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">tool </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> agent.get_tool(FindReferencingSymbolsTool)</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">print</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: tool.apply(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"isHiddenPost"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">relative_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"src/lib/filter-posts.ts"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)))</span></span></code></pre></figure>
<p>출력은 JSON 한 줄이다. 첫 항목만 옮기면 이렇다. 줄 번호는 0부터 센다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>{"src/lib/filter-posts.ts": {"Function": [{"name_path": "filterPublishedPosts", "body_location": {"start_line": 22, "end_line": 24}, "content_around_reference": "...  22:export function filterPublishedPosts(posts: Post[]): Post[] {\n  >  23:  return posts.filter(post => !isHiddenPost(post))\n...  24:}"}]}, ...</span></span></code></pre></figure>
<p>Serena는 참조 10곳을 돌려줬다. 정의가 있는 <code>filter-posts.ts:12</code> 는 빼고, 세 파일의 import 줄은 그 파일 자체를 심볼(<code>File</code>)로 삼아 돌려준다. grep이 잡은 코드 위치 11곳은 이 10곳에 정의 한 줄을 더한 것과 같다. 이 이름은 리포에 하나뿐이라 grep도 코드 위치를 놓치거나 더 잡지 않았다. grep이 더 잡은 5곳은 이 함수를 설명하는 문서 속 문장이었다. 루트의 <code>CLAUDE.md</code>, <code>.claude/commands/</code> 의 <code>audit.md</code> 와 <code>write-post.md</code>, 그리고 <code>docs/research/snapshot-20260816/</code> 에 남은 <code>CLAUDE.md</code> 와 <code>write-post.md</code> 사본이다.</p>
<p>Serena는 참조마다 <a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/repl/api/lsp_api.py#L367-L369" target="_blank" rel="noopener noreferrer">앞뒤 1줄</a>을 붙여 돌려준다. 그래서 grep은 줄 번호만 받는 <code>git grep -n</code> 과 앞뒤 1줄을 함께 받는 <code>git grep -n -C1</code> 두 가지로 돌리고, 세 출력 전체를 <code>o200k_base</code> 로 셌다.</p>
<table>
<thead>
<tr>
<th>출력</th>
<th>위치</th>
<th>tokens</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>git grep -n</code></td>
<td>16곳 (코드 11, 문서 5)</td>
<td>703</td>
</tr>
<tr>
<td><code>git grep -n -C1</code></td>
<td>16곳 (코드 11, 문서 5)</td>
<td>1,575 (코드 733, 문서 841)</td>
</tr>
<tr>
<td>Serena <code>find_referencing_symbols</code></td>
<td>10곳</td>
<td>892</td>
</tr>
</tbody>
</table>
<p>앞뒤 1줄을 함께 받는 grep과 견주면 Serena가 683 tokens 적다. 차이는 문서 5곳을 돌려주지 않는 데서 나온다. 다만 Serena는 참조마다 심볼 이름과 위치 정보를 JSON으로 붙이므로 코드 쪽만 보면 오히려 159 tokens 많다. 줄 번호만 돌려주는 <code>git grep -n</code> 의 703 tokens보다는 Serena의 892 tokens가 크다. 이 리포에서 LSP 계층이 아끼는 것은 검색 결과가 아니라, grep 결과를 보고 주변 줄이나 파일을 더 읽는 다음 단계다.</p>
<p>다음 단계에서 에이전트가 grep에 걸린 파일 9개를 통째로 읽는다면 읽는 양은 47,034 tokens이고, 코드 4개가 3,716, 문서 5개가 43,318이다. 이것은 상한이다. <code>CLAUDE.md</code> 는 실제 세션에서 이미 context에 올라 있어 다시 읽지 않을 가능성이 높다. <code>isLocale</code> 처럼 흔한 이름이라면 코드 위치에서도 결과가 갈릴 수 있다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, 네 계층은 코드를 얼마나 깊이 이해하느냐에 따라 갈리고 줄이는 비용도 다르다. Context packing은 코드를 텍스트로 넘기고, <code>--compress</code> 를 줘도 구문까지만 남겨 들어가는 토큰을 줄인다. tree-sitter repo map은 심볼이 있다는 것까지 알고, 들어가는 토큰에 예산으로 상한을 두고, 그 안을 무엇으로 채울지는 그 순간의 대화가 정한다. 지식 그래프는 관계를 미리 저장해 tool call과 처리 토큰을 줄이지만, 제작사 측정으로는 창에 남는 양이 오히려 늘었다. LSP는 심볼이 무엇인지까지 알고, 이 리포에서는 문서 속 같은 이름을 돌려주지 않는 대신 결과마다 메타데이터를 붙였다. 아끼는 지점은 검색 결과가 아니라 그 뒤의 읽기다.</p>
<p>그래서 필자는 도구를 고를 때 계층의 깊이보다 지금 어떤 비용이 문제인지를 먼저 본다. 창이 작고 세션이 길면 남는 양을, 왕복이 느리면 tool call 수를, 문서와 코드가 같은 이름을 공유하는 리포라면 grep 결과를 보고 더 읽게 되는 파일의 양을 본다. Cursor가 임베딩 의미 검색을 걷어내고 로컬 텍스트 인덱스(Instant Grep)로 옮긴 것도, 비싼 인덱스가 모델의 탐색 능력보다 늘 낫지는 않다는 신호로 읽힌다.</p>
<p>이 도구들이 에이전트가 코드를 찾는 비용을 줄인다면, 에이전트가 처음부터 알아야 할 프로젝트 규칙을 어떤 파일에 얼마나 적을지는 또 다른 문제다. 그 이야기는 <a href="/260529" target="_blank" rel="noopener noreferrer">Context file</a>에서 다룬다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep-mcp" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep-mcp</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>개발도구</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[MCP와 function calling]]></title>
            <link>https://hooninedev.com/260524</link>
            <guid isPermaLink="false">https://hooninedev.com/260524</guid>
            <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 MCP(Model Context Protocol)가 function calling과 무엇이 다른지에 대한 이야기를 해보려고 한다. Claude Code나 Cursor에 MCP 서버를 붙여 쓰고는 있지만, 그것이 LLM API의 function calling과 어디서 갈리는지 설명하기 어려웠던 개발자를 위한 글이다. 답부터 적으면 MCP는 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>MCP(Model Context Protocol)가 function calling과 무엇이 다른지</strong>에 대한 이야기를 해보려고 한다.</p>
<p>Claude Code나 Cursor에 MCP 서버를 붙여 쓰고는 있지만, 그것이 LLM API의 function calling과 어디서 갈리는지 설명하기 어려웠던 개발자를 위한 글이다. 답부터 적으면 MCP는 function calling을 대체하지 않는다. 호스트 애플리케이션이 MCP 서버에서 받은 도구 목록을 function calling의 <code>tools</code> 파라미터로 바꿔 넣고, 모델이 고른 호출을 다시 MCP 서버로 넘긴다. 끝까지 읽으면 그 위에서 MCP가 더하는 네 가지 차이가 프로토콜의 어느 부분에서 나오는지, 2026-07-28 개정판 이후에도 남는 차이가 무엇인지, 그리고 그 구조가 어떤 공격면을 여는지 알 수 있다.</p>
<p>필자는 프론트엔드 개발자로 일하면서 일상적으로 Claude를 활용하는데, MCP 서버를 하나씩 추가할 때마다 이 도구들이 어떤 원리로 모델의 시야에 들어오는지가 매번 가물가물했다.</p>
<h2 id="mcpmodel-context-protocol"><a class="anchor" href="#mcpmodel-context-protocol">MCP(Model Context Protocol)</a></h2>
<p>MCP(Model Context Protocol)는 "<strong>에이전트에게 무엇을 할 수 있게 해줄까</strong>"의 문제를 푼다.</p>
<p>조금 풀어쓰면 이렇다. AI 에이전트가 Slack에 메시지를 보내려면 Slack API를 호출할 수 있어야 한다. GitHub 이슈를 만들려면 GitHub API를 호출할 수 있어야 한다. Postgres에 쿼리하려면 DB 연결을 다룰 수 있어야 한다. 이 모든 외부 시스템과의 통합을 <strong>하나의 표준 프로토콜로 묶은 것</strong>이 MCP다. (클라이언트와 서버가 같은 규격으로 연결된다는 뜻이다)</p>
<p>MCP는 Anthropic이 <strong>2024년 11월 25일</strong>에 처음 공개한 개방형 표준이다. 그리고 <strong>2025년 12월 9일</strong>, Anthropic은 MCP를 Linux Foundation 산하 <a href="https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Agentic AI Foundation(AAIF)</a>에 기증했다. AAIF는 Anthropic·Block·OpenAI가 공동 창립했다.</p>
<p>MCP는 JSON-RPC 위에 만들어진 프로토콜이다. <a href="https://www.jsonrpc.org/specification" target="_blank" rel="noopener noreferrer">JSON-RPC 2.0</a>은 JSON을 와이어 포맷으로 쓰는 stateless·경량 RPC(Remote Procedure Call) 프로토콜이다. 전송 계층에 독립적이어서 HTTP·TCP·표준 입출력 무엇이든 위에서 동작한다. 이 글은 2025-11-25 개정판을 기준으로 설명하고, 이 판에서 MCP는 연결마다 세션을 맺는 stateful 프로토콜이다. 그 뒤에 나온 개정판에서 무엇이 바뀌었는지는 호출 흐름을 본 다음에 다룬다.</p>
<h3 id="여섯-가지-primitive"><a class="anchor" href="#여섯-가지-primitive">여섯 가지 primitive</a></h3>
<p>2025-11-25 명세의 개요는 서버가 제공하는 기능 셋과 클라이언트가 제공하는 기능 셋을 든다. 이 글은 이 여섯을 primitive라고 부른다. 여기서 primitive는 JavaScript의 원시 타입(string, number 같은 것)과는 관계가 없고, 프로토콜이 정의해 둔 기본 상호작용 유형을 가리킨다.</p>
<h4 id="서버측-primitive"><a class="anchor" href="#서버측-primitive">서버측 primitive</a></h4>
<ul>
<li><strong>Tool</strong> (model-controlled): 모델이 호출 여부를 스스로 판단해 실행하는 동작이다. 이런 동작은 부작용(side effect)을 가질 수 있다</li>
<li><strong>Resource</strong> (application-controlled): URI로 식별되는 데이터다. 스펙에는 내용을 읽어 오는 <code>resources/read</code> 만 있고 쓰는 메서드는 없다. 그 리소스를 context에 어떻게 넣을지는 호스트 애플리케이션이 결정한다.</li>
<li><strong>Prompt</strong> (user-controlled): 사용자가 슬래시 명령 등으로 명시적으로 트리거하는 재사용 가능한 템플릿이다.</li>
</ul>
<h4 id="클라이언트측-primitive"><a class="anchor" href="#클라이언트측-primitive">클라이언트측 primitive</a></h4>
<ul>
<li><strong>Sampling</strong>: 서버가 거꾸로 클라이언트의 LLM에게 completion을 요청할 수 있게 해주는 메커니즘으로 클라이언트와 서버를 양방향 구조로 만든다. 서버가 도구를 실행하다 문장 생성이 필요할 때 자기 API 키 없이 클라이언트가 쓰는 모델을 빌리는 용도다. 2026-07-28 개정판에서는 제거 예정인 deprecated 상태가 되었다.</li>
<li><strong>Roots</strong>: 클라이언트가 서버에게 "여기까지가 작업 가능한 범위"라고 알려주는 워크스페이스 경계 정보</li>
<li><strong>Elicitation</strong>: 서버가 도구를 실행하는 도중에 사용자에게 추가 입력을 구조화된 형태로 요청할 수 있게 해주는 기능</li>
</ul>
<p>이 구분이 중요한 이유는 <strong>누가 호출이나 제공을 결정하는가</strong>가 다르기 때문이다. Tool은 모델이 판단해 실행하니 잘못된 호출의 리스크가 있고, Prompt는 사용자가 명시적으로 고른다. Resource는 앱이 고르는 것이 기본이지만, 스펙은 heuristic이나 모델의 선택으로 자동 포함하는 구현도 허용한다. 클라이언트측 세 가지는 방향이 반대다. 서버가 요청하고, 응할지는 클라이언트가 정한다.</p>
<h3 id="두-가지-전송-방식"><a class="anchor" href="#두-가지-전송-방식">두 가지 전송 방식</a></h3>
<p><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" target="_blank" rel="noopener noreferrer">표준 전송 방식</a>은 두 가지이고, 스펙은 그 밖의 커스텀 전송도 허용한다(MAY). 하나는 <strong>stdio</strong>로, MCP 서버를 로컬 서브프로세스로 실행하고 표준 입출력으로 통신하는 방식이다. 파일시스템이나 깃처럼 로컬에서 동작하는 도구에 적합하다. 다른 하나는 <strong>Streamable HTTP</strong>인데, HTTP POST와 GET에 SSE(Server-Sent Events) 스트리밍을 얹어서 양방향에 가까운 통신을 만들어내는 방식이다. SSE는 HTTP 연결 위에서 서버가 클라이언트로 데이터를 한 방향으로 밀어 보내는 방식이다. 원격 서버, OAuth 인증, 다중 클라이언트 연결, 클라우드 배포처럼 네트워크 너머에서 일어나는 시나리오에 적합하다.</p>
<h3 id="llm이-mcp-도구를-호출하는-흐름"><a class="anchor" href="#llm이-mcp-도구를-호출하는-흐름">LLM이 MCP 도구를 호출하는 흐름</a></h3>
<p>primitive와 전송 방식까지 봤으니, 이제 <strong>실제로 LLM이 MCP 도구를 어떻게 발견하고 호출하는지</strong>의 흐름을 따라가보자.</p>
<p>2025-11-25 개정판에서 연결이 시작되면 다음 순서의 handshake가 일어난다. 2026-10-08 현재 TypeScript SDK의 1.32.1과 v2 계열 2.3.1도 기본 설정에서는 이 순서로 움직인다.</p>
<ul>
<li><strong>클라이언트 → 서버</strong>: <code>initialize</code> 요청 (지원하는 프로토콜 버전, 클라이언트 capabilities 전달)</li>
<li><strong>서버 → 클라이언트</strong>: <code>initialize</code> 응답 (서버 capabilities + 선택적으로 <code>instructions</code> 필드)</li>
<li><strong>클라이언트 → 서버</strong>: <code>notifications/initialized</code> 알림</li>
<li><strong>클라이언트 → 서버</strong>: <code>tools/list</code> 요청 → 사용 가능한 도구 목록 수신</li>
<li>(이후) LLM이 도구를 호출하기로 결정 → 클라이언트가 <code>tools/call</code> 발송 → 결과 수신</li>
</ul>
<p><code>initialize</code> 응답의 <code>instructions</code> 필드는 서버가 도구를 어떻게 써야 하는지 텍스트로 적어 보내는 자리다. 아래 데모 출력의 instructions 줄이 이 값이다.</p>
<p>그러면 tool 정의 자체는 어떻게 LLM의 시야에 들어갈까. MCP의 tool 정의는 다음과 같은 JSON Schema 형태다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "name"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "description"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "inputSchema"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"object"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "properties"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"string"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "required"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>호스트는 <code>tools/list</code> 로 받은 이 목록을 <strong>Anthropic Messages API의 <code>tools</code> 파라미터</strong> 또는 <strong>OpenAI function calling의 <code>tools</code> 파라미터</strong>로 변환해서 LLM API 호출에 같이 넣는다. Anthropic의 경우, tool 파라미터가 들어오면 <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">special system prompt가 자동으로 추가</a>되어 모델이 tool 호출 방식을 이해하도록 만든다. 그 길이는 모델마다 다르다. 2026-10-08 에 본 문서 표에서 <code>tool_choice: auto</code> 기준 현행 모델은 286~675토큰이었다.</p>
<p>LLM이 도구를 호출해야 한다고 판단하면 응답 안에 <code>tool_use</code> 블록(<code>{"type": "tool_use", "name": ..., "input": ...}</code>)이 끼어 있고, 응답의 <code>stop_reason</code>이 <code>tool_use</code>로 끝난다. 호스트는 이걸 받아 실제 MCP 서버로 <code>tools/call</code>을 발송하고, 결과를 받아서 다음 user 메시지의 <code>tool_result</code> 블록에 담아 다시 LLM에 보낸다. <strong><code>stop_reason</code>이 <code>tool_use</code>가 아닌 값(<code>end_turn</code>, <code>max_tokens</code> 등)으로 바뀔 때까지 이 루프가 반복된다.</strong> 우리가 흔히 "에이전트가 일한다"고 부르는 동작은 사실 이 호출-결과-호출 루프의 연속에 가깝다.</p>
<p>그림으로 놓으면 모델과 MCP 서버 사이에는 선이 없다. 모델은 <code>tools</code> 로 받은 정의만 보고, MCP 서버는 모델이 아니라 호스트의 요청을 받는다. 두 프로토콜을 잇는 것은 가운데의 호스트다.</p>
<p><picture><source type="image/webp" srcset="/content/260524/1.720w.webp 720w, /content/260524/1.1440w.webp 1440w, /content/260524/1.2000w.webp 2000w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260524/1.png" alt="왼쪽 LLM API와 가운데 호스트 사이에 tools, tool_use, tool_result 화살표가 오가고, 호스트와 오른쪽 MCP 서버 사이에 tools/list, tools/call 화살표가 오간다. LLM API와 MCP 서버 사이에는 선이 없다" style="max-width:min(720px, 100%);height:auto;" width="2000" height="640" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>이 변환이 실제로 얼마나 짧은지 돌려 봤다. 2026-10-08 에 Node v24.16.0, <code>@modelcontextprotocol/sdk</code> 1.32.1, <code>zod</code> 4.6.5로 실행했다. 전송은 stdio 대신 같은 프로세스 안에서 서버와 클라이언트를 잇는 <code>InMemoryTransport</code> 이고, LLM은 호출하지 않았다. <code>tool_use</code> 블록은 Anthropic 문서의 모양대로 손으로 만들었다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { McpServer } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/server/mcp.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Client } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/client/index.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { InMemoryTransport } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/inMemory.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { z } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "zod"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// MCP 서버: 도구 하나와 instructions 를 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> server</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> McpServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { instructions: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Use get_weather for current conditions only."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">registerTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, inputSchema: { location: z.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() } },</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">location</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ content: [{ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"text"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, text: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">location</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}: 15C, partly cloudy`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트: 같은 프로세스 안에서 서버와 잇고, 클라이언트가 보내는 메서드 이름을 찍는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">clientT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">serverT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InMemoryTransport.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">createLinkedPair</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> clientT.send.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">bind</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">clientT.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"C->S"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, m.method, m.params?.protocolVersion </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> send</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(serverT);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> client</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Client</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"demo-host"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"instructions:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInstructions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. tools/list 결과를 LLM API 의 tools 파라미터 모양으로 바꾼다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">listTools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> anthropicTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ name: t.name, description: t.description, input_schema: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> openaiTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: t.name, description: t.description, parameters: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"anthropic:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(anthropicTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"openai:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(openaiTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. 모델이 이런 tool_use 블록을 돌려줬다고 가정한다. input 이 그대로 tools/call 의 arguments 가 된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toolUse</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_use"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toolu_demo"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, input: { location: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Seoul"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">callTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: toolUse.name, arguments: toolUse.input });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, tool_use_id: toolUse.id, content: result.content }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>node post-demo.mjs</code> 의 출력은 이렇다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>C->S initialize 2025-11-25</span></span>
<span data-line=""><span>C->S notifications/initialized </span></span>
<span data-line=""><span>instructions: Use get_weather for current conditions only.</span></span>
<span data-line=""><span>C->S tools/list </span></span>
<span data-line=""><span>anthropic: {"name":"get_weather","description":"Get current weather information for a location","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>openai: {"type":"function","name":"get_weather","description":"Get current weather information for a location","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>C->S tools/call </span></span>
<span data-line=""><span>tool_result: {"type":"tool_result","tool_use_id":"toolu_demo","content":[{"type":"text","text":"Seoul: 15C, partly cloudy"}]}</span></span></code></pre></figure>
<p>도구 정의의 변환은 필드 이름을 바꾸는 것이 전부다. MCP의 <code>inputSchema</code> 가 Anthropic에서는 <code>input_schema</code>, OpenAI에서는 <code>parameters</code> 가 된다. SDK가 schema에 <code>$schema</code> 를 덧붙인다는 점도 출력에서 보인다. 반대 방향도 짧다. Anthropic의 <code>tool_use.input</code> 은 객체라서 그대로 <code>tools/call</code> 의 <code>arguments</code> 가 된다. 결과 쪽도 text 블록은 모양이 같아 그대로 넘어가지만, 이미지나 에러 결과는 모양이 달라 Anthropic SDK의 MCP helper가 따로 변환한다. 위 OpenAI 쪽 모양은 Responses API의 형식이다. OpenAI가 돌려주는 호출의 <code>arguments</code> 는 <a href="https://developers.openai.com/api/docs/guides/function-calling" target="_blank" rel="noopener noreferrer">JSON 문자열</a>이라서 넘기기 전에 <code>JSON.parse</code> 를 한 번 거쳐야 한다. 위 코드는 Anthropic 모양의 <code>tool_use</code> 블록만 만들었으므로 이 parse 단계는 출력에 나오지 않는다.</p>
<h3 id="mcp가-더하는-네-가지"><a class="anchor" href="#mcp가-더하는-네-가지">MCP가 더하는 네 가지</a></h3>
<p>그렇다면 MCP는 function calling에 무엇을 더하는가? 2025-11-25 개정판 기준으로 네 가지다.</p>
<ul>
<li><strong>동적 발견</strong>: 빌드 타임에 도구 목록을 모르고 런타임에 <code>tools/list</code> 로 가져온다. 서버는 <code>notifications/tools/list_changed</code> 로 연결 도중 목록이 바뀌었다고 알릴 수 있다</li>
<li><strong>Stateful session</strong>: <code>initialize</code> 로 연결을 맺고 그 안에서 요청이 오간다. 종료용 JSON-RPC 메시지는 따로 없고 전송을 닫는 것으로 끝낸다</li>
<li><strong>Tool 외 primitive</strong>: Resource·Prompt·Sampling·Roots·Elicitation을 capability negotiation으로 노출한다. capability negotiation은 <code>initialize</code> 에서 양쪽이 지원하는 기능을 서로 알리는 단계다</li>
<li><strong>양방향성</strong>: 서버가 Sampling으로 클라이언트의 LLM에게 completion을 거꾸로 요청할 수 있다(2026-07-28 개정판에서 deprecated)</li>
</ul>
<h3 id="2026-07-28-개정판-이후"><a class="anchor" href="#2026-07-28-개정판-이후">2026-07-28 개정판 이후</a></h3>
<p>2026-10-08 현재 공식 사이트에서 latest로 열리는 것은 <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" target="_blank" rel="noopener noreferrer">2026-07-28 개정판</a>이고, 여기서 이 목록의 절반이 바뀌었다. 먼저 <code>initialize</code> 와 <code>notifications/initialized</code> 로 이루어진 handshake와 프로토콜 수준의 세션이 사라졌다. 대신 매 요청이 <code>_meta</code> 에 프로토콜 버전과 클라이언트 capabilities를 싣는다. 이 필드는 메시지의 본래 인자와 별도로 메타데이터를 붙이도록 MCP가 예약해 둔 자리다. 서버는 <code>server/discover</code> 를 반드시 구현해야 한다(MUST). 서버가 지원하는 프로토콜 버전, capabilities, 서버 정보를 돌려주는 RPC로, 클라이언트는 다른 요청보다 먼저 이것을 불러 지원 버전과 capabilities를 미리 확인할 수 있다.</p>
<p>서버가 먼저 보내던 요청은 Multi Round-Trip Requests(MRTR)라는 패턴으로 바뀌었다. 서버가 요청을 따로 보내는 대신 추가 입력이 필요하다는 중간 결과(<code>input_required</code>)를 돌려주고, 클라이언트가 그 입력을 채워 원래 요청을 다시 보내는 방식이다.</p>
<p>Sampling과 Roots는 Logging과 함께 deprecated 되었다. 명세에 남아 동작은 하지만 새 구현은 채택하지 말라는 뜻이고, 스펙은 Sampling 대신 LLM provider API에 직접 붙으라고 권한다.</p>
<p>다만 SDK의 기본 동작은 아직 옛 방식이다. TypeScript SDK 1.32.1은 최신 버전 상수가 <code>2025-11-25</code> 라서 2026-07-28 개정판을 모르고, 2.3.1은 이 개정판을 지원하지만 버전 협상 기본값이 <code>legacy</code> 다. 위 출력의 첫 줄 <code>initialize 2025-11-25</code> 가 그 결과다.</p>
<p>그러면 네 가지 중 남는 것은 동적 발견과 Tool 외 primitive(Resource, Prompt, Elicitation)다. 동적 발견도 모양이 조금 바뀌어서, 목록 변경 알림은 <code>subscriptions/listen</code> stream에 opt-in 한 클라이언트만 받는다. 결국 MCP가 function calling과 갈리는 지점은 세션이나 양방향성보다 <strong>도구 목록과 context를 런타임에 주고받는 계약</strong>에 있다.</p>
<h3 id="api가-mcp-클라이언트가-될-때"><a class="anchor" href="#api가-mcp-클라이언트가-될-때">API가 MCP 클라이언트가 될 때</a></h3>
<p>이 계약 중 어디까지가 모델에게 닿는지는 Anthropic의 <a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector" target="_blank" rel="noopener noreferrer">MCP connector</a>(beta)가 보여 준다. Messages API가 원격 MCP 서버에 직접 붙는 기능인데, 문서의 Limitations는 MCP 명세의 기능 가운데 "only tool calls are currently supported" 라고 적고, "Local STDIO servers cannot be connected directly" 라고 적는다. 같은 문서는 로컬 서버나 MCP prompt, resource가 필요하면 MCP SDK로 연결을 직접 관리하면서 Anthropic SDK의 변환 helper를 쓰라고 안내한다.</p>
<p>즉 function calling layer에서 MCP를 소비하면 Tool만 남는다. Resource와 Prompt는 그것을 화면이나 context로 옮겨 줄 호스트가 있어야 의미를 갖는다. OpenAI도 function calling 가이드에서 MCP 서버의 기능을 built-in tool로 쓰는 길을 소개한다. OpenAI의 <a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp" target="_blank" rel="noopener noreferrer">MCP servers 가이드</a>는 도구 목록을 가져오고 호출하는 방법만 설명하고, Resource나 Prompt를 지원하는지는 적지 않는다.</p>
<h3 id="동적-발견이-여는-공격면"><a class="anchor" href="#동적-발견이-여는-공격면">동적 발견이 여는 공격면</a></h3>
<p>도구의 description과 도구 호출 결과는 호스트를 거쳐 모델의 context에 들어간다. 그러니 서버가 그 자리에 무엇을 쓰든 모델은 그것을 읽는다. 대표적인 공격 두 가지가 모두 여기서 나온다.</p>
<ul>
<li>
<p><strong>Tool Poisoning Attack(TPA)</strong>: <a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks" target="_blank" rel="noopener noreferrer">Invariant Labs</a>가 2025년 4월에 명명하고 PoC를 공개한 공격이다. MCP 서버의 도구 설명(description)에 악의적 지시사항을 숨겨 두면, 모델은 사용자에게 보이지 않는 그 텍스트를 읽고 사용자 모르게 따를 수 있다.</p>
</li>
<li>
<p><strong>Rug Pull</strong>: Invariant Labs가 같은 글에서 설명한 공격으로, 사용자가 승인한 뒤에 서버가 도구 정의를 바꾼다. <a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/" target="_blank" rel="noopener noreferrer">Simon Willison이 인용한</a> Elena Cross의 예처럼, 1일 차에 안전해 보이는 도구를 승인했는데 7일 차에는 그 도구가 API 키를 공격자에게 보내도록 바뀌어 있는 식이고, 도구 정의를 설치 시점이 아니라 런타임에 서버에서 받아 오는 구조라서 생긴다. <a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" target="_blank" rel="noopener noreferrer">tool 명세</a>는 어떤 도구가 모델에 노출되는지 보여 주는 UI를 권할(SHOULD) 뿐 바뀐 정의를 다시 승인받으라고 요구하지는 않으므로, 재승인은 호스트의 몫이다.</p>
</li>
</ul>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, MCP는 function calling을 대체하는 것이 아니라 그 위에 얹힌 표준이다. 모델이 도구를 부르는 방식은 여전히 <code>tools</code> 파라미터와 <code>tool_use</code> 루프이고, 두 프로토콜 사이는 호스트가 번역한다. 2025-11-25 개정판에서 MCP가 더한 것은 동적 발견, stateful session, Tool 밖의 primitive, 서버에서 클라이언트로 향하는 호출이었다. 2026-07-28 개정판에서 세션이 사라지고 Sampling이 deprecated 되면서 남는 것은 도구 목록과 context를 런타임에 주고받는 계약이다. 그 계약 때문에 도구 정의를 오염시키거나 몰래 바꾸는 공격도 같은 자리에서 생긴다. MCP 서버를 하나 더 붙일 때는 그 서버가 무엇을 할 수 있는지와 함께, 정의가 바뀌었을 때 호스트가 알려 주는지도 확인해 보길 권한다.</p>
<p>MCP가 에이전트에게 무엇을 할 수 있게 해줄지의 문제라면, 무엇을 알려줄지는 <code>CLAUDE.md</code>나 <code>AGENTS.md</code> 같은 context file의 문제다. 그 파일들이 에이전트에게 어떻게 읽히고 어디까지 지켜지는지는 <a href="/260529" target="_blank" rel="noopener noreferrer">Context file</a>에서 다룬다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle" target="_blank" rel="noopener noreferrer">MCP Specification 2025-11-25, Lifecycle</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" target="_blank" rel="noopener noreferrer">MCP Specification 2026-07-28, Versioning</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>개발도구</category>
            <category>Claude</category>
            <category>MCP</category>
        </item>
        <item>
            <title><![CDATA[상태 관리]]></title>
            <link>https://hooninedev.com/260518</link>
            <guid isPermaLink="false">https://hooninedev.com/260518</guid>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 상태 관리(State Management) 에 대한 이야기를 해보려고 한다. 라이브러리 비교 글은 아니다. 어떤 도구가 더 좋은지를 가리는 것보다, 상태라는 것을 어떻게 바라보고, 어디에 경계를 그어야 하는지에 대한 감각을 정리하는 글이다. 요즘 AI 도구들(Claude, ChatGPT, Cursor, Gemini, Copilot)이 우리...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>상태 관리(State Management)</strong> 에 대한 이야기를 해보려고 한다. 라이브러리 비교 글은 아니다. 어떤 도구가 더 좋은지를 가리는 것보다, 상태라는 것을 <strong>어떻게 바라보고</strong>, 어디에 <strong>경계를 그어야 하는지</strong>에 대한 감각을 정리하는 글이다.</p>
<p>요즘 AI 도구들(Claude, ChatGPT, Cursor, Gemini, Copilot)이 우리 옆에 깊숙이 자리잡았다. 개발 속도는 기하급수적으로 빨라졌는데, 솔직히 말하면 서비스의 완성도는 그만큼 따라오지 못하고 있다는 인상을 받는다. 늘어난 기능만큼 늘어난 버그를 마주하는 일이 잦아졌고, "이거 왜 이렇게 됐는지 모르겠어요"라는 말도 자주 듣게 된다.</p>
<p>빠르게 개발하는 만큼 코드 한 줄 한 줄을 세밀하게 들여다보지는 않게 된다. 그렇기에 더더욱 <strong>AI에게 옳은 방향을 가이드할 수 있는 기본 실력</strong>이 필요하다고 필자는 생각한다. AI가 짜준 코드의 문제를 알아채고, 원하는 방향으로 다시 가이드할 수 있어야 결과물의 품질이 유지된다. 그 기본 실력에는 도메인 관점의 개발, 추상화, TDD(Test-Driven Development, 테스트 주도 개발), 라이브러리 활용, 성능 우위 확보 등 여러 가지가 있을 것이다.</p>
<p>그런데 필자가 프론트엔드 동료들에게, 그리고 다른 IT 직군의 동료들에게 "프론트엔드 개발에서 제일 까다로운 작업이 뭐예요?"라고 물어볼 때마다 가장 많이 듣는 대답은 한결같았다. <strong>"상태 흐름 관리요."</strong></p>
<p>이 글에서는 왜 상태 흐름 관리가 그렇게 까다로운지, 그리고 그것을 잘 다루기 위해 어떤 안목과 느낌을 키워야 하는지를 정리해보려 한다.</p>
<h2 id="상태state란-무엇인가"><a class="anchor" href="#상태state란-무엇인가">상태(State)란 무엇인가</a></h2>
<p>본격적인 이야기에 들어가기 전에, 가장 기초적인 질문부터 짚고 가자. 우리가 말하는 "상태"란 정확히 무엇일까?</p>
<p>프론트엔드 개발 공부하며 <a href="https://blog.hoseung.me/2021-12-05-state-management" target="_blank" rel="noopener noreferrer">hoseung.me</a>님의 글을 종종 읽었다. 여기서는 상태를 <strong>"UI에 영향을 줄 수 있는 모든 데이터"</strong> 라고 이야기한다. 좋아요 수, 장바구니 목록, 모달의 열림 여부, 입력값, 로그인된 사용자 정보, 현재 선택된 탭, 검색 결과, 로딩 여부. 이 모든 것이 상태이다.</p>
<p>React 공식 문서는 좀 더 형식적으로 정의한다. 페이지 제목이 그대로 <a href="https://react.dev/learn/state-a-components-memory" target="_blank" rel="noopener noreferrer">"State: A Component's Memory"</a>인데, 풀어보면 <strong>"컴포넌트가 렌더 사이에 데이터를 기억하고(retain), 갱신될 때 React에게 리렌더링을 트리거하는 메커니즘"</strong> 정도이다. 즉 시간이 지나도 사라지지 않고, 어떤 이벤트에 의해 갱신되며, 갱신될 때 UI를 다시 그리게 만드는 데이터이다. 한 가지 더 짚자면 상태는 <strong>컴포넌트 인스턴스마다 고립된다.</strong> 같은 컴포넌트가 페이지에 열 개 있어도 각자 독립된 상태를 갖는다. 이 사실은 뒤에 다룰 "상태를 어디에 둘 것인가" 논의와 직접 연결된다.</p>
<p>두 정의 모두 한 곳을 가리킨다. <strong>"렌더에 영향을 주면서 시간에 따라 변하는 값"</strong> 이 상태이다. 변하지 않는 상수(constant)는 상태가 아니다. 빌드 타임에 박혀버린 원시 디자인 토큰은 상태가 아니지만, 사용자가 토글하는 다크 모드는 상태이다. (엄밀히 말하면 값 자체가 다크/라이트 테마라는 상태에 따라 resolve되는 것이라, "테마 선택"이 상태이고 토큰은 그 상태가 비추는 거울이라고 보는 게 정확하다)</p>
<p>여기서 한 가지 짚고 갈 점이 있다. <strong>모든 상태가 컴포넌트에 사는 것은 아니다.</strong> 쿠키에 사는 상태도 있고, localStorage·sessionStorage·IndexedDB에 사는 상태도 있고, URL에 사는 상태도 있다. 서버에 사는 데이터를 클라이언트로 들고 와서 캐시하면 그것도 일종의 상태가 된다. 브라우저 자체가 들고 있는 스크롤 위치나 히스토리 스택도, 우리 앱의 행동을 결정한다는 점에서 상태처럼 다뤄야 할 때가 있다.</p>
<h2 id="왜-그렇게-까다로운가"><a class="anchor" href="#왜-그렇게-까다로운가">왜 그렇게 까다로운가</a></h2>
<p>상태를 다루는 게 왜 어려운지를 일단 단순하게 생각해보자. 필요한 상태를 만들고, 필요한 곳까지 전달하고, 갱신과 초기화만 잘 핸들링하면 되는 거 아닐까?</p>
<p>이 질문을 머릿속에 띄워둔 채로, 지금 일하고 있는 서비스의 한 페이지를 열어보자.</p>
<p>그 페이지에 컴포넌트가 몇 개나 있을까? 단순한 페이지라도 적게는 수십 개, 많게는 수백 개의 컴포넌트가 트리를 이루고 있을 것이다. 각 컴포넌트는 자기만의 상태를 들고 있을 수도 있고, 형제 컴포넌트와 상태를 공유할 수도 있으며, 부모로부터 상태를 내려받기도 한다. 페이지 간에도 상태가 전이되고, 새로고침을 해도 살아남아야 하는 상태가 있고, 탭을 닫으면 사라져야 하는 상태가 있다.</p>
<p>상태가 관리하기 어려운 진짜 이유는 이것이다. <strong>수많은 상태들이 어디서 선언되고, 어떻게 갱신되고, 언제 소멸되는지를 우리는 한눈에 파악할 수 없다.</strong> 비슷한 역할의 컴포넌트가 늘어날수록 상태의 이름을 짓는 것도, 상태를 변경하는 코드를 추적하는 것도 까다로워진다.</p>
<p>그래서 보이지 않는 거미줄이 생긴다. A 컴포넌트의 어떤 클릭이 B의 데이터를 무효화시키고, B의 무효화가 C의 UI를 닫히게 만들고, C가 닫히면서 폼 입력이 사라진다. 이런 연쇄가 코드 어디에도 명시되어 있지 않으면, 버그를 디버깅할 때 우리는 머릿속에서 이 거미줄을 다시 그려야 한다.</p>
<p>그렇다면 우리는 이 거미줄을 어떻게 정돈해야 할까? 필자가 생각하는 첫 단추는 <strong>"상태에는 종류가 있다"</strong> 는 것을 인지하는 것이다.</p>
<h2 id="모든-상태가-똑같은-상태가-아니다"><a class="anchor" href="#모든-상태가-똑같은-상태가-아니다">모든 상태가 똑같은 상태가 아니다</a></h2>
<p><a href="https://kentcdodds.com/blog/application-state-management-with-react" target="_blank" rel="noopener noreferrer">Kent C. Dodds</a>는 상태를 <strong>Server Cache</strong> (서버에 존재하는 정보를 클라이언트가 빠른 접근을 위해 들고 있는 것)와 <strong>UI State</strong> (인터페이스 동작을 제어하기 위해 UI에만 존재하는 것)로 나눈다. 우리는 종종 이 둘을 한데 묶을 때 실수를 한다.</p>
<p><a href="https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state" target="_blank" rel="noopener noreferrer">TanStack Query 공식 문서</a>는 서버-상태(server-state) 라이브러리로, 서버와 클라이언트 사이의 비동기 작업을 관리하고, Redux, MobX, Zustand 같은 도구들은 클라이언트-상태(client-state) 라이브러리라고 정의한다. (비동기 데이터를 저장할 수는 있지만, 그것은 비효율적이다)</p>
<p>핵심은 분명하다. <strong>서버 상태와 클라이언트 상태는 다른 문제이다.</strong> 서버 상태는 비동기적이고, 다른 사용자가 바꿀 수 있으며, 시간이 지나면 stale(오래된) 상태가 된다. 클라이언트 상태는 동기적이고, 우리가 통제할 수 있으며, 새로고침하면 사라진다.(정확히는 페이지가 unload되면서 <strong>JavaScript 런타임이 재시작되고, heap 메모리에 올라가 있던 컴포넌트 트리와 그 안의 상태가 함께 회수된다.</strong>  그래서 새로 마운트될 때는 <code>useState</code>의 초기값부터 다시 시작한다) 이 둘을 같은 도구로 묶으려고 하면 캐시 무효화, 백그라운드 갱신, 낙관적 업데이트 같은 패턴을 다 직접 짜야 한다.</p>
<p>필자는 여기서 한 발 더 나아가, 프론트엔드의 상태를 <strong>일곱 가지 범주</strong>로 구분해서 본다. 미리 짚어두자면, 이 일곱 가지는 단일 축으로 깔끔하게 나뉘지 않는다. 저장 위치·출처·생애주기·역할이 섞여 있어 한 상태가 여러 범주에 동시에 속할 수도 있다. 완벽한 분류표가 아니라 <strong>상태를 어떻게 관리할지 결정할 때 던지는 질문들</strong>이라고 봐주면 좋겠다.</p>
<ul>
<li><strong>지역 상태(Local State)</strong>: 한 컴포넌트, 또는 좁은 트리 안에서만 쓰는 상태</li>
<li><strong>전역 상태(Global State)</strong>: 앱 전체가 공유해야 하는 상태</li>
<li><strong>서버 상태(Server State)</strong>: 서버가 진실의 출처이고, 클라이언트는 캐시인 상태</li>
<li><strong>폼 상태(Form State)</strong>: 사용자 입력 중 일시적으로 존재하는 상태</li>
<li><strong>URL 상태(URL State)</strong>: 주소창에 사는, 공유 가능하고 새로고침에도 살아남는 상태</li>
<li><strong>외부 상태(External State)</strong>: 쿠키, localStorage, sessionStorage, IndexedDB 등 React 외부에 사는 상태</li>
<li><strong>상태 가드(State Guard)</strong>: 상태 자체가 아닌, 상태의 조합으로 접근/실행을 막거나 검증하는 로직</li>
</ul>
<p>이 분류 외에도 상태머신으로 정교화해야 하는 워크플로우 상태, WebSocket·CRDT 기반의 실시간 협업 상태가 있다.</p>
<p>각각이 왜 다른 도구를 필요로 하는지, 그리고 어떤 안목으로 접근해야 하는지를 하나씩 풀어보자.</p>
<h2 id="지역-상태local-state"><a class="anchor" href="#지역-상태local-state">지역 상태(Local State)</a></h2>
<p>가장 단순한 상태이다. 한 컴포넌트 안에서만 사용하고, 외부에서는 알 필요도 알 권리도 없는 상태. 모달의 열림 여부, 토글 버튼의 on/off, 호버 상태, 입력 중인 검색어 같은 것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SearchBox</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{query} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e.target.value)} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이 정도는 익숙할 것이다. 그런데 지역 상태에서 진짜로 까다로운 건 <strong>"이 상태를 어디에 두어야 하는가"</strong> 라는 위치 결정의 문제이다.</p>
<p><a href="https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster" target="_blank" rel="noopener noreferrer">Kent C. Dodds의 State Colocation</a> 글에서 <strong>사람들은 상태를 "끌어올리는(lift up)" 것에는 익숙하지만, 코드가 바뀌었을 때 상태를 다시 "가까이 두는(colocate)" 것은 잘 하지 않는다.</strong> 라고 이야기를 한다.</p>
<p>상태를 끌어올리는 것은 형제 컴포넌트들이 같은 상태를 공유해야 할 때 우리가 자연스럽게 하는 행동이다. 두 형제가 같은 데이터를 봐야 하니, 공통 부모로 상태를 올려서 props로 내려준다.</p>
<p>문제는 그 상태가 더 이상 형제들에게 필요 없게 되었을 때이다. 우리는 그 상태를 다시 자식 쪽으로 <strong>끌어내리는</strong> 일을 잘 하지 않는다. 그 결과 부모 컴포넌트에는 사실 자기와 무관한 상태가 잔뜩 쌓이고, 부모가 리렌더링될 때마다 자식 트리 전체가 함께 리렌더링되는 일이 벌어진다.</p>
<p>그렇기에 지역 상태의 첫 번째 안목은 <strong>더 빠르고 ,단순해지는 것을 위해 상태를 그 상태를 쓰는 코드에 최대한 가까이 둬라</strong> 이다. 한 컴포넌트의 자식 하나에서만 쓰이는 상태라면, 부모가 들고 있을 이유가 없다. 그 자식 내부로 옮기자. 부모는 그만큼 가벼워진다.</p>
<h2 id="전역-상태global-state"><a class="anchor" href="#전역-상태global-state">전역 상태(Global State)</a></h2>
<p>전역 상태는 앱 어디서나 접근 가능해야 하는 상태이다. 로그인 정보, 테마, 언어, 알림(토스트) 같은 것들이 후보가 된다.</p>
<p>지역 상태와 전역 상태의 차이는 단순히 "어디에 사느냐"가 아니다. <strong>참조의 약속</strong>이 다르다. 지역 상태는 <strong>"이 컴포넌트 내부에서만 의미가 있다"</strong> 는 약속을, 전역 상태는 <strong>"앱 어디서든 이 이름으로 이 값을 참조할 수 있다"</strong> 는 약속을 코드 전체에 발행한다. 그 약속이 비싸다는 것이 전역 상태의 본질이다.</p>
<p>전역 상태를 하나 만든다는 것은, 사실 <strong>앱 전역에 걸친 암묵적 의존성</strong>을 하나 추가하는 일이다.</p>
<h2 id="서버-상태server-state"><a class="anchor" href="#서버-상태server-state">서버 상태(Server State)</a></h2>
<p>API로 받아온 데이터를 클라이언트 상태에 넣고, 직접 로딩과 에러를 boolean으로 관리하다가, <strong>"왜 이렇게 매번 똑같은 보일러플레이트를 짜고 있지?"</strong> 라는 의문에 도달한다.</p>
<p>Tanstack 메인 관리자인 Tanner Linsley는 <strong>"클라이언트 상태는 동기적이고 예측 가능하다. 서버 상태는 비동기적이고, 여러 컴포넌트에 걸쳐 공유되며, 캐싱·백그라운드 갱신·에러 상태를 신중히 다루어야 한다."</strong> 라고 한다. 즉 서버 상태는 클라이언트 상태와 <strong>본질적으로 다른 종(種)</strong> 이다. 같은 도구로 다루면 안 된다.</p>
<p>서버 상태가 까다로운 것은 도구의 문제가 아니라 <strong>데이터의 본질</strong> 때문이다.</p>
<p>클라이언트가 보고 있는 데이터는 서버의 것이다. 클라이언트가 가진 데이터는 <strong>어느 시점의 스냅샷</strong> 일 뿐이다. 이 데이터는 시간이 지날수록 staleness가 발생한다. 또한 비동기적이고 실패할 수 있으며 pending, error, success 등의 상태를 가진다.</p>
<p>제일 중요한 본질은 <strong>응답이 보낸 순서대로 돌아온다는 보장이 없다</strong>는 점이다. 검색창에서 "react"를 빠르게 타이핑한다고 해보자. r → re → rea → reac → react 요청이 순서대로 나가지만, "react"의 응답이 먼저 도착하고 그 뒤에 "rea"의 응답이 도착하면, 화면에는 "rea"의 결과가 표시된다. 이런 문제를 막으려면 AbortController나 요청 ID 추적을 매번 손으로 짜야 하는 <strong>동시성 위험 (race conditions)</strong> 을 신경써야한다.</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>외부 상태</strong>나 <strong>URL 상태</strong>와 결합한다.</p>
<h2 id="url-상태url-state"><a class="anchor" href="#url-상태url-state">URL 상태(URL State)</a></h2>
<p>검색 페이지에서 카테고리·정렬·페이지 번호를 필터링하고 있다고 해보자. 이 상태들을 useState로 들고 있으면 세 가지 문제가 동시에 생긴다.</p>
<ul>
<li>새로고침하면 모든 필터가 초기화된다</li>
<li>URL을 친구에게 공유해도 친구는 필터가 적용되지 않은 페이지를 본다</li>
<li>뒤로 가기를 눌러도 이전 필터로 돌아가지 않는다</li>
</ul>
<p>이런 문제를 해결하는 방법으로 <strong>상태를 URL에 넣는게 자연스럽다.</strong> URL은 그 자체로 새로고침·공유·히스토리에 대응되는 공짜로 얻는 영구 저장소이다.</p>
<pre><code>/products?category=shoes&#x26;sort=price-desc&#x26;page=2
</code></pre>
<p>이 URL 한 줄에 <strong>"신발 카테고리를 가격 내림차순으로 정렬한 2페이지"</strong> 라는 완전한 상태가 들어 있다. useState로 따로 들고 있을 필요가 없다.</p>
<p>그러면 언제 URL에서 상태를 다루는게 적합할까? <strong>URL은 공개 인터페이스이다.</strong> 비밀번호, 인증 토큰, 사용자가 누군가에게 보여주기 싫을 일시 메모 같은 것은 URL에 들어가면 안 된다. 또한 너무 자주 바뀌는 값(입력마다 갱신되는 검색어)을 그대로 URL에 박으면 히스토리 스택이 쓰레기로 가득 찬다. 이런 경우엔 debounce 후 반영하거나, <code>push</code> 대신 <code>replace</code>로 히스토리를 더럽히지 않게 해야 한다.</p>
<p>URL의 값은 <strong>항상 문자열</strong>이다. 숫자, boolean, 배열, 객체는 직렬화·역직렬화 단계를 거쳐야 한다. 게다가 URL은 <a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams" target="_blank" rel="noopener noreferrer">퍼센트 인코딩(percent-encoding)</a> 규칙을 따라야 해서 <code>&#x26;</code>, <code>=</code>, 한글, 공백 등이 특수 처리된다. 이걸 매번 손으로 짜면 곧 버그의 온상이 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> URLSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(location.search);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> page</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "1"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(page </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">navigate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`?${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">page</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQueryState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, parseAsInteger.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">withDefault</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p><a href="https://nuqs.dev/" target="_blank" rel="noopener noreferrer">nuqs</a> 같은 라이브러리는 <strong>파서(parser)</strong> 개념으로 이 두 가지를 해결한다. <code>parseAsInteger</code>, <code>parseAsBoolean</code>, <code>parseAsJson</code> 같은 파서가 직렬화·역직렬화·타입을 한 번에 책임진다. Next.js (App/Pages Router 모두), React Router v6/v7, TanStack Router, Remix 등 대부분의 환경을 지원한다.</p>
<p>그러면 URL에 상태를 얼마든지 박아 넣어도 괜찮을까? 직렬화·타입 문제와는 별개로, 마지막으로 신경 써야 할 제약이 하나 남아 있다. <a href="https://datatracker.ietf.org/doc/html/rfc7230" target="_blank" rel="noopener noreferrer">RFC 7230</a>은 정확한 한계를 정하지는 않지만, "서버가 최소 8,000 옥텟(네트워크나 데이터 통신에서 8개의 Bit가 모인 1Byte를 명확하게 지칭할 때 사용하는 단위)은 지원해야 한다"고 권장한다. 브라우저별 한계도 제각각이어서, 모던 브라우저는 대체로 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의 생명주기와 무관하게 살아남고, 또 변한다. 여기서 의미하는 외부 상태에는 <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 쿠키</strong>가 1순위이다. localStorage는 JavaScript에서 접근 가능하므로 <strong>XSS에 노출되는 순간 토큰이 그대로 털린다</strong>. 일부 보안 가이드는 <strong>액세스 토큰은 메모리에, 리프레시 토큰은 HttpOnly 쿠키에</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>를 항상 의식하고, 필요하면 직렬화 adapter를 둬야 한다.</p>
<p>외부 상태의 진짜 어려움은 <strong>React가 그 변화를 자동으로 감지하지 못한다</strong>는 점이다. localStorage에 값을 써도 React 컴포넌트는 리렌더링되지 않는다. 이걸 해결하는 패턴은 보통 세 가지이다.</p>
<ul>
<li><strong>커스텀 훅(useLocalStorage)으로 한 겹 감싸 외부 상태를 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>처럼 상태 라이브러리들이 외부 저장소 연동을 1급 기능으로 제공하기에 이미 만들어진 라이브러리를 활용할 수 있다.</li>
</ul>
<p>여기서 한 가지 안목을 더하자. <strong>외부 상태를 React로 가져오는 순간, 동기화의 책임은 우리에게 떨어진다.</strong> 다른 탭에서 갱신되면? 서버에서 쿠키를 바꾸면? 사용자가 브라우저 개발자 도구로 localStorage를 직접 만지면? 이런 것들이 종종 가장 큰 버그의 온상이 된다.</p>
<h2 id="상태-가드state-guard"><a class="anchor" href="#상태-가드state-guard">상태 가드(State Guard)</a></h2>
<p>마지막 범주는 결이 조금 다르다. 상태 자체가 아니라, <strong>상태의 조합으로 어떤 흐름을 막거나 허용하는 로직</strong>이다.</p>
<p>가장 흔한 예가 <strong>인증 가드(Auth Guard)</strong> 이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ProtectedRoute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isAuthenticated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isAuthenticated) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>여기서 <code>isAuthenticated</code>라는 상태가 라우팅 흐름을 통제한다. 이런 것이 바로 가드 로직이다. 인증 가드(인증된), 권한 가드(특정 역할, 권한), 플로우 가드(진입 분기), 검증 가드(단계 활성화) 등 여러 종류의 가드가 있다.</p>
<p>가드 로직은 한 곳에 몰리기 쉽다. 한 컴포넌트에 <strong>"로그인 안 했으면 로그인 페이지, 권한 없으면 403, 장바구니 비었으면 상품 페이지, 사용자 정지면 정지 안내"</strong> 가 다 들어가는 일이 흔하다. 가드가 비대해질수록 어떤 조건이 어디서 막혔는지를 디버깅하기 어려워진다.</p>
<p>좋은 가드는 <strong>한 가지만 검사한다.</strong> 결합은 Composition 형태로 한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"admin"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> require</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cartHasItems"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CheckoutPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>각 가드는 한 가지 결정만 내리고, 합성은 트리 구조가 책임진다. 새 가드를 추가할 때 기존 가드를 건드릴 필요가 없다.</p>
<p>가드를 다룰 때 막는 것보다 더 많이 생각해야할 부분은 <strong>어디로 보낼지/이후 어떻게 다룰지를 결정하는 것</strong>이다. 막기만 하고 폴백이 없는 가드는 흰 화면이나 무한 스피너로 끝난다.</p>
<p>가장 흔한 버그가 <strong>"가드의 비동기 검사가 끝나기 전에 보호된 콘텐츠가 잠깐 깜빡이는"</strong> 것이다. 인증 토큰 검증, 권한 조회는 대부분 비동기이고, 그 사이 <code>isAuthenticated</code>가 <code>undefined</code>나 <code>false</code>로 잠시 잡혀 있는 시점이 있다. <strong>로딩 상태를 명시적으로 다루지 않으면 그 틈에 보호된 화면이 노출되거나, 로그인 페이지로 잘못 리다이렉트되거나 한다.</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Ignores loading and handles only missing data => incorrect</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Treat loading as a first-class state (early return) => correct</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span></code></pre></figure>
<p>권한 가드를 짤 때 두 가지 모델이 흔히 쓰인다.</p>
<ul>
<li><strong>RBAC(Role-Based Access Control)</strong>: 역할 단위로 권한을 부여한다. "admin은 모든 사용자 정보를 볼 수 있다" 처럼. 단순하고 빠르지만, 역할이 세분화될수록 역할의 수가 폭발한다</li>
<li><strong>ABAC(Attribute-Based Access Control)</strong>: 속성 조합으로 권한을 결정한다. "사용자가 그 게시글의 작성자이거나, 같은 팀이거나, admin인 경우" 처럼. 표현력이 크지만 구현·디버깅이 어렵다</li>
</ul>
<p><a href="https://tanstack.com/router/v1/docs/framework/react/how-to/setup-rbac" target="_blank" rel="noopener noreferrer">TanStack Router의 RBAC 가이드</a>처럼 라우터 레벨에서 <code>beforeLoad</code>에 가드를 박는 패턴이 권장된다. 핵심은 <strong>권한 검사를 코드에 흩뿌리지 않고 데이터(역할/권한 목록)로 표현 가능해야</strong> 한다는 것이다. 그래야 권한 정책 변경이 <strong>데이터 변경</strong>으로 끝난다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리해보자. 상태 관리가 어려운 이유는 라이브러리가 어려워서가 아니다. <strong>상태에 종류가 있다는 사실을 자주 잊기 때문</strong>이고, 종류마다 다른 도구와 다른 사고방식이 필요하다는 점을 놓치기 쉽기 때문이다.</p>
<p>지역 상태는 가장 가까이, 전역 상태는 정말 전역인지 한 번 더 의심하고, 서버 상태는 캐시로 다루고, 폼은 도메인과 분리하고, URL은 더 적극적으로 활용하고, 외부 저장소는 책임을 의식하고, 가드는 얇게 나누어 합성하는 것. 이것이 일곱 가지 범주를 다루는 기본기이다.</p>
<p>그리고 그 위에서 작동하는 안목은 결국 네 가지 질문으로 압축된다.</p>
<ul>
<li>이 데이터의 진실의 출처는 어디인가?</li>
<li>이건 계산할 수 있는 값인가, 정말로 저장해야 하는 값인가?</li>
<li>이 상태의 조합 중 불가능한 조합이 있는가?</li>
<li>이 상태는 정말로 이 위치에 있어야 하는가?</li>
</ul>
<p>이 질문들을 새 화면을 짤 때마다, PR을 리뷰할 때마다, AI가 만들어준 코드를 받아볼 때마다 한 번씩 던지는 것. 그것이 안목과 느낌을 키우는 가장 확실한 길이라고 필자는 믿는다.</p>
<p>서두에서 말했듯, AI는 우리 옆에 오랫동안 머무를 것이다. 우리가 한 줄 한 줄 들여다보는 시간은 점점 줄어들 것이다. 그러나 그럴수록 <strong>"이 상태는 어디에 있어야 할까?"</strong> 같은 작은 질문에 답할 수 있는 감각은 더 비싸진다. AI에게 "여기에 useState 하나 더 만들어줘"라고 시키는 것은 쉽다. 그런데 그 한 줄이 우리 앱의 거미줄에 어떤 가닥을 더하는지를 아는 것은, 오직 그 코드를 읽는 사람의 안목에 달려 있다.</p>
<p>정답은 없다. 하지만 적어도 <strong>"상태가 뭔지 모르고 상태를 만드는 것"</strong> 과 <strong>"상태의 종류와 위치를 의식하면서 만드는 것"</strong> 사이에는 명백한 차이가 있다. 이 글을 읽는 독자 분들도 다음에 <code>useState</code>를 한 줄 적기 전에 한 번쯤 멈추고, "이건 어느 범주의 상태일까?"라고 물어봐 주시기를 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/choosing-the-state-structure" target="_blank" rel="noopener noreferrer">React, Choosing the State Structure</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://legacy.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html" target="_blank" rel="noopener noreferrer">React, You Probably Don't Need Derived State</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://xstate.js.org/" target="_blank" rel="noopener noreferrer">XState</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.syncfusion.com/blogs/post/react-state-management-libraries" target="_blank" rel="noopener noreferrer">Top 5 React State Management Tools in 2026</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>상태관리</category>
            <category>React</category>
            <category>아키텍처</category>
        </item>
        <item>
            <title><![CDATA[도메인 모델]]></title>
            <link>https://hooninedev.com/260418</link>
            <guid isPermaLink="false">https://hooninedev.com/260418</guid>
            <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 프론트엔드에서 도메인 로직을 어디에 둘 것인가에 대한 이야기를 해보려고 한다. 세금 계산이나 신고 상태 판단 같은 비즈니스 규칙이 컴포넌트와 훅, utils 에 흩어져 있어서, 규칙 하나가 바뀔 때마다 여러 파일을 찾아 고치고 있는 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 그 규칙을 React 에 의존하지 않는 순수 함수로 옮기는 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>프론트엔드에서 도메인 로직을 어디에 둘 것인가</strong>에 대한 이야기를 해보려고 한다.</p>
<p>세금 계산이나 신고 상태 판단 같은 비즈니스 규칙이 컴포넌트와 훅, utils 에 흩어져 있어서, 규칙 하나가 바뀔 때마다 여러 파일을 찾아 고치고 있는 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 그 규칙을 React 에 의존하지 않는 순수 함수로 옮기는 방법과, 그 분리를 어디서 멈출지 판단하는 기준을 얻을 수 있다.</p>
<p>예시는 모두 종합소득세 계산으로 든다. 필자가 관심을 두고 있는 도메인이다.</p>
<h2 id="프론트엔드에서-도메인-로직은-어디에-있어야-하는가"><a class="anchor" href="#프론트엔드에서-도메인-로직은-어디에-있어야-하는가">프론트엔드에서 도메인 로직은 어디에 있어야 하는가?</a></h2>
<p>답하기 전에 용어 하나를 맞춰 둔다. 이 글에서 <strong>도메인 모델</strong>은 비즈니스 개념과 그 위에서 작동하는 규칙을 함께 담은 것을 말한다. API 응답 타입처럼 데이터가 어떤 모양으로 오가는지만 정의하는 <strong>데이터 모델</strong>과는 다르다. 컴포넌트가 응답 구조에 직접 기대면 백엔드 스키마가 바뀔 때마다 프론트엔드 전체가 흔들리는 것도 이 차이 때문이다. 도메인, 도메인 모델, 도메인 오브젝트, 도메인 오브젝트 모델 네 용어가 어떻게 다른지는 <a href="/260413" target="_blank" rel="noopener noreferrer">도메인 용어</a>에 따로 정리해 두었다.</p>
<p>소프트웨어 설계에 깊은 관심을 가지고 있는 <a href="https://khalilstemmler.com/about/" target="_blank" rel="noopener noreferrer">Khalil Stemmler</a>는 처음에 "비즈니스 로직은 프론트엔드에 속하지 않는다"고 주장했다가, 이후 입장을 "백엔드에서 아키텍처적으로 하고 있는 거의 모든 것을, 프론트엔드에서도 할 수 있고 해야 한다." 라고 수정하며 말했다.</p>
<p>필자도 이 입장에 동의한다. 물론 프론트엔드가 비즈니스 로직의 <strong>단일 진실 공급원(Single Source of Truth)</strong> 이 되어서는 안 된다. 그것은 백엔드의 역할이다. 하지만 프론트엔드에도 <strong>프론트엔드만의 도메인 로직</strong>이 분명히 존재한다.</p>
<p>"사용자가 입력한 정보에 따라 실시간으로 예상 환급액을 보여줘야 하는" 경우를 생각해보자. 이런 계산 로직이 백엔드에만 존재하게 되면, 사용자가 소득 금액을 한 글자 고칠 때마다 API를 호출해야 한다. 네트워크 왕복 시간만큼 UI가 멈추고, 입력이 빠른 사용자라면 불필요한 요청이 폭발적으로 늘어난다. debounce를 건다 해도 수백 밀리초의 지연은 "실시간 미리보기"라는 경험을 깨뜨리기에 충분하다. <strong>결국 즉각적인 피드백이 필요한 계산은 프론트엔드가 직접 수행할 수밖에 없고, 프론트엔드에서만 수행할 수 있는 로직이 존재하게된다.</strong></p>
<h3 id="도메인-로직이-컴포넌트에-섞인-경우"><a class="anchor" href="#도메인-로직이-컴포넌트에-섞인-경우">도메인 로직이 컴포넌트에 섞인 경우</a></h3>
<p>종합소득세 미리보기 화면을 예로 들어보자. 사용자가 소득 정보를 입력하면 예상 세액을 실시간으로 보여주는 기능이다. 아래는 흔히 볼 수 있는, 도메인 로직과 UI 로직이 뒤섞인 코드이다</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 종합소득금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 경비율;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 소득공제합계</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 인적공제대상인원 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 과세표준</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, 종합소득금액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 소득공제합계);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 50_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.15</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_260_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 88_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.24</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5_760_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 150_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.35</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 15_440_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.38</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 19_940_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 기납부세액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> refundOrPayment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 기납부세액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>...&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이 코드의 문제가 보이는가? "인적공제 1인당 150만원", "8단계 누진세율", "3.3% 원천징수"라는 <strong>세법에 의해 정해진 비즈니스 규칙</strong>이 React 컴포넌트 안에 직접 박혀 있다. 세법이 매년 개정되는데, 이런 규칙이 컴포넌트에 흩어져 있으면 개정 시 수정해야 할 곳을 찾아 헤매게 된다. 개발자뿐 아니라 QA팀이 다루는 E2E 시나리오가 존재한다면 테스트 비용도 만만하지 않을 것이다.</p>
<p>결국 이게 뷰 로직인지 비즈니스 로직인지 구분하기 어려워지고, 수많은 조건문과 커스텀 훅으로 엉키게 된다.</p>
<h3 id="도메인-로직을-분리해보자"><a class="anchor" href="#도메인-로직을-분리해보자">도메인 로직을 분리해보자.</a></h3>
<p>Alex Bespoyasov의 Clean Architecture 접근법에서 핵심 원칙을 빌려오자. 도메인 로직을 <strong>프레임워크에 의존하지 않는 순수 함수</strong>로 분리하는 것이다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>도메인은 하나의 애플리케이션을 다른 애플리케이션과 구별하는 핵심이다. React에서 Angular로 옮겨도 변하지 않는 것으로 생각할 수 있다.</p></div><div class="quote-original" lang="en"><p>The domain is the core that distinguishes one application from another. You can think of the domain as something that won't change if we move from React to Angular.</p></div></blockquote>
<p>위의 세금 계산 예시를 리팩토링해보자.</p>
<p>먼저 도메인 타입과 규칙을 정의하여 관련한 정보를 응집한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  grossAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  expenseRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  personalCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  pensionPaid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  additionalDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> PERSONAL_DEDUCTION_PER_PERSON</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> WITHHOLDING_RATE</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { limit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, rate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, progressiveDeduction: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /** ...구간들... **/</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>그리고 도메인 로직을 순수 함수로 분리한다</p>
<p>앞서 소득 계산, 공제, 과세표준, 세액, 환급액 계산등의 로직을 <code>computeFullTax</code> 함수로 분리한다. 각 단계는 다시 작은 순수 함수로 쪼갠다. 결과 타입은 <code>ReturnType&#x3C;typeof computeFullTax></code>로 추론시키면 별도 인터페이스 선언이 필요 없다.</p>
<p>이후 컴포넌트는 도메인 로직을 "사용"만 한다</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { computeFullTax } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    grossAmount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    expenseRate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDeductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    personalCount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    pensionPaid: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    additionalDeductions: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> computeFullTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(income, deductions);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">IncomeForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{income} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIncome} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DeductionForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{deductions} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setDeductions} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TaxResultSummary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{result} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>무엇이 달라졌는가?</p>
<ul>
<li><strong>8단계 누진세율 테이블</strong>(<code>TAX_BRACKETS</code>)이 한 곳에 모여 있어 세법 개정 시 <code>domain/tax.ts</code>만 수정하면 된다</li>
<li><strong>계산 파이프라인</strong>이 <code>computeFullTax</code> 한 함수로 응집되어, 전체 흐름이 한눈에 보인다. (예시의 단순함을 위해 하나로 묶었지만, 실제 프로젝트에서는 소득 계산·공제 계산·세액 산출 등 목적별로 더 세분화하는 것이 적합하다.)</li>
<li><strong>컴포넌트는 "어떻게 보여줄까"에만 집중</strong>한다. 세율이 바뀌어도 컴포넌트를 수정할 필요가 없다</li>
<li>React에서 다른 프레임워크로 마이그레이션한다 해도, <code>domain/tax.ts</code>는 <strong>바뀌지 않는다</strong></li>
</ul>
<p>도메인 로직이 분리되면 테스트는 놀랍도록 단순해진다. 세금 도메인에서는 <strong>계산의 정확성이 곧 사용자의 돈</strong>이기 때문에 이 점이 특히 중요하다.</p>
<p>세금 관련한 계산 로직을 담은 순수 함수에는 React Testing Library도, <code>render</code>도, <code>screen.getByText</code>도 필요 없다. 입력을 넣고 출력을 확인하면 끝이다. "1,400만원 이하는 6% 세율", "과세표준 0원이면 세액도 0원", "프리랜서 3,000만원 소득의 환급액" 같은 케이스가 한 줄짜리 <code>it</code>으로 표현된다. 도메인 단위 테스트가 컴포넌트 분리 기준을 자연스럽게 잡아주고, 테스트 코드가 문서 역할까지 한다.</p>
<h2 id="빈약한-도메인-모델anemic-domain-model"><a class="anchor" href="#빈약한-도메인-모델anemic-domain-model">빈약한 도메인 모델(Anemic Domain Model)</a></h2>
<p>앞 절에서는 <strong>계산 로직</strong>을 분리했다. 그런데 도메인 로직에는 계산 외에도 <strong>상태 전이 규칙</strong>과 <strong>권한 판단</strong>이 있다. "이 신고서를 지금 수정할 수 있나?", "제출은 가능한가?", "청구방식을 전환할 수 있는가?" 같은 질문이 그것이다. 이런 규칙을 분리하다 보면 빠지기 쉬운 함정이 하나 있는데, Martin Fowler가 이름 붙인 <strong>빈약한 도메인 모델(Anemic Domain Model)</strong> 이다.</p>
<p>빈약한 도메인 모델은 <strong>타입은 도메인 언어로 잘 정의해뒀는데, 그 위에서 작동하는 규칙은 도메인 바깥으로 흩어져버린</strong> 상태를 말한다. 세금 신고(Filing) 도메인을 예로 들어보자. 타입은 깔끔하다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// types/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>그런데 이 타입에 대한 판단·전이 규칙은 다른 곳에 박혀 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/filingHelpers.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmendFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// components/FilingDetail.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingDetail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 같은 도메인 규칙을 컴포넌트 안에 다시 작성한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> canEdit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// hooks/useSubmitFiling.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmitFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>같은 도메인 규칙이 utils, 컴포넌트, 훅 세 곳에 각자 다른 모습으로 존재한다. 이 상태에서 "청구 가능 조건이 변경됩니다"라는 요구사항이 들어오면 수정해야 할 곳을 찾아 다녀야 하고, 빠뜨린 한 곳은 사이트 어딘가에서 잘못된 판단을 내린다. Fowler는 이런 코드를 두고 <strong>"객체지향의 외피만 두른 절차적 코드와 다를 바 없다"</strong> 고 비판했다.</p>
<p>해법은 앞 절에서 계산 로직에 적용한 것과 동일하다. <strong>규칙을 타입 옆에 둔다.</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 규칙은 도메인 옆에 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이제 신고 관련 규칙은 <code>domain/filing.ts</code> 한 곳에서 관리된다. 어떤 컴포넌트에서든 <code>canAmend(filing)</code>을 호출하면 되고, 규칙이 바뀌면 이 파일 하나만 수정하면 된다. 핵심은 <strong>타입과 그 위의 규칙은 한 묶음으로 인지하는 것이다.</strong> 타입만 도메인 폴더에 두고 규칙은 utils로 빼는 부분 분리는, 외양은 깔끔해 보여도 여전히 빈약한 상태다.</p>
<h2 id="api-응답과-도메인-모델-사이의-변환-계층"><a class="anchor" href="#api-응답과-도메인-모델-사이의-변환-계층">API 응답과 도메인 모델 사이의 변환 계층</a></h2>
<p>실무에서 한 가지 더 고려해야 할 것이 있다. 백엔드 API 응답 구조와 프론트엔드의 도메인 모델이 항상 일치하지는 않는다는 점이다. 국가 기관과 연동되는 세금 서비스라면 더욱 그렇다. 국세청 홈택스 연동 데이터는 약어와 코드값으로 가득 차 있어, 프론트엔드 도메인 모델과 같은 형태로 내려올 가능성이 낮다.</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을 두면 된다. 홈택스 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>이다. "원"이라는 단위를 붙이고 천 단위 콤마를 찍는 것은 비즈니스 규칙이 아니라, 사용자에게 어떻게 보여줄지에 관한 것이다. 반면 <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-vs-함수형"><a class="anchor" href="#class-vs-함수형">Class vs 함수형</a></h2>
<p>여기까지 읽으면 한 가지 근본적인 질문이 떠오를 수 있다. 지금까지의 예시는 모두 <code>interface</code> + 순수 함수 조합이었는데, Class로 도메인을 표현하면 응집이 더 자연스럽지 않을까?</p>
<p>맞는 말이다. Class 기반으로 도메인을 표현하면 데이터와 행위가 하나의 객체에 묶이기 때문에, 응집이 코드 구조에서 바로 드러난다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  ) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  547200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>Class 기반으로 작성하면 행위가 데이터 귀속된다. 그리고 사용처에서 주어가 명확하다. <code>filing.canAmend()</code>는 마치 자연어를 읽는 것처럼 직관적이다. 주어(filing)와 동사(canAmend)가 명확하게 결합되어 있다. 마치 <code>jihoon.eat('감자탕')</code>이라고 쓰면 "지훈이가 감자탕을 먹는다"가 바로 읽히는 것과 같다.</p>
<p>반면 함수형 스타일에서는 이렇게 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">eat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"감자탕"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>함수형은 데이터가 밖에 존재한다. 위 두가지 코드는 <code>filing</code>이라는 데이터를 인자로 받아서 어떤 동작을 수행한다. <code>eat</code> 함수는 <code>jihoon</code>과 <code>감자탕</code>이라는 데이터를 인자로 받아서 수행한다.</p>
<p>결과적으로 주어와 동사의 결합이 느슨해진다. <code>canAmend</code>라는 함수가 <code>TaxFiling</code>과 관련된다는 것은 파일을 열어보거나 타입 시그니처를 확인해야 알 수 있다. 같은 파일에 <code>canAmend(filing)</code>, <code>canEdit(filing)</code>, <code>calculateTax(taxableBase)</code> 같은 함수들이 섞여 있으면, 어떤 함수가 어떤 도메인에 속하는지 한눈에 파악하기 어려워질 수 있다.</p>
<h3 id="그렇다면-class를-써야-하는가"><a class="anchor" href="#그렇다면-class를-써야-하는가">그렇다면 Class를 써야 하는가?</a></h3>
<p>솔직히 말하면, 답은 <strong>"상황에 따라 다르다"</strong> 이다. 하지만 필자의 경험상 React + TypeScript 환경에서는 Class가 만능이 아닌 현실적 이유들이 있다.</p>
<h4 id="react-상태-관리와의-마찰"><a class="anchor" href="#react-상태-관리와의-마찰">React 상태 관리와의 마찰</a></h4>
<p>React의 상태 관리는 기본적으로 <strong>Plain Object</strong>와 가장 자연스럽게 맞물린다. <code>useState</code>나 <code>useReducer</code>는 기술적으로 어떤 값이든 담을 수 있고 Redux DevTools도 Class 인스턴스의 프로토타입을 직접 제거하지는 않는다. 하지만 Redux/Zustand의 영속화 미들웨어가 JSON으로 상태를 저장했다가 복원하면 Class 인스턴스는 <code>JSON.stringify</code> → <code>JSON.parse</code> 사이클에서 메서드와 프로토타입을 잃은 plain object가 된다. 한편 React Server Component에서 Client Component로 props를 전달하는 경계는 직렬화 가능한(serializable) 값만 지원하므로, 임의의 Class 인스턴스는 애초에 전달할 수 없다.</p>
<p>아래 코드를 살펴보자.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>React 상태를 업데이트하는 것만으로는 <code>filing</code>이 <code>TaxFilingModel</code> 인스턴스라는 사실이 사라지지 않는다. 다만 Redux/Zustand 영속화가 JSON으로 저장·복원한 값은 메서드 없는 plain object가 될 수 있어, 무심코 호출한 <code>filing.canAmend()</code>가 런타임 에러로 터질 수 있다. React Server Component에서 Client Component로 넘길 때는 Class 인스턴스가 지원되는 직렬화 형식이 아니므로 전달 단계에서 막힌다.</p>
<h4 id="불변성-보장의-어려움"><a class="anchor" href="#불변성-보장의-어려움">불변성 보장의 어려움</a></h4>
<p>React는 상태 변경을 <strong>참조 비교(referential equality)</strong> 를 기반으로 감지한다. Class 인스턴스의 메서드가 <code>this.items.push(...)</code> 같은 내부 변경을 하면 참조가 그대로라 React가 리렌더링을 트리거하지 않는다. 그래서 결국 <code>addDeduction(item)</code>이 <code>return new DeductionList([...this.items, item])</code> 처럼 매번 새 인스턴스를 반환하도록 짜야 하는데, 이러면 Class의 이점인 "캡슐화된 상태 변경"이 무색해진다. 함수형 갱신과 별로 다르지 않은 코드가 된다.</p>
<h3 id="함수형에서-응집을-확보하는-전략"><a class="anchor" href="#함수형에서-응집을-확보하는-전략">함수형에서 응집을 확보하는 전략</a></h3>
<p>그렇다면 함수형 스타일에서 <code>eat('jihoon', '감자탕')</code> 같은 느슨한 응집 문제를 어떻게 개선할 수 있을까? 필자가 효과적이라고 느낀 방법 세 가지를 소개한다.</p>
<h4 id="모듈-네임스페이스로-응집하기"><a class="anchor" href="#모듈-네임스페이스로-응집하기">모듈 네임스페이스로 응집하기</a></h4>
<p>가장 직관적인 방법이다. 파일(모듈) 자체를 도메인 단위로 만들고, import 시 네임스페이스를 활용한다. 앞서 정의한 <code>domain/filing.ts</code>를 그대로 가져다 쓰면 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> *</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FilingModel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/filing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span></code></pre></figure>
<p><code>FilingModel.canAmend(filing)</code>은 <code>filing.canAmend()</code>만큼은 아니지만, 최소한 이 함수가 Filing 도메인에 속한다는 것이 코드에서 바로 드러난다. 함수가 여러 도메인에 걸쳐 섞일 위험도 없어진다.</p>
<h4 id="첫-인자는-도메인-주체로"><a class="anchor" href="#첫-인자는-도메인-주체로">첫 인자는 도메인 주체로</a></h4>
<p>함수형에서 응집을 표현하는 또 다른 컨벤션이 있다. <strong>첫 번째 인자를 항상 "행위의 주체"로 둔다.</strong> <code>canAmend(filing)</code>, <code>calculateTotalIncome(income)</code>처럼 시그니처를 통일하면 <code>canAmend(filing)</code>은 "filing에 대해 canAmend를 물어본다"로 읽힌다. Unix의 파이프라인 사고방식(<code>data |> transform</code>)과도 일맥상통한다. 사실 Go 언어의 메서드 리시버가 정확히 이 패턴이고, Rust의 <code>impl</code> 블록에서 <code>self</code>를 첫 인자로 받는 것도 같은 발상이다.</p>
<h4 id="factory-함수로-행위-묶기"><a class="anchor" href="#factory-함수로-행위-묶기">Factory 함수로 행위 묶기</a></h4>
<p>Class의 응집력이 그리울 때 사용할 수 있는 패턴이다. 팩토리 함수가 도메인 객체와 그 행위를 한 번에 반환한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">data,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(rawFiling);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>이 패턴은 Class의 표현력(<code>filing.canAmend()</code>)과 객체 리터럴을 활용하는 함수형 구성의 실용성을 함께 얻는다. 다만 반환 객체에 함수 프로퍼티가 있으므로 JSON 직렬화가 가능한 데이터 객체는 아니다. 매번 함수 객체를 새로 만드는 비용도 있지만, 프론트엔드에서 다루는 데이터 규모에서는 성능 문제가 되는 경우가 거의 없다.</p>
<h2 id="어디까지-분리할-것인가"><a class="anchor" href="#어디까지-분리할-것인가">어디까지 분리할 것인가?</a></h2>
<p>Clean Architecture를 읽다 보면 3~4개 레이어를 나누고 Port/Adapter를 정의하는 이상적인 구조가 나온다. 하지만 현실에서 모든 프로젝트에 이 구조를 적용하는 것은 과잉 설계(over-engineering)가 될 수 있다.</p>
<p>필자가 생각하는 실용적 기준은 다음과 같다.</p>
<ul>
<li><strong>도메인 타입을 API 응답 타입과 분리</strong>한다. <code>interface</code>든 <code>type</code>이든, 프론트엔드가 사용하는 도메인 개념을 별도 파일로 정의한다.</li>
<li><strong>비즈니스 규칙이 포함된 로직은 컴포넌트 밖으로 뺀다.</strong> <code>domain/</code> 폴더가 아니어도 좋다. 중요한 것은 React에 의존하지 않는 순수 함수로 만드는 것이다.</li>
<li><strong>API 응답 → 도메인 모델 변환을 한 곳에서 한다.</strong> Mapper 함수든, Zod 스키마든, 그 한 곳만 수정하면 변경이 전파되지 않는 구조를 만든다.</li>
</ul>
<p>만약 프로젝트가 복잡해지면 아래와 같은 상황을 더 고려해볼 수 있다.</p>
<ul>
<li><strong>Bounded Context 단위로 폴더를 나눈다.</strong> <a href="https://frontend-fundamentals.com/" target="_blank" rel="noopener noreferrer">토스 프론트엔드 챕터</a>에서도 "함께 변경되는 파일을 같은 디렉토리에 배치하라"는 원칙을 강조한다. 도메인 단위로 폴더를 나누면 import 경로가 도메인 경계를 자연스럽게 드러낸다.</li>
<li><strong>Use Case 계층을 도입한다.</strong> 도메인 로직의 조합이 복잡해지면, "소득 정보 조회 → 경비율 적용 → 공제 항목 계산 → 세액 산출 → 환급액 확정"이라는 시나리오를 하나의 함수로 묶는 Application 계층이 필요해진다.</li>
</ul>
<pre><code>src/
├── domains/
│   ├── tax/
│   │   ├── tax.ts                  # 세액 계산 도메인 (세율, 공제, 계산 파이프라인)
│   │   ├── tax.viewModel.ts        # 세액 표현 (금액 포맷, 구간 라벨)
│   │   ├── tax.test.ts             # 세액 계산 테스트
│   │   └── incomeMapper.ts         # 홈택스 API ↔ 도메인 변환
│   ├── filing/
│   │   ├── filing.ts               # 신고 상태 도메인 (상태 전이, 권한)
│   │   ├── filing.viewModel.ts     # 신고 표현 (상태 배지, 라벨)
│   │   ├── filing.test.ts
│   │   └── filingMapper.ts
│   └── deduction/
│       ├── deduction.ts            # 공제 항목 도메인 (자격 조건, 한도)
│       └── deduction.viewModel.ts
├── hooks/                           # React 의존 로직
├── components/                      # UI 컴포넌트
└── api/                             # API 호출
</code></pre>
<p>세금이라는 하나의 도메인 안에서도 <strong>세액 계산(tax)</strong>, <strong>신고 관리(filing)</strong>, <strong>공제 항목(deduction)</strong> 이 각각 독립된 하위 도메인으로 분리된다. 세율이 바뀌어도 신고 상태 전이 로직은 영향을 받지 않고, 공제 항목이 추가되어도 신고서 제출 플로우는 그대로이다. 이것이 Bounded Context의 실전적 적용이다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, 프론트엔드에도 프론트엔드만의 도메인 로직이 있고, 그 규칙은 컴포넌트가 아니라 타입 옆의 순수 함수에 둔다. API 응답은 mapper 한 곳에서 도메인 타입으로 바꾸고, 화면 표현은 ViewModel 로 떼어 내고, 함께 변하는 것은 함께 둔다.</p>
<p>이것을 프론트엔드에서 실천한다는 것은, 단순히 폴더를 나누는 것이 아니라 <strong>여러 겹의 경계를 의식적으로 판단하는 것</strong>이다. "이건 비즈니스 규칙인가, 표현 로직인가?" "이 데이터는 도메인 상태인가, UI 상태인가?" "이 함수의 응집은 충분한가?" 이런 질문을 습관적으로 던지는 것만으로도 코드의 구조는 자연스럽게 좋아진다.</p>
<p>물론 모든 프로젝트에 Clean Architecture의 레이어를 다 갖출 필요는 없다. 단순한 CRUD 앱에 4개 레이어를 나누고 모든 도메인에 Factory 패턴을 적용하는 것은 배보다 배꼽이 더 큰 격이다. Class의 우아한 응집과 함수형의 실용적 유연함 사이에서 정답은 프로젝트의 복잡도와 팀의 컨텍스트가 결정한다.</p>
<p>정답은 없다. 하지만 적어도 <strong>"도메인이 무엇인지 모르고 코드를 짜는 것"</strong> 과 <strong>"도메인을 인식하고, 경계를 판단하고, 의식적으로 분리하는 것"</strong> 사이에는 분명한 차이가 있다. 이 글을 읽는 독자 분들도 자신의 프로젝트에서 "여기서 도메인은 뭘까, 그리고 이 코드는 어디에 있어야 할까?"라는 질문을 한 번쯤 던져보시길 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design (Book)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html" target="_blank" rel="noopener noreferrer">Robert C. Martin, Clean Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://khalilstemmler.com/articles/typescript-domain-driven-design/ddd-frontend/" target="_blank" rel="noopener noreferrer">Khalil Stemmler, Does DDD Belong on the Frontend?</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://bespoyasov.me/blog/clean-architecture-on-frontend/" target="_blank" rel="noopener noreferrer">Alex Bespoyasov, Clean Architecture on Frontend</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://toss.tech/article/income-qa-e2e-automation" target="_blank" rel="noopener noreferrer">토스, E2E 자동화 여정</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>아키텍처</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[도메인 용어]]></title>
            <link>https://hooninedev.com/260413</link>
            <guid isPermaLink="false">https://hooninedev.com/260413</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 도메인, 도메인 모델, 도메인 오브젝트, 도메인 오브젝트 모델이 서로 어떻게 다른지에 대한 이야기를 해보려고 한다. DDD 글을 읽다가 이 단어들이 같은 것을 가리키는지 헷갈렸던 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 네 용어를 추상에서 구체로 내려가는 한 줄 위에 놓을 수 있고, API 응답 타입이 왜 도메인 모델이 아닌지 설명...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>도메인, 도메인 모델, 도메인 오브젝트, 도메인 오브젝트 모델이 서로 어떻게 다른지</strong>에 대한 이야기를 해보려고 한다.</p>
<p>DDD 글을 읽다가 이 단어들이 같은 것을 가리키는지 헷갈렸던 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 네 용어를 추상에서 구체로 내려가는 한 줄 위에 놓을 수 있고, API 응답 타입이 왜 도메인 모델이 아닌지 설명할 수 있다.</p>
<p>필자도 개발을 하면서 이 단어들을 꽤 자주 접해왔지만, 막상 "도메인이 정확히 뭔데?"라는 질문에는 명쾌하게 대답하기가 쉽지 않았다. (솔직히 개발을 처음 시작했을때 도메인은 www 을 의미하는 줄 알았다.) 예시는 모두 종합소득세 계산으로 든다.</p>
<h2 id="도메인domain"><a class="anchor" href="#도메인domain">도메인(Domain)</a></h2>
<p>가장 기초적인 질문부터 시작해보자. <strong>도메인</strong>이란 무엇인가?</p>
<p>Eric Evans는 그의 저서 <strong>Domain-Driven Design: Tackling Complexity in the Heart of Software(2003)</strong> 에서 도메인을 다음과 같이 정의한다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>지식, 영향력, 또는 활동의 영역.</p></div><div class="quote-original" lang="en"><p>"A sphere of knowledge, influence, or activity."</p></div></blockquote>
<p>쉽게 말해, <strong>프로그래밍으로 해결하고자 하는 문제 영역</strong> 그 자체가 도메인이다. 세금 신고 서비스를 만든다면 "세금 신고"가 도메인이고, 보험 청구 플랫폼을 만든다면 "보험 청구"가 도메인인 것이다. 도메인은 코드가 아니다. 소프트웨어 이전에 존재하는 현실 세계의 문제 영역이다.</p>
<p>프론트엔드 개발자에게 이것은 어떤 의미일까? 우리가 만드는 UI는 결국 이 도메인을 사용자에게 보여주고 조작할 수 있게 해주는 <strong>창(window)</strong> 이다. 세금 도메인을 메인으로 다루는 토스인컴, 삼쩜삼 같은 세금 환급 서비스를 개발한다면, 소득 유형, 경비율, 소득공제, 세액공제, 환급액이라는 도메인 개념을 UI로 표현하는 것이다. 따라서 프론트엔드 개발자도 자신이 다루는 도메인을 깊이 이해해야 한다. UI 컴포넌트를 잘 그리는 것 못지않게, <strong>"이 서비스가 해결하는 문제가 무엇인지"</strong> 를 아는 것이 중요하다는 뜻이다.</p>
<p>그런데 "세금"이라는 도메인 하나만 해도, 들여다보면 내부에 수많은 하위 도메인이 존재한다. 내가 겉으로만 알고있는 종합소득세 계산 파이프라인만 봐도 이렇다.</p>
<p><picture><source type="image/webp" srcset="/content/260413/1.720w.webp 720w, /content/260413/1.1090w.webp 1090w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260413/1.png" alt="종합소득세 계산 파이프라인. 총수입금액에서 납부와 환급까지 이어지는 단계가 소득, 공제, 세액, 결과 네 하위 도메인으로 색이 나뉜다" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>이 파이프라인의 각 단계가 독자적인 규칙과 데이터를 가진 하위 도메인이다. "세금"이라는 하나의 큰 도메인 안에 소득(Income), 공제(Deduction), 세액(Tax), 결과(Filing)라는 세부 도메인들이 얽혀 있는 것이다. 이것을 코드로 어떻게 나눠야 하는지가 바로 도메인 모델링의 핵심 질문이다.</p>
<h2 id="도메인-모델domain-model"><a class="anchor" href="#도메인-모델domain-model">도메인 모델(Domain Model)</a></h2>
<p>그렇다면 도메인 모델은 무엇일까? 도메인이랑 "도메인 모델"은 어떻게 다른 것일까?</p>
<p><a href="https://martinfowler.com/eaaCatalog/domainModel.html" target="_blank" rel="noopener noreferrer">Martin Fowler</a>는 도메인 모델을 행위와 데이터를 모두 포함하는 도메인의 객체 모델이라고 정의한다. Eric Evans의 정의는 여기서 한 걸음 더 들어간다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>도메인의 선택된 측면을 기술하는 추상화 체계로, 해당 도메인과 관련된 문제를 해결하는 데 사용될 수 있다. (Eric Evans)</p></div><div class="quote-original" lang="en"><p>A system of abstractions that describes selected aspects of a domain and can be used to solve problems related to that domain.</p></div></blockquote>
<p>핵심은 <strong>"선택적 추상화"</strong> 라는 점이다. 도메인 모델은 현실 세계의 모든 것을 담지 않는다. 영화감독이 현실의 모든 장면을 담지 않고 이야기에 필요한 장면만 선택하듯, 도메인 모델도 <strong>해결하려는 문제에 필요한 측면만 골라서 구조화</strong>한 것이다.</p>
<p>여기서 중요한 점이 하나 있다. 도메인 모델은 반드시 코드일 필요가 없다. 화이트보드에 그린 다이어그램일 수도 있고, 팀원들 머릿속에 공유된 멘탈 모델(Mental Model)일 수도 있다. 결국 도메인 모델이라는 용어 자체는 소프트웨어와는 별개의 개념일 수 있다.</p>
<p>여기서 프론트엔드 개발자가 특히 혼동하기 쉬운 부분이 있다. API 응답 구조를 보고 "이게 도메인 모델이군"이라고 생각하는 것이다. 하지만 이는 <strong>데이터 모델(Data Model)</strong> 이지, 도메인 모델이 아니다.</p>
<p>데이터 모델과 도메인 모델을 구분해보면 아래와 같다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>도메인 모델</th>
<th>데이터 모델</th>
</tr>
</thead>
<tbody>
<tr>
<td>목적</td>
<td>비즈니스 개념과 규칙을 표현</td>
<td>저장/전송 구조를 정의</td>
</tr>
<tr>
<td>언어</td>
<td>비즈니스 용어 (과세표준, 세액공제, 환급액)</td>
<td>기술 용어 (string, number, array)</td>
</tr>
<tr>
<td>포함 요소</td>
<td>데이터 + 행위(규칙)</td>
<td>데이터 구조만</td>
</tr>
<tr>
<td>예시</td>
<td>"과세표준 1,400만원 이하 구간은 세율 6%"</td>
<td><code>{ taxableBase: number, taxRate: number }</code></td>
</tr>
</tbody>
</table>
<p>데이터 모델은 "어떤 형태로 데이터가 오가는가"를 정의하고, <strong>도메인 모델은 "이 데이터가 비즈니스적으로 무엇을 의미하고 어떤 규칙을 따르는가"를 정의한다.</strong> 이 둘을 구분하지 못하면, 컴포넌트가 API 응답 구조에 직접 의존하게 되어 백엔드 스키마가 바뀔 때마다 프론트엔드 전체가 흔들리는 상황이 벌어진다.</p>
<h2 id="도메인-오브젝트domain-object"><a class="anchor" href="#도메인-오브젝트domain-object">도메인 오브젝트(Domain Object)</a></h2>
<p>도메인 모델이 개념의 체계라면, <strong>도메인 오브젝트</strong>는 그 개념이 코드로 구현된 실체이다.</p>
<p>Code with Jason 을 운영하고있는 <a href="https://www.codewithjason.com/difference-domains-domain-models-object-models-domain-objects/" target="_blank" rel="noopener noreferrer">Jason Swett 의 글</a>에서 도메인 오브젝트를 이렇게 정의한다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>내 객체 모델의 객체 가운데 도메인 모델에서도 하나의 개념으로 존재하는 것은 도메인 오브젝트라고 부르겠다.</p></div><div class="quote-original" lang="en"><p>Any object in my object model that also exist as a concept in my domain model I would call a domain object.</p></div></blockquote>
<p>즉, 도메인 모델에서 "종합소득"이라는 개념이 있고, 코드에서 <code>Income</code>이라는 타입이 있다면, 이 <code>Income</code>이 바로 도메인 오브젝트인 것이다. 하지만 모든 코드 객체가 도메인 오브젝트인 것은 아니다. <code>HttpClient</code>, <code>LocalStorageAdapter</code>, <code>useDebounce</code> 같은 것들은 기술적 도구이지 도메인 개념이 아니다.</p>
<h3 id="entity와-value-object"><a class="anchor" href="#entity와-value-object">Entity와 Value Object</a></h3>
<p>Evans는 도메인 오브젝트를 <strong>Entity</strong>, <strong>Value Object</strong>, <strong>Service</strong> 세 범주로 분류한다. (Martin Fowler는 이 분류를 "Evans Classification"이라고 부른다.) Service는 "특정 오브젝트에 자연스럽게 귀속되지 않는 도메인 연산"을 표현하는 별도 개념인데, 이 절이 다루려는 핵심은 데이터를 어떻게 식별하느냐의 문제이므로 Entity와 Value Object 두 가지를 중심으로 살펴본다.</p>
<p><strong>Entity(엔티티)</strong> 는 시간과 다양한 표현을 관통하는 고유한 정체성을 가진 객체다. 세금 신고서(TaxFiling), 납세자(Taxpayer), 소득 내역(IncomeRecord)처럼 고유한 ID로 식별되며, 속성이 바뀌더라도 같은 ID면 같은 엔티티이다. 신고서의 공제 항목이 수정되어도, 신고서 ID가 바뀌지 않는 한 그것은 여전히 같은 신고서이다.</p>
<p><strong>Value Object(값 객체)</strong> 는 속성의 조합으로만 의미를 가지는 객체로, 모든 속성 값이 같으면 동일한 것으로 간주한다. 금액(Money), 세율(TaxRate), 과세 구간(TaxBracket)처럼 값 자체가 의미인 객체이다. "세율 6%"는 어디에서 쓰이든 "세율 6%"일 뿐이다.</p>
<p>프론트엔드에서 이 구분이 왜 중요할까? 아래 코드 예시를 통해 알아보자.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxpayerName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameFiling</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.id;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  currency</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "KRW"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "USD"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameMoney</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  a.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.currency </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.currency;</span></span></code></pre></figure>
<p>TaxFiling 은 id를 정체성의 기준으로 삼기에 Entity이다. (id 필드를 가지는 것 자체가 Entity의 정의가 아니라, "그 id로 같은지 다른지를 판단한다"는 점이 핵심이다.) Money 는 id 없이 amount와 currency의 조합으로만 식별되고, 모든 속성이 같으면 같은 값으로 간주된다.</p>
<p>Entity는 ID 기반 비교, Value Object는 속성 기반 비교. 이 구분을 명확히 하면 상태 관리에서 "이 데이터가 같은 건지 다른 건지"를 판단하는 로직이 자연스럽게 정리된다. 리스트에서 아이템을 갱신할 때 Entity라면 ID로 찾아서 교체하고, Value Object라면 불변 교체(immutable replace)를 하는 식이다.</p>
<h2 id="도메인-오브젝트-모델domain-object-model"><a class="anchor" href="#도메인-오브젝트-모델domain-object-model">도메인 오브젝트 모델(Domain Object Model)</a></h2>
<p>"도메인 모델"과 "도메인 오브젝트"는 알겠는데, <strong>도메인 오브젝트 모델</strong>은 무엇일까?</p>
<p>찾아보니 의외로 합의된 정의가 없다. 상당수의 문헌에서는 "도메인 모델", "도메인 오브젝트 모델", "개념 모델(conceptual model)", "분석 오브젝트 모델(analysis object model)"을 <strong>사실상 동의어</strong>로 본다. 객체지향 분석 단계에서 그려지는 개념적 모델을 부르는 여러 이름이라는 입장이다.</p>
<p>반면 조금 더 분리된 계층으로 보는 시각도 있다. <strong>도메인 모델이 실제 코드로 변환되는 지점이 바로 객체 모델</strong>이라는 설명이 대표적이다.</p>
<p>이 두 번째 관점에서 <strong>오브젝트 모델</strong>은 시스템 안의 <strong>모든 코드 객체의 구조</strong>다. 여기에는 <code>HttpClient</code>나 <code>useDebounce</code> 같은 기술적 도구도 포함된다. 그 안에서 <strong>도메인 개념을 표현하는 객체들의 부분집합, 그리고 그들 사이의 관계</strong>가 바로 <strong>도메인 오브젝트 모델</strong>이다. "Object Model"을 시스템의 정적 구조(클래스, 속성, 연산, 관계)로 정의해온 객체지향 모델링의 전통과도 맞닿아 있다.</p>
<p>필자는 이쪽 관점이 프론트엔드 개발자에게 더 실용적이라고 본다. 우리가 실제 작성하는 코드에는 도메인 객체와 기술 객체가 늘 섞여 있기 때문이다.</p>
<p>결국 <strong>도메인 → 도메인 모델 → 도메인 오브젝트 모델 → 도메인 오브젝트</strong> 는 추상에서 구체로 내려가는 계층이다. 도메인이 가장 넓고, 도메인 오브젝트가 가장 구체적이다. 그래서 프론트엔드 코드를 작성할 때 우리가 실질적으로 고민하는 영역은 결국 <strong>도메인 오브젝트 모델(도메인 개념을 표현하는 타입과 그 사이의 관계)을 어떻게 구조화할 것인가</strong> 다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, <strong>도메인</strong>은 우리가 해결하려는 문제 영역이고, <strong>도메인 모델</strong>은 그 문제를 선택적으로 추상화한 개념 체계이며, <strong>도메인 오브젝트 모델</strong>은 필자가 택한 관점에서 그 개념 체계를 코드로 구현한 것이고, <strong>도메인 오브젝트</strong>는 그 구현 안의 개별 객체이다.</p>
<p>이 구분이 코드에서 어떤 차이를 만드는지, 즉 세금 계산 같은 도메인 로직을 컴포넌트 밖 어디에 두고 어디까지 나눌지는 <a href="/260418" target="_blank" rel="noopener noreferrer">도메인 모델</a>에서 이어서 다룬다.</p>
<p>이 글을 읽는 독자 분들도 다음에 API 응답 타입을 보면 "이건 데이터 모델인가, 도메인 모델인가"를 한 번쯤 물어보시길 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design (Book)</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>아키텍처</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Toss Frontend Fundamentals 모의고사 2회차 리팩토링 후기]]></title>
            <link>https://hooninedev.com/260328</link>
            <guid isPermaLink="false">https://hooninedev.com/260328</guid>
            <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 Toss Frontend Fundamentals 모의고사 2회차에 참여하며 진행한 리팩토링 경험에 대한 이야기를 해보려고 한다. 평소 코드 리뷰나 리팩토링에 관심이 있었던 필자는, 토스에서 공개한 Frontend Fundamentals 모의고사라는 흥미로운 형식의 과제를 진행하게되었다. 과제는 회의실 예약 앱이 주어지고, 이를 리팩토링하는...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 Toss Frontend Fundamentals 모의고사 2회차에 참여하며 진행한 리팩토링 경험에 대한 이야기를 해보려고 한다.</p>
<p>평소 코드 리뷰나 리팩토링에 관심이 있었던 필자는, 토스에서 공개한 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>그다음으로 실제 코드를 살펴보았는데, 눈에 들어온 것은 두 개의 모놀리식 컴포넌트였다.</p>
<ul>
<li><code>ReservationStatusPage</code> 는 385줄의 컴포넌트로 날짜 선택, 타임라인 시각화, 예약 상세 툴팁, 내 예약 목록, 취소 기능이 하나의 파일에 전부 들어가 있었다.</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> 함수였다. 두 페이지에서 동일한 함수가 각각 인라인으로 정의되어 있었기 때문이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/formatYYYYMMDD.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> year</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getFullYear</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> month</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMonth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">year</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">month</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">date</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>작은 변경이지만, 리팩토링의 첫 커밋으로서 중요한 의미가 있었다. 가장 독립적이고 사이드이펙트가 적은 부분부터 건드려서, 테스트가 여전히 통과하는지 확인하는 <strong>워밍업</strong> 같은 것이다.</p>
<h3 id="react-query-훅-분리"><a class="anchor" href="#react-query-훅-분리">React Query 훅 분리</a></h3>
<p>다음으로 컴포넌트 내부에 직접 작성되어 있던 <code>useQuery</code>, <code>useMutation</code> 호출을 별도의 파일로 분리했다. <code>queryOptions</code> 패턴을 활용하여 쿼리 설정을 재사용 가능한 단위로 만들었다.</p>
<p>이 과정에서 <code>remotes.ts</code>에 있던 API 응답 타입도 명시적으로 정의했다. 기존에는 <code>any</code>로 흘러가던 타입들이 <code>GetRoomsResponse</code>, <code>GetReservationsResponse</code> 등으로 명확해졌다.</p>
<p>인프라 레이어를 정리했으니, 이제 도메인 모델로 눈을 돌려보자.</p>
<h2 id="도메인-모델-분리"><a class="anchor" href="#도메인-모델-분리">도메인 모델 분리</a></h2>
<p>리팩토링에서 가장 중요한 전환점은 <strong>도메인 모델을 별도의 <code>models/</code> 디렉토리로 분리</strong>한 것이었다.</p>
<p>기존 코드에서는 <code>EQUIPMENT_LABELS</code>, <code>TIME_SLOTS</code> 같은 비즈니스 상수가 컴포넌트 파일 상단에 선언되어 있었다. <code>Room</code>이나 <code>Reservation</code>의 타입도 서버 핸들러(<code>_tosslib/server/types.ts</code>)에만 존재하고, 클라이언트 코드에서는 <code>any</code>에 가까운 상태로 사용되고 있었다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/equipment.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> typeof</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ALL_EQUIPMENT</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/reservation.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  capacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  roomId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>도메인 모델 분리가 왜 중요한가? 비즈니스 로직이 UI 컴포넌트에 종속되어 있으면, 해당 로직을 변경할 때 컴포넌트의 렌더링 로직까지 함께 들여다봐야 한다. 반면 <code>models/</code> 디렉토리에 독립적으로 존재하면, 비즈니스 규칙의 변경이 UI와 분리된 채로 이루어질 수 있다. 물론 완벽한 분리란 현실적으로 어렵지만, 최소한 <strong>"이 로직은 여기 있을 것이다"라고 예측할 수 있는 구조</strong>를 만드는 것이 핵심이다.</p>
<p>도메인 모델을 분리했다면, 이제 UI는 얼마나 가벼워질 수 있을까?</p>
<h2 id="컴포넌트-해체-작업"><a class="anchor" href="#컴포넌트-해체-작업">컴포넌트 해체 작업</a></h2>
<h3 id="reservationstatuspage"><a class="anchor" href="#reservationstatuspage">ReservationStatusPage</a></h3>
<p>이 작업이 가장 극적인 변화를 만들어낸 커밋이자 시간을 많이 소요했다. 385줄의 모놀리식 컴포넌트를 다음과 같이 분리했다.</p>
<pre><code>ReservationStatusPage/
├── index.tsx                    # 페이지 레벨
└── components/
    ├── DateSelector.tsx         # 날짜 선택 UI
    ├── ReservationTimeline.tsx  # 타임라인
    └── MyReservation.tsx        # 내 예약 목록 + 취소
</code></pre>
<p>분리 기준은 <strong>"이 코드가 독립적으로 의미를 가지는가"</strong> 였다. 타임라인 시각화는 날짜에 따른 예약 데이터를 받아 그리드를 그리는 독립적인 관심사다. 내 예약 목록은 사용자의 예약 데이터를 조회하고 취소하는 독립적인 관심사다. 이들이 한 파일에 있을 이유는 없었다.</p>
<p>분리 후 <code>index.tsx</code>는 <strong>조율자(orchestrator)</strong> 역할만 하게 되었다. 상태 관리, 메시지 표시, 하위 컴포넌트 조합을 담당할 뿐, 실제 데이터 페칭이나 렌더링 디테일은 하위 컴포넌트에 위임했다.</p>
<h3 id="roombookingpage"><a class="anchor" href="#roombookingpage">RoomBookingPage</a></h3>
<p>예약 페이지도 동일한 원칙으로 분리했다.</p>
<pre><code>RoomBookingPage/
├── index.tsx                    # 페이지 레벨
├── components/
│   ├── BookingFilter.tsx        # 날짜, 시간, 인원, 장비, 층 UI
│   └── AvailableRoomList.tsx    # 예약 가능 방 목록
└── hooks/
    └── useBookingParams.ts      # URL searchParams 기반 상태 관리
</code></pre>
<p>이 과정에서 한 가지 흥미로운 선택이 있었다. 초기에는 <code>react-hook-form</code> + <code>zod</code>를 도입해서 폼 유효성 검증을 시도했다. 그런데 최종적으로는 이를 제거하고 커스텀 훅 <code>useBookingParams</code>로 대체했다. 이 결정에 대해서는 뒤에서 더 자세히 이야기하겠다.</p>
<p>여기까지 읽으면 자연스럽게 떠오르는 질문이 하나 있다. 과연 어디까지 추상화해야 할까?</p>
<h2 id="추상화의-적정선"><a class="anchor" href="#추상화의-적정선">추상화의 적정선</a></h2>
<p>이 섹션이 필자가 이번 모의고사에서 가장 많이 고민한 부분이다.</p>
<h3 id="중첩-조건문-어디까지-풀어야-할까"><a class="anchor" href="#중첩-조건문-어디까지-풀어야-할까">중첩 조건문, 어디까지 풀어야 할까</a></h3>
<p>방 예약 가능 여부를 판단하는 로직에는 여러 조건이 결합되어 있다. 수용 인원이 충분한지, 필요한 장비가 있는지, 선호 층수가 맞는지, 시간이 겹치지 않는지. 원본 코드에서는 이 모든 조건이 하나의 <code>filter</code> 콜백 안에 인라인으로 작성되어 있었다.</p>
<p>필자는 이를 <code>models/roomFilter.ts</code>로 추출하면서, 각 조건을 <strong>이름이 있는 함수</strong>로 분리했다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isEnoughCapacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.capacity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> attendees;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasRequiredEquipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">every</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">eq</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">includes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(eq));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isOnPreferredFloor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasNoTimeConflict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">reservations.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">some</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.roomId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.start </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> filterAvailableRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> rooms</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isEnoughCapacity</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.attendees) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasRequiredEquipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.equipment) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isOnPreferredFloor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasNoTimeConflict</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, reservations, params.date, params.startTime, params.endTime)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.name.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">localeCompare</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.name);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>여기서 핵심은 <strong>추상화할 수 있는 이름이 명확한 경우에만 함수로 분리</strong>했다는 점이다. <code>isEnoughCapacity</code>, <code>hasRequiredEquipment</code> 같은 이름은 구현을 보지 않아도 무엇을 하는지 예측할 수 있다. 만약 이름이 <code>processRoomConditions</code>처럼 모호해질 수밖에 없다면, 그 추상화는 오히려 읽는 사람에게 인지 부하를 줄 수 있다.</p>
<p>물론 이것이 정답이라는 의미는 아니다. 다만 필자의 판단 기준은 <strong>"이 함수 이름만 보고 동작을 예측할 수 있는가?"</strong> 였다. 예측 가능하다면 추상화하고, 그렇지 않다면 인라인으로 두는 것이 오히려 가독성에 도움이 된다고 생각했다.</p>
<h3 id="searchparams-vs-form-state"><a class="anchor" href="#searchparams-vs-form-state">searchParams vs form state</a></h3>
<p>예약 필터 상태를 어디에 둘 것인가도 상당히 고민한 부분이었다. 원본 코드에서는 <code>useState</code>로 각 필터 값을 관리하면서, <code>useEffect</code>로 URL searchParams와 동기화하고 있었다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 원본: useState + useEffect 동기화 방식</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">startTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setStartTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ... 6개의 개별 상태</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {};</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (date) params.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 모든 상태를 searchParams에 동기화</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [date, startTime, endTime, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>필자는 처음에 <code>react-hook-form</code> + <code>zod</code>를 도입해서 폼으로 관리하는 방식을 시도했다. 하지만 결국 이를 제거하고 <strong>searchParams를 단일 진실 공급원(Single Source of Truth)으로</strong> 사용하는 <code>useBookingParams</code> 훅으로 대체했다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useBookingParams: searchParams가 곧 상태</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">searchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    date: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    startTime: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [searchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> updateParam</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> extends</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prev</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 기존 파라미터 병합 후 업데이트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [setSearchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { params, updateParam };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이 결정의 핵심 근거는 <strong>"상태가 별개로 움직이는 것이 적합하지 않다"</strong> 는 판단이었다. <code>useState</code>와 <code>searchParams</code>가 각각 상태를 가지면, 동기화 시점에 따라 불일치가 발생할 수 있다. 반면 searchParams만을 상태로 사용하면, URL이 곧 애플리케이션 상태가 되어 동기화 문제 자체가 사라진다. 사용자가 URL을 공유하면 동일한 필터 상태가 재현되는 것은 덤이다.</p>
<p>다른 참가자의 후기에서도 비슷한 고민을 발견할 수 있었다. <strong>"URL searchParams를 단일 진실 공급원으로 통일했다", "개별 필터 props를 하나의 <code>filter</code> 객체로 통합하는 접근을 택했다."</strong> 표현 방식은 다르지만, <strong>"흩어진 상태를 하나의 개념으로 묶어야 한다"</strong> 는 문제 인식은 동일했다.</p>
<h2 id="안정성"><a class="anchor" href="#안정성">안정성</a></h2>
<h3 id="suspense와-errorboundary"><a class="anchor" href="#suspense와-errorboundary">Suspense와 ErrorBoundary</a></h3>
<p>컴포넌트 구조가 확정된 후에 에러/로딩 처리를 추가했다. 순서가 중요한 이유는, 경계(Boundary)를 어디에 설정할지는 컴포넌트 트리가 결정된 후에야 판단할 수 있기 때문이다.</p>
<p><code>react-error-boundary</code> 라이브러리를 활용하여, 각 독립적인 데이터 페칭 단위마다 <code>ErrorBoundary</code>와 <code>Suspense</code>를 감쌌다. 타임라인이 실패해도 내 예약 목록은 정상적으로 보여야 하고, 그 반대도 마찬가지이기 때문이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 각 영역이 독립적으로 에러/로딩을 처리 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[date]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"예약 현황을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ReservationTimeline</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{date} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"내 예약을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MyReservation</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleCancel} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="query-key-중앙-관리"><a class="anchor" href="#query-key-중앙-관리">Query Key 중앙 관리</a></h3>
<p>리팩토링 과정에서 쿼리 훅을 분리하다 보니, query key가 여러 파일에 분산되는 문제가 생겼다. mutation의 <code>onSuccess</code>에서 invalidation할 때 어떤 key를 써야 하는지 추적하기 어려워진 것이다.</p>
<p><code>@lukemorales/query-key-factory</code>를 도입하여 쿼리 키를 중앙에서 관리하도록 변경했다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queries/queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> roomKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  list: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> reservationKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ queryKey: [date], </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date) }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  my: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMyReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>이렇게 하면 <code>useSuspenseQueries({ queries: [roomKeys.list, reservationKeys.list(date)] })</code> 형태로 사용할 수 있어, 쿼리 키와 페칭 함수가 항상 함께 이동한다. 또한 route 경로도 <code>PATHS</code> 상수로 추출하여, 문자열 하드코딩을 제거했다.</p>
<h2 id="출제자의-의도는-무엇이었을까"><a class="anchor" href="#출제자의-의도는-무엇이었을까">출제자의 의도는 무엇이었을까</a></h2>
<p>리팩토링을 마치고 나서, 필자는 한 발 물러서서 생각해보았다. 이 모의고사가 평가하고자 한 것은 무엇이었을까?</p>
<p>다른 참가자들의 후기를 읽으며 흥미로운 공통점을 발견했다. 거의 모든 후기에서 <strong>"코드는 읽는 것이 아니라 예측하는 것이다"</strong> 라는 문장이 등장했다. 우리의 뇌는 코드를 한 줄 한 줄 해석하는 것이 아니라, 경험에서 쌓인 패턴을 기반으로 예측하며 읽는다는 것이다. 그리고 그 예측이 어긋날 때 인지 부하가 급격히 증가한다.</p>
<p>이 관점에서 보면, 모의고사가 평가하는 것은 단순한 코딩 능력이 아니라 <strong>"동료가 읽을 코드를 얼마나 예측 가능하게 만들 수 있는가"</strong> 라는 협업 역량인 것이다. (출제자와 동료의 마음을 읽는 것이야말로 진정한 소프트웨어 엔지니어의 역량일지도 모른다)</p>
<p>다른 참가자들의 후기들을 살펴보니, <strong>"남이 작성한 코드를 이해하는 것은 쉽지 않다", "인터페이스를 먼저 설계하는 것이 중요하지만, 방대한 기존 코드 앞에서는 그 접근이 흔들릴 수 있다"</strong> 는 내용이 공감이 되었다. 필자 역시 비슷한 경험을 했다. 기존 코드가 이미 동작하고 있으면, 그 구조를 합리화하려는 유혹이 생긴다. "이미 돌아가는 코드인데 굳이?"라는 생각 말이다. 하지만 모의고사의 핵심은 바로 그 유혹을 넘어서, <strong>"이 코드를 내가 아닌 다른 누군가가 읽었을 때 얼마나 빨리 파악할 수 있는가, 본인의 인지로 문제를 판단하고 해결해나갈 수 있는지"</strong> 를 기준으로 판단하는 것이었다.</p>
<h2 id="리팩토링에서-배운-것"><a class="anchor" href="#리팩토링에서-배운-것">리팩토링에서 배운 것</a></h2>
<p><strong>리팩토링 순서가 결과를 좌우한다.</strong> 바깥(인프라)에서 안쪽(UI)으로 진행하는 것이 중간에 꼬이지 않는 안전한 경로였다. 유틸과 도메인 모델이 정리된 상태에서 컴포넌트를 분리하니, 각 컴포넌트가 무엇에 의존하는지가 명확했다.</p>
<p><strong>추상화의 판단 기준은 "이름"이다.</strong> 함수나 변수로 추출했을 때 그 이름이 동작을 설명할 수 있다면 추상화할 가치가 있다. 이름이 모호해질 수밖에 없다면, 인라인이 오히려 나은 선택일 수 있다.</p>
<p><strong>상태의 위치가 곧 아키텍처다.</strong> 같이 이동해야 하는 상태는 같은 곳에 두어야 한다. <code>useState</code>와 <code>searchParams</code>를 동기화하는 것보다, searchParams 하나만을 진실 공급원으로 사용하는 것이 구조적으로 더 건강하다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>과제를 마치고 2명의 동료와 이야기를 나눠보았다. 혼자 코드를 들여다볼 때는 보이지 않던 것들이, 대화를 통해 생각을 풀어나가는 과정에서 드러나기 시작했다. 필자가 당연하다고 넘겼던 구조적 선택에 "왜 그렇게 했어?"라는 질문이 들어오는 순간, 미처 의식하지 못했던 판단의 빈틈이 보이는 것이다.</p>
<p>AI가 코드 작성과 리뷰에 드는 시간을 획기적으로 줄여주고 있는 것은 사실이다. 하지만 그럼에도 코드 리뷰와 데일리 미팅이 여전히 중요하다고 생각하는 이유가 바로 이런 경험에 있다. AI는 코드의 정합성을 검증해줄 수 있지만, <strong>"네가 놓친 관점은 이거야"</strong> 라고 짚어주는 것은 결국 같은 맥락을 공유하는 동료의 몫이다. 내가 보지 못한 부분에 대한 발견, 그리고 그 발견을 통한 프로덕트의 안정화. 이것이 협업의 본질이 아닐까.</p>
<p>문제를 풀어나가는 과정속에 코드를 작성에는 정답은 없다. 같은 모의고사를 풀었던 다른 참가자들도 각자 다른 경로를 택했고, 각자의 근거를 가지고 있었다. 중요한 것은 <strong>"왜 이렇게 짰는가"에 대해 설명할 수 있는 것</strong>이다. 이 글을 읽는 분들도 한 번쯤 자신의 코드를 처음 보는 사람의 시선으로 바라보기를 권한다. 그 시선 하나가 코드의 품질을 결정하는 가장 강력한 기준이 될 수 있을 것이다.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>리팩토링</category>
        </item>
        <item>
            <title><![CDATA[AI 프론트엔드 엔지니어]]></title>
            <link>https://hooninedev.com/260302</link>
            <guid isPermaLink="false">https://hooninedev.com/260302</guid>
            <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 AI와 엔지니어가 어떻게 성장하고 살아남을 수 있을까에 대한 이야기를 개인의 관점에서 해보려고 한다. 필자가 주니어 시절 가장 인상 깊게 읽었던 글 중 하나로 배휘동 님의 '프론트엔드 엔지니어 커리어 로드맵, 주니어를 위한 3가지 전문성 트랙'이 있다. 프론트엔드 엔지니어의 커리어를 웹 특화(Software Engineer) / 제품 특화...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>AI와 엔지니어가 어떻게 성장하고 살아남을 수 있을까</strong>에 대한 이야기를 개인의 관점에서 해보려고 한다.</p>
<p>필자가 주니어 시절 가장 인상 깊게 읽었던 글 중 하나로 <a href="https://kr.linkedin.com/posts/hwidongbae_%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%BB%A4%EB%A6%AC%EC%96%B4-%EB%A1%9C%EB%93%9C%EB%A7%B5-%EC%A3%BC%EB%8B%88%EC%96%B4%EB%A5%BC-%EC%9C%84%ED%95%9C-3%EA%B0%80%EC%A7%80-%EC%A0%84%EB%AC%B8%EC%84%B1-%ED%8A%B8%EB%9E%99-activity-7013888624140189696-XiIz" target="_blank" rel="noopener noreferrer">배휘동 님의 '프론트엔드 엔지니어 커리어 로드맵, 주니어를 위한 3가지 전문성 트랙'</a>이 있다. 프론트엔드 엔지니어의 커리어를 <strong>웹 특화(Software Engineer) / 제품 특화(Product Engineer) / 운영 특화(Full-Stack Engineer)</strong> 세 가지 트랙으로 나누어 정리하고, '탁월한 엔지니어의 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>는 자신의 트위터에 이런 문장을 남겼다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>제가 'vibe coding'이라고 부르는 새로운 종류의 코딩이 있다. 바이브에 완전히 몸을 맡기고, 지수적 성장을 받아들이며, 코드 자체가 존재한다는 것조차 잊는 방식이다.</p></div><div class="quote-original" lang="en"><p>There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists.</p></div></blockquote>
<p><strong>바이브 코딩(Vibe Coding)</strong> 이란 한마디로 "AI에게 키보드를 넘기고 원하는 걸 자연어로 묘사하기만 하는 코딩 방식" 이다. 아키텍처 문서도, 보일러플레이트도, 세미콜론 검색도 없다. 그저 바이브로 코드가 굴러간다. 이 용어는 1년도 안 되어 영어권 개발자 커뮤니티 표준 어휘로 자리잡았다.</p>
<p>그런데 정확히 1년 후인 2026년 2월, 같은 Karpathy가 <a href="https://thenewstack.io/vibe-coding-is-passe/" target="_blank" rel="noopener noreferrer">한 발 물러섰다</a>. 그는 vibe coding이라는 단어를 <strong>"agentic engineering"</strong> 으로 대체하자고 제안한다. 둘의 차이는 분명하다.</p>
<ul>
<li><strong>Vibe coding</strong>: 원하는 걸 묘사하고 결과물을 받아들이는 것</li>
<li><strong>Agentic engineering</strong>: 시스템을 설계하고, 제약을 명세하고, 이미 머릿속에서 추론을 마친 구현을 AI로 가속화하는 것</li>
</ul>
<p>1년 전엔 "그냥 시키면 다 만들어줍니다"가 기본 베이스였다면, 지금은 "AI에게 무엇을 어떻게 시킬지를 설계하는 능력" 자체가 엔지니어링 역량으로 자리잡고 있다. 이 흐름은 단순히 한 사람의 트윗이 아니다. 같은 시기 구글의 엔지니어 <a href="https://addyosmani.com/" target="_blank" rel="noopener noreferrer">Addy Osmani</a>는 <a href="https://www.amazon.com/Beyond-Vibe-Coding-AI-Era-Developer/dp/B0F6S5425Y" target="_blank" rel="noopener noreferrer">Beyond Vibe Coding: From Coder to AI-Era Developer</a>라는 책을 출간하며 "AI는 비서일 뿐 자율적으로 신뢰할 수 있는 코더가 아니다. 당신이 시니어 개발자고, LLM은 당신의 판단을 가속화하기 위해 존재한다" 라고 못박았다.</p>
<h3 id="도구는-폭주하고-있다"><a class="anchor" href="#도구는-폭주하고-있다">도구는 폭주하고 있다</a></h3>
<p>도구 진영도 이 흐름에 맞춰 빠르게 진화 중이다. 2026년 3월 기준 가장 많이 언급되는 코딩 도구는 Cursor, Claude Code, GitHub Copilot, Windsurf, v0 by Vercel, Bolt.new, Devin이 정도다.</p>
<p>특히 v0의 변화가 상징적이다. Vercel은 <a href="https://venturebeat.com/infrastructure/vercel-rebuilt-v0-to-tackle-the-90-problem-connecting-ai-generated-code-to" target="_blank" rel="noopener noreferrer">"90% problem"</a>이라는 표현을 쓰는데, 현실 개발의 90%는 기존 코드베이스와 기존 인프라 안에서 일어난다 는 뜻이다. 처음엔 그린필드 프로토타입만 잘 만들면 됐던 v0가, 이제는 GitHub 저장소를 직접 가져와 작업하고, 디자인 시스템을 강제하며, 배포 환경 변수를 자동으로 끌어다 쓴다. "AI는 장난감 같은 데모만 잘 만들지 않냐" 라는 시니어들의 반론에 도구 진영이 직접 답하고 있는 셈이다.</p>
<p>빅테크의 코드베이스가 이 변화를 가장 잘 보여준다.</p>
<p>Google Sundar Pichai가  <a href="https://fortune.com/2024/10/30/googles-code-ai-sundar-pichai/" target="_blank" rel="noopener noreferrer">2024년 10월 Q3 어닝콜에서 "새로운 코드의 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">토스</a>는 개발자가 더 이상 문서를 찾지 않고 DX를 높이기 위해 AI 기반 문서 시스템을 구축했고, 한 발 더 나가 <a href="https://toss.tech/article/removing_designers_in_ai_era" target="_blank" rel="noopener noreferrer">"AI 시대, 디자이너를 없앴더니 생긴 일"</a> 같은 주제를 다루기도 했다. 당근은 매주 화요일 <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>을 내걸기 시작했다. 우아한형제들은 <a href="https://techblog.woowahan.com/22828/" target="_blank" rel="noopener noreferrer">"AI가 코드 짜는 시대, 그래도 개발자가 되시겠습니까?"</a> 같은 글을 통해 "개발자의 본질은 코드가 아니라 문제를 정의하고 해결하는 능력에 있다" 라는 메시지를 내놓고 있다.</p>
<h3 id="그런데-숫자는-좀-다른-이야기를-한다"><a class="anchor" href="#그런데-숫자는-좀-다른-이야기를-한다">그런데 숫자는 좀 다른 이야기를 한다</a></h3>
<p>여기까지만 보면 "이제 그냥 시키면 다 되는구나" 라는 결론으로 향하기 쉽다. 그런데 실제 데이터를 들여다보면 이야기가 조금 다르다.</p>
<p>소프트웨어 개발 현황을 종합적으로 분석한 <a href="https://survey.stackoverflow.co/2025/ai" target="_blank" rel="noopener noreferrer"><strong>2025 Stack Overflow Developer Survey</strong></a>의 숫자를 먼저 보자.</p>
<ul>
<li>84%의 개발자가 AI 도구를 사용하거나 사용 예정이라고 답했다. (2024년 76% 대비 상승)</li>
<li>전문 개발자 중 51%가 매일 AI 도구를 쓴다.</li>
<li>그런데 <strong>AI 도구에 대한 호감도(positive sentiment)는 오히려 떨어졌다</strong>. 2023, 2024년 70% 이상이었던 호감도가 2025년에는 60%까지 내려왔다.</li>
<li>10년 이상 경력 시니어 개발자들이 AI 출력물에 대한 신뢰도가 가장 낮다.</li>
</ul>
<p>요약하면 <strong>"쓰긴 다 쓰는데, 시간이 갈수록 더 못 미덥다"</strong> 는 거다.</p>
<p><a href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener noreferrer"><strong>METR</strong></a>이라는 비영리 연구소에서 2025년에 진행한 실험은 이 인식과 현실의 간극을 더 극적으로 보여준다. 평균 5년 경력 + 평균 1,500커밋의 숙련된 오픈소스 개발자 16명에게 246개 작업을 시키고, 무작위로 AI 사용 여부를 배정한 통제 실험이었다. 결과는 다음과 같다.</p>
<ul>
<li>시작 전 개발자들은 "AI를 쓰면 24% 빨라질 것" 이라고 예측했다.</li>
<li>작업을 마친 직후에도 "20% 정도 빨라진 것 같다" 고 자평했다.</li>
<li>그런데 실제 측정값은 <strong>19% 더 느려졌다</strong>.</li>
</ul>
<p>연구진이 짚은 원인은 흥미롭다. AI가 생성한 코드의 수용률이 44% 이하 였고, 거절된 코드도 검토와 테스트엔 시간이 들었으며, 수용된 코드조차 리뷰와 수정에 상당한 시간 이 들었다는 것이다. 느려졌는데 빨라진 것 같은 착각, 이 간극이 시니어 개발자들이 AI를 점점 더 회의적으로 보는 이유 중 하나다.</p>
<p>여기에 "AI가 짜는 코드의 품질" 자체도 매끈하지 않다. <a href="https://www.veracode.com/blog/genai-code-security-report/" target="_blank" rel="noopener noreferrer"><strong>Veracode</strong></a>가 100개 이상의 AI 모델에게 코드를 짜게 시킨 실험을 보자.</p>
<ul>
<li>AI 생성 코드의 <strong>45%가 OWASP Top 10 보안 취약점</strong>을 포함했다.</li>
<li><strong>XSS(크로스 사이트 스크립팅) 방어에 실패한 비율은 86%</strong>.</li>
<li>로그 인젝션(Log Injection) 방어 실패율은 88%.</li>
<li>다른 연구에서는 AI 코드의 취약점 밀도가 인간 코드 대비 <strong>2.7배 높다</strong>고 보고됐다.</li>
</ul>
<p>특히 프론트엔드와 직결된 XSS 86% 실패는 좀 더 무겁게 받아들일 만하다. AI가 짜준 form input을 그대로 머지하는 행위 가 어떤 의미인지, 이 숫자가 잘 보여준다. (프론트엔드 보안감사에 대한 경험이 있는 개발자라면 <code>dangerouslySetInnerHTML</code>을 본인이 직접 작성할때도 어색함을 느끼고 걱정되는데, AI가 슬쩍 끼워넣은 건 더 무서운 것 같다.)</p>
<p>품질 면에서도 신호는 비슷하다. <a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" target="_blank" rel="noopener noreferrer"><strong>GitClear</strong></a>가 2020~2024년 사이 2억 1,100만 줄의 코드 변경을 분석한 결과는 이렇다.</p>
<ul>
<li>작성 후 2주 안에 되돌려진(reverted) 코드 비율(Code Churn): 2020년 5.5% → 2024년 <strong>7.9%</strong></li>
<li>리팩토링이 차지하는 비율: 2021년 25% → 2024년 <strong>10% 미만</strong></li>
<li>복사·붙여넣기(클론) 비율: 2021년 8.3% → 2024년 <strong>12.3%</strong> (2025년에는 무려 4배 증가)</li>
</ul>
<p>해석은 그리 어렵지 않다. 코드를 빠르게 찍어내는 능력은 늘었는데, 다시 손볼 만한 코드를 짜는 능력은 줄었다 는 뜻이다. Fortune 50 기업들을 분석한 <a href="https://www.softwareseni.com/ai-generated-code-security-risks-why-vulnerabilities-increase-2-74x-and-how-to-prevent-them/" target="_blank" rel="noopener noreferrer">Apiiro의 데이터</a>는 더 강하게 나온다. AI 보조 개발자들은 동료 대비 커밋은 3~4배 찍어내지만, 보안 finding은 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>같은 맥락에서, 토스의 <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> 을 꼽았다. 다섯 가지 모두 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>여기서 자연스럽게 떠오르는 질문이 하나 있다. 그러면 배휘동 님 글의 세 가지 트랙(웹 특화/제품 특화/운영 특화)은 이제 의미가 없는 걸까?</p>
<p>필자의 생각은 다르다. 트랙 자체는 여전히 유효하다. 다만 각 트랙이 AI 시대에 맞춰 한 단계씩 진화 했다고 보는 게 맞다. 트랙별로 어떻게 풍경이 바뀌었는지 정리해보자.</p>
<h2 id="생산자에서-검증자로"><a class="anchor" href="#생산자에서-검증자로">생산자에서 '검증자'로</a></h2>
<p>배휘동 님의 원글에서 웹 특화 트랙은 <strong>Software Engineer</strong> 라는 이름으로 묶여 있었다. "인터넷·웹브라우저·HTML/CSS/JS에 대한 깊이 있는 이해와 활용", 웹 생태계 도구의 장단점 파악과 트러블슈팅 경험, 새로운 기술에 민감한 태도가 핵심이고, 시니어로 가는 길로는 <strong>웹 생태계 도구 개발사 엔지니어 / 프론트엔드 교육자 / 복잡한 제품 조직의 테크 리드</strong> 가 제시됐다. 한마디로 "브라우저와 HTML/CSS/JS의 동작 원리를 깊이 파는 사람들" 이고, 1~2년 전까지 이들의 가장 큰 무기는 "그 누구보다 정확하게 코드를 짤 수 있다" 였다.</p>
<p>AI 시대에 이들의 가치는 어떻게 바뀌었을까? 코드 짜는 속도 만 놓고 보면 AI가 따라잡았다. 그러나 <strong>'AI가 짜준 코드를 정확히 평가할 수 있는 능력'</strong> 은 오히려 이들이 거의 독점하다시피 한다.</p>
<ul>
<li>AI 활용하는 일반인 : 내가 원하는 요구사항으로 구현이 되었고, 동작이 정상적이네.</li>
<li>AI 활용하는 개발자 : 동작은 하지만 이 의존성으로 어떤 문제를 발생할 수 있고, 이런 패턴은 이렇게 개선해보는게 컨밴션에 적합해. 연관된 부분 다시 살펴보자.</li>
</ul>
<p>앞서 다룬 Veracode 연구에서 XSS 86%, 로그 인젝션 88% 실패라는 숫자가 있었다. 이 숫자를 발견하고 잡아낼 수 있는 사람 이 바로 우리와 같은 전문가다. 이들은 AI 생산물의 품질 관리(QA) 시니어 역할로 자연스럽게 진화한다.</p>
<p>또 한 가지, 전문가들의 영역에 완전히 새로운 토픽 이 추가됐다. <strong>생성형 UI(Generative UI)</strong> 와 <strong>AI 인터페이스 디자인</strong> 이다. LLM 응답을 스트리밍으로 그려주는 채팅 UI, 도중에 멈출 수 있는 abort 컨트롤, 마크다운/코드 블록 점진적 렌더링, 도구 호출 결과를 inline으로 표시하는 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>를 활용한 어시스턴트 통합 등이 그 예다. 이 영역에서 "웹의 동작 원리를 정확히 알면서 동시에 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의 핵심 자질로 세 가지를 꼽는다.</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">디자인 엔지니어를 연봉 $200K+의 정식 트랙</a>으로 채용 중이고, Linear, Stripe도 비슷한 방향으로 움직인다. 프론트엔드와 디자인 사이의 핸드오프 자체를 없애는 직군이다. AI가 그림은 빠르게 그려주니, "무엇을 그릴지 + 그려진 결과가 일관된 디자인 시스템에 맞는지" 를 동시에 다루는 역량이 더 희소해진 결과다.</p>
<h2 id="ai-오케스트레이터"><a class="anchor" href="#ai-오케스트레이터">AI 오케스트레이터</a></h2>
<p>운영 특화 트랙은 가장 극적으로 변하고 있는 트랙이다. 배휘동 님의 원글에서는 이 트랙을 <strong>Full-Stack Engineer</strong> 로 분류하며 "프로젝트 구조·통합·테스트·배포에 높은 관심을 가지고, 간단한 API와 인프라를 직접 다루며 조직의 빈틈을 메우고 프로세스를 개선하는 사람" 으로 정의했다. 그 위에 1~2년 사이 <strong>AI 에이전트 자체를 운영하는 역할</strong> 이 새로 얹히면서, 트랙의 외연이 빠르게 확장되고 있다.</p>
<p><a href="https://beyond.addy.ie/2026-trends/" target="_blank" rel="noopener noreferrer">2026년 트렌드</a>를 정리하면서 <strong>"오케스트레이팅 코딩 에이전트(Orchestrating Coding Agents)"</strong> 라는 개념을 핵심으로 꼽았다. 이는 하나의 AI에게 시키는 것 을 넘어 여러 AI 에이전트를 동시에 협업시키는 시스템 을 설계하고 운영한다는 의미다. 같은 맥락에서 그는 <a href="https://github.com/addyosmani/agent-skills" target="_blank" rel="noopener noreferrer">"agent-skills"</a>라는 프레임워크를 제안하며, 프로페셔널 워크플로우와 품질 게이트, 산업 베스트 프랙티스를 에이전트의 동작 로직에 직접 인코딩하자는 의견도 나오고있다.</p>
<p>관련된 자료들을 취합해보니 필자가 생각하는 운영 트랙 엔지니어가 새로 다뤄야 할 키워드를 나열해보면 이렇다.</p>
<ul>
<li><strong>MCP(Model Context Protocol)</strong>: Anthropic이 제안한 LLM과 외부 도구 연결 표준</li>
<li><strong>AI 거버넌스</strong>: 누가 어떤 컨텍스트로 AI를 쓸 수 있는지, 시크릿이 새지 않는지 관리</li>
<li><strong>에이전트 평가(Evaluation)</strong>: 에이전트가 만들어낸 결과물을 자동으로 채점하는 파이프라인</li>
<li><strong>AI 게이트</strong>: PR 머지 전 자동 보안/품질 검증, AI 코드 라벨링</li>
</ul>
<p>원글에서는 운영 트랙의 시니어 경로로 <strong>대규모 조직의 플랫폼 팀 엔지니어 / 테크 리드 / 애자일 코치 / 테크니컬 프로그램 매니저(TPM) / CTO</strong> 같은 자리를 꼽았다. 이 경로는 지금도 유효하지만, 거기에 <strong>'AI 개발 인프라 리드'</strong>, <strong>'개발자 생산성(DevProd) 엔지니어'</strong> 같은 새 자리가 추가됐다고 보면 된다.</p>
<p>세 트랙이 각자 진화하는 한편, 모든 트랙에서 공통으로 더 중요해진 역량 이 있다. 원래는 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 코드를 빠르고 정확히 검토하는 능력" 자체가 핵심이라고 생각한다. 보안 헤더·인풋 sanitization·CSRF 토큰을 빠뜨리진 않았는지, 접근성(ARIA·키보드 내비게이션·포커스 트랩)이 살아있는지, 렌더 비용·메모리·번들 사이즈 같은 성능 영향에 문제가 있는지 파악하는 것이다. 검토 없이 AI slop을 PR에 던지는 것은 엔지니어로서의 직무유기다. 머지 버튼을 누르는 사람은 여전히 사람이고, 그 책임은 AI에게 떠넘길 수 없다. Stack Overflow 설문에서 시니어들의 AI 신뢰도가 가장 낮은 것도, 결국 이런 디테일을 잡아내는 눈을 가지고 있기 때문 일 가능성이 높다.</p>
<p><strong>세 번째는 시스템 이해와 아키텍처 사고다.</strong> AI는 한 번에 한 파일을 잘 다루고, 흐름과 연관에 대해 인지능력이 뛰어나다. AI는 증상을 빠르게 고치지만, 역량이 뛰어난 개발자는 근본 원인을 찾는다. 이 역량을 키우는 방법에 Architecture Retrospective 같은 의식적 활동을 하는 것이다. 코드의 변화 속도가 빨라진 만큼, 팀의 시스템 이해도 도 의식적으로 끌어올리지 않으면 빠르게 인지 부채가 누적된다.</p>
<p><strong>네 번째는 AI 오케스트레이션 능력이다.</strong> AI 자체를 다루는 역량도 별도의 스킬셋으로 분리되고 있다. 단순히 프롬프트를 잘 쓴다 의 차원이 아니라, 작업을 작은 티켓으로 잘게 쪼개는 능력, 같은 작업에 어떤 모델을 쓸지 고르는 능력, 에이전트 평가/검증 파이프라인 설계 능력, 에이전트 실패 시 회복(rollback) 전략까지 한 묶음으로 다뤄야 하는 영역이 됐다. <a href="https://sourcegraph.com/blog/revenge-of-the-junior-developer" target="_blank" rel="noopener noreferrer">Steve Yegge</a>는 이 흐름을 <strong>6개의 wave(traditional → completions → chat → coding agents → agent clusters → agent fleets)</strong> 로 정리한다.</p>
<p><strong>다섯 번째는 Context Engineering이다.</strong> 2025년 중반부터 <a href="https://www.faros.ai/blog/context-engineering-for-developers" target="_blank" rel="noopener noreferrer">Karpathy와 Shopify CEO인 Tobi Lütke가 함께 밀기 시작한</a> 개념으로, 한마디로 "AI에게 어떤 컨텍스트를 어떤 형태로, 얼마나 보여줄지를 설계하는 능력" 이다. 구체적으로는 프로젝트 컨벤션·아키텍처 원칙·금기 사항을 AI 손이 닿는 곳 에 정리해두는 <strong>CLAUDE.md / rules 파일</strong> 작업, 모든 파일을 컨텍스트에 넣지 않고 관련 모듈만 선별해 보여주는 <strong>컨텍스트의 의도적 축소</strong>와 계획 → 구현 → 검증 을 별도 세션으로 분리해 컨텍스트 오염을 막는 <strong>명시적 단계 분리</strong>, 디자인 시스템·API 스키마·모니터링 데이터 같은 외부 컨텍스트를 표준 인터페이스로 연결하는 <strong>MCP를 통한 외부 컨텍스트</strong> 같은 모양으로 나타난다. <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic의 공식 문서</a>는 이를 "the new prompt engineering" 이라 부르며, 단일 프롬프트로는 시스템의 아키텍처 지식·패턴·tribal wisdom을 절대 담을 수 없다고 못박았다. 다시 말해, "한 번에 좋은 프롬프트를 짜는 것" 보다 "AI가 항상 좋은 컨텍스트를 받게 환경을 설계하는 것" 이 훨씬 더 중요해졌다.</p>
<p>여기까지 읽었다면 자연스러운 질문이 나온다. 그러면 구체적으로 어떻게 공부하면 되는데? 필자가 하고있는 방법은 대략 네 가지다.</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 님의 글에서 짚은 인지적 부하 감소 문제와 직결되는 이야기가 있다. 하루에 한두 시간은 AI 없이 코드를 짜는 시간 을 따로 확보하는 게 좋다. 아키텍처를 손으로 그려보거나, 익숙하지 않은 영역의 코드를 직접 한 줄씩 읽어보는 활동이 그 예다. (필자도 매일 점심 식사 이후 나른한 시간엔 AI 없이 코드를 짜본다. 옛 익숙함에서 멀어지지 않게 붙들어두는 시간이다.)</p>
<p>이게 단순히 "옛 방식을 잊지 않기 위해" 가 아니다. AI가 대신 해주는 시간만큼 본인의 깊이도 자라지 않기 때문이다. 검증과 판단력, 시스템 이해 같은 역량은 직접 부딪쳐 본 시간의 함수다.</p>
<h2 id="그래서-우리는"><a class="anchor" href="#그래서-우리는">그래서 우리는</a></h2>
<p>여기까지 길게 적었지만, 사실 AI 시대에 살아남는 프론트엔드 엔지니어 의 모습은 원글이 내놓은 결론과 그렇게 다르지 않다. 원글에서 꼽았던 좋은 시니어 엔지니어의 세 가지 포인트 는 이랬다.</p>
<ul>
<li><strong>기본에 충실</strong> 하고자 노력한다. (5가지 기본 역량을 지속적으로 유지·강화)</li>
<li>명시적 리더가 아니더라도 모범적 행동으로 자연스러운 영향력을 발휘한다.</li>
<li>주어진 일을 잘 끝마치는 것에 만족하지 않고, 전후 맥락을 살피며 큰 영향력을 만든다.</li>
</ul>
<p>이걸 AI 시대 관점으로 적용하면 이렇게 된다.</p>
<ul>
<li>AI가 만들어주는 코드 너머의 <strong>기본기</strong>(웹, 시스템, 도메인)에 충실하다.</li>
<li>AI가 아니라 자신이 방향을 정한다. 명시적 책임자가 아닐 때조차 "어디로 가야 하는지" 를 결정한다.</li>
<li>AI를 개인 생산성 도구로만 쓰지 않고, 팀과 시스템의 병목을 푸는 데 쓴다.</li>
</ul>
<p>Open AI의 권위자인 Andrej Karpathy의 글을 살펴보면, 그가 지금 강조하는 <strong>"agentic engineering"</strong> 의 핵심도 결국 같다. 시스템을 설계하고, 제약을 명세하고, 이미 머릿속에서 추론을 마친 구현을 AI로 가속화하는 것. 도구가 바뀌어도, 방향키는 여전히 사람 손에 있다 는 이야기다.</p>
<p>원글이 마지막에 던지는 메시지도 결국 "주어진 일을 잘 끝마치는 데에 만족하지 않고, 전후 맥락을 살피며 큰 영향력을 만드는 사람" 이 시니어가 된다는 것이었다. AI 시대에는 그 "임팩트" 의 정의가 달라졌을 뿐이다. AI가 1시간 만에 만들어낸 화면 하나를 "동작하니까 됐다" 로 머지하는 사람과, 그 화면이 접근성·보안·성능·시스템 정합성 면에서 어디까지 합리적인지를 30분 더 살피는 사람이 있다. 1년 뒤 시니어로 인정받는 건 후자다. 70%(동작)와 30%(응용과 활용)의 경계 위에서, 30% 쪽에 서 있는 사람 이 살아남는다.</p>
<p>이 글을 읽는 프론트엔드 엔지니어 분들도 "이제 뭘 더 공부해야 하지?" 라는 질문에 본인만의 답을 가지고 가셨으면 한다. 정답은 아무도 모르지만, AI가 코드를 짜는 시대일수록 더 "코드 너머의 것" 을 보는 사람이 살아남으리라는 점만큼은 필자는 꽤 확신하는 편이다. 1년 뒤 또 한 번 이 풍경이 어떻게 바뀌었을지, 그때 다시 글로 정리해볼 수 있길 바라며 마친다. <strong>(혹시 이 글이 1년 뒤에는 너무 뻔하거나 낡은 이야기로 보인다면, 그만큼 우리가 잘 대응했다는 뜻이 아닐까싶다)</strong></p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>커리어</category>
            <category>AI</category>
        </item>
        <item>
            <title><![CDATA[추상화]]></title>
            <link>https://hooninedev.com/260201</link>
            <guid isPermaLink="false">https://hooninedev.com/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은 『Clean Code』에서 이 개념을 <strong>"함수당 하나의 추상화 수준(One Level of Abstraction per Function)"</strong> 이라는 원칙으로 정리했다. 하나의 함수 안에 높은 수준과 낮은 수준의 코드가 섞여 있으면, 읽는 사람은 "이게 핵심 로직인가, 아니면 세부 구현인가?"를 매 줄마다 판단해야 하기 때문이다.</p>
<p>실제 코드로 보면 이 문제가 명확해진다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 뒤섞인 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 비즈니스 규칙 검증</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 비밀번호 해싱 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> encoder</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TextEncoder</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> encoder.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">encode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashBuffer</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.subtle.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">digest</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"SHA-256"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, data);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(hashBuffer))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">16</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 사용자 저장</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 이메일 전송 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> verifyToken</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">randomBytes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">32</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"hex"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> db.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">execute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    "INSERT INTO email_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [user.id, verifyToken, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> transporter.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sendMail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    from: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"noreply@example.com"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    to: user.email,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subject: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Welcome! Please verify your email"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    html: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`&#x3C;a href="/verify?token=${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">verifyToken</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}">Verify your account&#x3C;/a>`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 환영 이메일 발송</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이 함수를 읽는 사람은 "사용자 등록 흐름"이라는 비즈니스 규칙 기반의 높은 수준의 맥락을 따라가다가, 갑자기 해시 버퍼 조작과 SQL 쿼리와 이메일 템플릿 문자열이라는 낮은 수준의 맥락으로 끌려 내려간다. 그러다 다시 <code>sendWelcomeEmail</code>라는 높은 수준으로 점프한다. 이렇게 추상화 수준이 오르락내리락하면, 읽는 사람의 머릿속도 함께 오르락내리락하게 된다.</p>
<p>같은 함수를 추상화 수준을 맞춰서 다시 작성하면 이렇게 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 일관된 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashPassword</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendVerificationEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>모든 문장이 같은 높이의 추상화 수준에서 이야기하고 있다. 이메일 전송이 어떻게 구현되어있는지, 비밀번호 해싱이 어떤 알고리즘을 쓰는지는 각각의 하위 함수가 책임진다. 이 함수를 읽는 사람은 오직 "사용자 등록의 전체 흐름"이라는 하나의 맥락에만 집중하면 된다.</p>
<p>Martin은 이것을 <strong>"내리막 규칙(The Stepdown Rule)"</strong> 이라고도 부른다. 코드를 위에서 아래로 읽을 때, 마치 신문 기사처럼 위에는 큰 그림이, 아래로 내려갈수록 세부사항이 나와야 한다는 것이다.</p>
<p>Kent Beck도 『Smalltalk Best Practice Patterns』에서 동일한 원칙을 <strong>Composed Method 패턴</strong>으로 제시했다. 하나의 메서드는 같은 추상화 수준의 작업들로만 구성되어야하고, 각 단계는 한 줄의 메서드 호출로 표현되어야 한다는 것이다.</p>
<p>결국 이 모든 이야기가 하나로 귀결된다. <strong>하나의 함수는 하나의 추상화 수준에서만 이야기해야 한다.</strong> 이것만 지켜도 코드의 가독성은 눈에 띄게 달라진다.</p>
<p>그렇다면 추상화의 방향은 어떻게 잡아야 할까? 구체적인 것에서 시작해야 할까, 추상적인 것에서 시작해야 할까?</p>
<h2 id="공통점-추출이-아닌-부품의-조립으로-생각하기"><a class="anchor" href="#공통점-추출이-아닌-부품의-조립으로-생각하기">공통점 추출이 아닌, 부품의 조립으로 생각하기</a></h2>
<p>OOP에서 흔히 말하는 "구체적인 것들의 공통점을 뽑아내어 추상적인 것을 정의하라"는 가이드라인이 있다. 이 접근법 자체가 틀린 것은 아니지만, 필자는 이 방식에 너무 얽매이면 현재의 요구사항에 갇힌 설계를 만들어낼 위험이 있다고 생각한다.</p>
<p>예를 들어보자. 요구사항에 A, B, C라는 버튼이 있고, 세 버튼 모두 파란색 둥근 모양이며 차이점은 라벨 텍스트뿐이라고 하자. 공통점만 뽑아 설계하면 아래처럼 표현할 수 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BlueRoundButton</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue round"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{label}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>현재 요구사항은 완벽하게 충족된다. 그런데 며칠 뒤 기획자가 말한다.</p>
<blockquote>
<p>"B 버튼의 색상을 변경할 수 있게 해주세요."</p>
</blockquote>
<p>이 순간 <code>BlueRoundButton</code>이라는 이름부터 어색해진다. 색상 prop을 추가하면 되긴 하지만, 애초에 "파란색 둥근 버튼"이라는 구체적인 공통점에서 출발한 설계가 변경에 취약했던 것이다. (이 정도는 양반이다. 현실에서는 버튼의 모양, 버튼의 크기 등 수많은 요구상이 날아온다)</p>
<p>이런 상황이 반복되다 보면 자연스럽게 깨닫게 되는 것이 있다. <strong>구체적인 요구사항에서 공통점을 뽑아내는 방식은, 추상화된 결과물마저도 현재의 요구사항만을 반영하게 되기 쉽다</strong>는 것이다.</p>
<p>그래서 필자가 선호하는 접근은 반대 방향이다. 구체적인 것에서 추상을 뽑아내는 것이 아니라, <strong>추상적인 부품들을 먼저 생각하고 이것들을 조립하여 구체적인 것을 만드는 방식</strong>이다.</p>
<p>토스트 알림 컴포넌트를 만든다고 생각해보자. 처음 요구사항은 단순하다. "저장이 완료되면 하단에 짧은 안내 메시지를 띄워주세요." 공통점 추출 방식으로 접근하면 이렇게 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 현재 요구사항의 공통 특성에서 출발한 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  hasAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  actionLabel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">hasAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">actionLabel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">onAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{message}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    {hasAction </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onAction}>{actionLabel}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>현재 요구는 완벽히 다뤄진다. 그런데 며칠 뒤 기획자가 "성공 / 경고에 따라 왼쪽에 아이콘을 넣어주세요"라고 말한다. <code>hasIcon</code>, <code>iconName</code>이 추가된다. 곧이어 "업로드 진행률 바가 붙은 토스트도 필요해요"라는 요구가 날아온다. <code>progress</code> prop이 또 하나 늘어난다. 이 과정을 몇 번 반복하고 나면 <code>Toast</code>는 열 개가 넘는 props를 가진 채, <strong>이 조합은 되고 저 조합은 안 되는</strong> 숨겨진 규칙까지 암기해야 하는 컴포넌트가 된다. (그리고 대개 그 규칙은 주석으로도 남지 않는다)</p>
<p>애초에 <strong>"토스트는 이런 모양이어야 한다"는 현재 시점의 구체적 상(像)</strong> 에서 출발한 설계가 변경에 취약했던 것이다.</p>
<p>부품 조립 방식으로 접근하면 이야기가 달라진다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 부품을 조립하는 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Icon</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "check"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "warn"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "info"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Action</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  onClick,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;{ </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onClick}>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Progress</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 필요한 부품만 골라 조립한다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>저장되었습니다&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Icon</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"check"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>업로드 완료&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{undo}>실행 취소&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Progress</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>파일 전송 중...&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>"토스트는 무언가를 담는 얕은 컨테이너"라는 변하지 않는 본질과, "그 안에 무엇이 담기는가"라는 변하기 쉬운 구체가 분리되었다. 이제 <code>Toast</code> 내부를 건드리지 않고도 새로운 부품을 얼마든지 추가하거나, 기존 부품을 새로운 조합으로 배치할 수 있다. prop 조합의 유효성 규칙도 사라진다. 단순히 <strong>넣고 싶은 것을 넣으면 되는 것</strong>이다.</p>
<p>물론 경험이 많은 개발자라면 "처음부터 IoC(제어의 역전)로 설계하면 되는 거 아니냐"고 할 수 있다. 맞는 말이다. 하지만 그런 판단이 가능한 이유는 과거에 수많은 삽질을 통해 "어디가 변경되기 쉬운 부분인지"에 대한 감각이 쌓였기 때문이다.</p>
<p>이런 감각이 아직 부족한 상황이라면, "이 기능은 어떤 부품들로 이루어져 있고, 각 부품은 어떻게 조립되어야 하는가"라는 질문에서 출발하는 것이 변경에 열린 설계를 만들어내기 훨씬 수월하다.</p>
<p>여기까지 읽으면 자연스럽게 떠오르는 질문이 하나 있다. 그렇다면 부품을 어떤 기준으로 나누고, 외부에 어떻게 표현해야 할까?</p>
<h2 id="좋은-추상화를-위한-세-가지"><a class="anchor" href="#좋은-추상화를-위한-세-가지">좋은 추상화를 위한 세 가지</a></h2>
<h3 id="표현에-대해-고민하기"><a class="anchor" href="#표현에-대해-고민하기">표현에 대해 고민하기</a></h3>
<p>추상화된 모듈의 가장 중요한 덕목은, 소스코드를 까보지 않아도 동작을 유추할 수 있어야 한다는 것이다. Kent Beck은 이것을 <strong>"의도를 드러내는 이름(Intention-Revealing Name)"</strong> 패턴이라고 불렀고, 이름을 간결하게 지을 수 없다면 추상화 자체를 다시 고민해봐야 한다고 말했다.</p>
<p>우리가 이를 위해 사용할 수 있는 도구는 크게 두 가지다. <strong>이름</strong>과 <strong>타입</strong>이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도대체 뭘 하는 건지 알 수 없는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">price</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이름과 타입만으로 동작을 유추할 수 있는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateDiscountedPrice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">originalPrice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">discountRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><code>calculateDiscountedPrice</code>는 이름만 봐도 원가와 할인율을 받아 할인된 가격을 계산한다는 것을 알 수 있고, <code>number</code>를 받아서 <code>number</code>를 반환한다는 타입 정보가 이를 뒷받침한다. 내부에서 어떤 계산 로직이 적용되는지는 몰라도 된다.</p>
<p>반면 <code>calculate(price: number, rate: number): number</code>는 계산하는 대상에대한 정보를 모르니 결과에 대한 예상을 할 수 없다. 결국 소스코드를 까봐야만 사용할 수 있게 되고, 추상화의 장점을 잃어버리는 것이다.</p>
<p>이때 이름을 짓는 방식 자체가 추상화 수준을 반영한다는 점에 주목할 필요가 있다. 프로그래밍에서 함수 이름은 대체로 <strong>동사 + 명사</strong> 조합으로 이루어지는데, 어떤 동사를 선택하느냐에 따라 그 함수가 어느 수준의 추상화에서 동작하는지가 드러난다.</p>
<blockquote>
<p>단, 동사 단독으로 추상화 수준이 결정되는 것은 아니다. 함께 오는 명사(도메인 맥락)가 최종 수준을 결정한다</p>
</blockquote>
<p>추상화 수준이 <strong>낮은</strong> 쪽에서 자주 쓰이는 동사들이 있다. <code>parse</code>, <code>encode</code>, <code>decode</code>, <code>serialize</code>, <code>read</code>, <code>write</code>, <code>push</code>, <code>pop</code>, <code>convert</code>, <code>transform</code> 같은 것들이다. 이 단어들은 데이터의 물리적인 변환이나 자료구조의 직접적인 조작을 암시한다.</p>
<p>중간 수준에서는 <code>get</code>, <code>save</code>, <code>load</code>, <code>validate</code> 같은 동사들이 등장한다. 기술적인 동작이지만, 그 동작의 의도가 어느 정도 드러나는 수준이다.</p>
<p>추상화 수준이 <strong>높은</strong> 쪽에서는 <code>register</code>, <code>refund</code>, <code>confirm</code>, <code>cancel</code>, <code>submit</code> 같은 동사들이 쓰인다. 이 단어들은 비즈니스 도메인의 언어이다. 내부에서 어떤 기술적 절차가 일어나는지는 전혀 드러나지 않고, <strong>사용자의 행위나 비즈니스 프로세스</strong>만이 표현된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 낮은 수준: 기술적 동작이 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseJSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">text</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> object</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> encodeBase64</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 중간 수준: 의도가 드러나되 기술적 맥락이 남아있음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getUserById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> validateEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 높은 수준: 비즈니스 의도만 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">form</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RegistrationForm</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refundPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">orderId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OrderId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Refund</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>Robert C. Martin은 『Clean Code』에서 이에 대해 <strong>"짧고 수수께끼 같은 이름보다 길고 서술적인 이름이 낫다"</strong> 고 말했다. 또한 <strong>"하나의 개념에는 하나의 단어를 사용하라"</strong> 는 원칙도 제시했는데, 같은 맥락의 동작에 <code>fetch</code>, <code>retrieve</code>, <code>get</code>을 혼용하면 읽는 사람이 "이 셋이 다른 동작인가?"라고 혼란을 겪게 되기 때문이다.</p>
<p>이 원칙은 React 컴포넌트와 훅의 네이밍에도 그대로 적용된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />           </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SearchInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />      </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SubmitOrderButton</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>컴포넌트는 추상화 수준에 따라 이름의 구체성이 달라진다. Button은 낮은 수준에서 범용 UI 프리미티브로 사용되지만, SubmitOrderButton은 높은 수준에서 비즈니스 의도가 명확히 드러난다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FormData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Form</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit} />           </span></span></code></pre></figure>
<p><code>on*</code>은 컴포넌트가 외부에 노출하는 prop 이름이다. 컴포넌트를 사용하는 쪽에서 "어떤 이벤트에 반응할지"를 선언한다.<code>handle*</code>은 그 prop에 실제로 전달되는 구현 함수 이름이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();                  </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">items</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCartItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModal</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();   </span></span></code></pre></figure>
<p>커스텀 훅은 <code>use</code> 접두사를 사용하여 React의 규칙을 따르고, 훅이 제공하는 상태나 동작을 컴포넌트에서 사용할 수 있도록 한다.</p>
<blockquote>
<p><a href="https://blog.codinghorror.com/" target="_blank" rel="noopener noreferrer">Coding Horror</a> 을 운영하는 Jeff Atwood 는 <code>Manager</code>라는 접미사의 문제를 지적한 적이 있다. <code>UrlManager</code>라는 이름은 URL을 풀링하는 건지, 검증하는 건지, 생성하는 건지 전혀 알 수 없다. <code>UrlBuilder</code>, <code>UrlValidator</code>, <code>UrlPool</code>처럼 구체적인 역할을 드러내는 이름이 훨씬 낫다. 이름이 모호하다는 것은, 그 모듈의 책임 자체가 모호하다는 신호일 수 있는 것이다.</p>
</blockquote>
<p>결국 좋은 이름이란 <strong>그 코드가 어떤 추상화 수준에서 동작하는지를 읽는 사람에게 즉시 알려주는 이름</strong>이다.</p>
<h3 id="입력의-자유도를-의도적으로-설계하기"><a class="anchor" href="#입력의-자유도를-의도적으로-설계하기">입력의 자유도를 의도적으로 설계하기</a></h3>
<p>추상화된 모듈을 설계할 때 자주 마주치는 고민이 있다. "기능을 어디까지 열어줄 것인가?"이다. 이 결정에 따라 모듈을 사용하는 개발자의 경험이 크게 달라진다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 닫힌 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 완전히 열린 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"button"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">props} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>첫 번째 버튼은 <code>children</code>만 받을 수 있다. <code>onClick</code>도, <code>type</code>도, <code>disabled</code>도 설정할 수 없다. 대신 사용하는 사람은 고민할 것이 없다.</p>
<p>두 번째 버튼은 <code>button</code> 엘리먼트의 모든 속성을 받을 수 있다. 자유도가 높지만, 사용하는 입장에서는 수십 개의 prop 중 뭘 써야 할지 고민하게 된다. 필자는 이런 상황을 <strong>"컴포넌트가 개발자에게 고민을 강요한다"</strong> 고 표현한다.</p>
<p>정답은 없다. 다만 모듈의 목적과 사용자에 따라 적절한 수준을 찾아야 한다. 디자인 시스템의 기본 버튼이라면 제한된 Props로 일관성을 지키는 것이 나을 수 있고, 범용 유틸리티 컴포넌트라면 유연하게 열어주는 것이 나을 수 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 닫힌 인터페이스 — 다양한 상황에 대응 불가</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit}>제출&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// onClick 외의 이벤트, className, disabled 등을 전달할 방법이 없다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 열린 인터페이스 — 의도가 사라짐</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">anyProps} /></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무엇을 전달해야 하는지 사용자가 직접 파악해야 한다</span></span></code></pre></figure>
<p>추상화의 폭은 사용자가 누구인지에 따라 결정되어야 한다. 내부 구현을 이해하고 세밀하게 제어해야 하는 사용자에게는 낮은 수준의 인터페이스가 적합하다. 반면 세부사항을 몰라도 되는 사용자에게 지나치게 열린 인터페이스를 제공하면 혼란만 가중된다. 반대로 다양한 상황에 대응해야 하는 사용자에게 입력을 과도하게 제한하면 사용처 자체가 막혀버린다</p>
<h3 id="추상화의-단위를-적절하게-유지하기"><a class="anchor" href="#추상화의-단위를-적절하게-유지하기">추상화의 단위를 적절하게 유지하기</a></h3>
<p>추상화의 단위, 즉 "어디까지를 하나의 모듈로 묶을 것인가"도 중요한 고민이다.</p>
<p>프론트엔드에서 흔히 보는 안티패턴 중 하나는 Custom Hook의 과도한 추출이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이 훅은 단 하나의 컴포넌트에서만 사용된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useUserProfileData</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setUser)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { user, loading };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>하나의 컴포넌트에서만 사용되는 로직을 굳이 훅으로 분리하면, 오히려 코드를 읽는 사람이 두 개의 파일을 오가며 맥락을 파악해야 한다. 추상화가 맥락을 줄여주기는커녕 늘려버린 것이다.</p>
<p>반대로, 하나의 훅에 너무 많은 것을 담는 것도 문제다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 관련 없는 관심사가 하나의 훅에 뒤섞여 있다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useEverything</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> auth</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> theme</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useTheme</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> analytics</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAnalytics</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useToast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { auth, theme, analytics, toast };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>이런 "God Hook"은 테스트하기 어렵고, 한 가지를 수정하면 관련 없는 부분까지 영향을 받을 수 있다.</p>
<p>추상화의 적절한 단위를 판단하는 기준은 <strong>"이 분리가 코드를 읽는 사람의 맥락을 실제로 줄여주는가?"</strong> 이다. 만약 분리한 결과 오히려 맥락이 분산되어 파악이 어려워진다면, 그 추상화는 아직 때가 아닌 것이다.</p>
<h2 id="섣부른-추상화를-경계하라"><a class="anchor" href="#섣부른-추상화를-경계하라">섣부른 추상화를 경계하라</a></h2>
<p>여기까지 읽으면 "그래서 추상화를 언제 해야 하는 건데?"라는 질문이 남을 수 있다. 필자의 생각은 이렇다. <strong>기본 전제는 추상화를 섣부르게 하지 않는 것이어야 한다.</strong></p>
<p>명확한 추상화 신호가 보이지 않는 한, 코드를 있는 그대로 두는 것이 잘못된 추상화를 만들어놓고 나중에 풀어헤치는 것보다 저점이 높다. 잘못된 추상화가 만들어지는 과정은 대략 이렇다.</p>
<ol>
<li>코드 A와 코드 B에서 비슷한 패턴이 보인다.</li>
<li>"DRY 원칙이니까 공통 함수로 뽑아야지!" 하고 추상화한다.</li>
<li>코드 C에서도 비슷한 패턴이 보여서 같은 함수를 쓰되, 약간 다른 동작을 위해 매개변수를 하나 추가한다.</li>
<li>코드 D, E에서도 사용하게 되면서 조건문과 매개변수가 계속 늘어난다.</li>
<li>이제 이 함수는 모든 곳에서 사용되지만, 아무도 건드리기 두려워하는 코드가 된다.</li>
</ol>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 처음에는 단순했던 함수가...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">firstName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">} ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">lastName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 요구사항이 추가될 때마다 매개변수가 늘어나고...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  includeMiddleName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "full"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "short"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  locale</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  honorific</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (format </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (includeMiddleName </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.middleName) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (honorific </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> locale </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "ko"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...끝없는 분기</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>이런 상황에 빠졌다면, 해법은 명확하다. 추상화된 코드를 각 사용처에 다시 인라인하고, 각 사용처에서 불필요한 코드를 제거한 뒤, 깨끗해진 상태에서 진짜 공통점이 보이면 그때 다시 추상화한다. <strong>"가장 빠른 전진은 뒤로 돌아가는 것이다."</strong></p>
<p>그렇다면 추상화를 해야 할 때는 언제인가? 필자가 느끼는 <strong>추상화 신호</strong>는 대략 이런 것들이다.</p>
<ul>
<li><strong>일관성이 깨지고 있다</strong>: 똑같은 로직인데 어떤 컴포넌트에서는 인라인으로, 어떤 컴포넌트에서는 별도 함수로 분리되어 있다. 같은 계산 로직이 사방에 흩어져 있다.</li>
<li><strong>내부 구조가 외부에 불필요하게 노출되고 있다</strong>: 외부에서 알 필요 없는 구현 세부사항을 호출자가 일일이 다루고 있다.</li>
<li><strong>자신의 행위가 계속 노출되고 있다</strong>: 모듈이 자기 내부의 절차를 숨기지 못하고, 사용하는 쪽에서 그 절차를 그대로 따라가야 한다.</li>
</ul>
<p>문제는, 이런 신호를 감지하는 것 자체는 대체로 단순한데, <strong>실제로는 이 신호를 무시하고 더 "중요한" 요구사항을 충족하는 데 집중하게 되기 쉽다</strong>는 것이다. 일정에 쫓기거나 기능 구현에 몰두하다 보면, "일단 동작하니까 나중에 정리하자"가 되고, 그 나중은 좀처럼 오지 않는다.</p>
<p>여기서 한 가지 더 중요한 것은 <strong>일관된 추상화 기준을 유지하는 것</strong>이다. 코드베이스 안에서 같은 종류의 로직이 어떤 곳에서는 인라인으로, 어떤 곳에서는 함수로, 또 어떤 곳에서는 커스텀 훅으로 분리되어 있다면, 새로 코드를 읽는 사람은 "이 차이에 의도가 있는 건가?"라고 혼란스러워한다. 추상화를 하든 안 하든, 팀 안에서 기준이 일관되어야 한다.</p>
<p>Joel Spolsky가 2002년에 제시한 <strong>"새는 추상화의 법칙(The Law of Leaky Abstractions)"</strong> 도 이 맥락에서 기억해둘 만하다. 새는 추상화의 법칙은 추상화는 복잡한 구현을 숨기려 하지만, 그 구현의 세부사항이 결국 밖으로 새어나온다(leak) 는 것이다. 즉 추상화를 사용하는 사람이 내부 구현을 몰라도 된다고 설계했지만, 실제로는 알아야만 제대로 쓸 수 있는 상황이 생긴다는 뜻으로 사용된다.</p>
<p>TCP는 불안정한 네트워크를 안정적인 연결처럼 보이게 추상화하지만, 케이블이 끊기면 그 추상화는 깨진다. React는 UI 업데이트를 선언적으로 추상화하지만, 리렌더링 최적화를 위해서는 결국 내부 동작을 이해해야 한다. 완벽한 추상화는 존재하지 않기에, 추상화를 할 때는 <strong>"이 추상화가 깨졌을 때 사용자가 대응할 수 있는가?"</strong> 까지 고민해야 한다.</p>
<p>결국 <strong>"추상화는 우리의 작업 시간을 절약해주지만, 학습 시간을 절약해주지는 않는다."</strong></p>
<h2 id="추상화는-체화의-영역이다"><a class="anchor" href="#추상화는-체화의-영역이다">추상화는 체화의 영역이다</a></h2>
<p>필자가 동료와 추상화에 대해 이야기를 나눈 적이 있는데, 그때 나온 이야기가 인상 깊었다. 추상화 신호를 감지하고, 적절한 시점에 적절한 수준으로 분리하는 것은 결국 <strong>감각의 영역</strong>이라는 것이다.</p>
<p>물론 앞서 이야기한 원칙들, 추상화 수준을 맞추고, 이름을 잘 짓고, 입력 자유도를 설계하는 것은 분명히 중요하다. 하지만 실제로 코드를 작성하는 순간에 이런 원칙들을 하나하나 떠올리면서 "이걸 분리할까 말까?"를 따지는 접근은 오히려 흐름을 깨뜨릴 수 있다. 마치 시합 중에 잽을 던질 때 팔꿈치 각도를 의식하면 오히려 타이밍을 놓치는 것처럼, 코드를 짤 때도 추상화는 의식적 판단이 아닌 자연스러운 감각에서 나와야 하는 것이다.</p>
<p>코드를 쭉 짜다가 뭔가 거부반응이 올라오는 순간이 있다. "이 로직이 여기 있으면 안 되는 것 같은데", "이 컴포넌트가 너무 많은 것을 알고 있는 것 같은데"라는 느낌. 그 느낌이 바로 추상화 신호이고, 이것을 자연스럽게 감지하고 반응할 수 있는 것이 체화인 것이다.</p>
<p>다만 이 감각은 하루아침에 만들어지지 않는다. 수많은 패턴을 학습하고, 다양한 코드를 읽고, 직접 삽질을 겪어봐야 비로소 <strong>"이건 분리해야 할 것 같은데"</strong> 라는 감이 자연스럽게 올라오기 시작한다. 나중에 동료가 "왜 이걸 분리했어?"라고 물었을 때, "이건 OO이기 때문에 분리했어"라고 자연스럽게 설명할 수 있다면 그것이 체화된 것이다.</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, Reusing Logic with Custom Hooks</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/260104</link>
            <guid isPermaLink="false">https://hooninedev.com/260104</guid>
            <pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 TanStack Query의 queryKey에 대한 이야기를 해보려고 한다. TanStack Query를 쓰면서 queryKey를 인라인 배열로 둘지, 상수 객체나 query key factory로 모을지, v5의 queryOptions로 옮길지 고민하는 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 각 관리 방식이 어떤 문제를 풀려고 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>TanStack Query의 queryKey</strong>에 대한 이야기를 해보려고 한다.</p>
<p>TanStack Query를 쓰면서 queryKey를 인라인 배열로 둘지, 상수 객체나 query key factory로 모을지, v5의 <code>queryOptions</code>로 옮길지 고민하는 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 각 관리 방식이 어떤 문제를 풀려고 등장했는지 알 수 있고, 지금 코드베이스에 어느 단계가 맞는지 판단할 기준을 얻을 수 있다.</p>
<p>필자는 실무에서 TanStack Query를 쓰면서 <strong>queryKey 관리 방식을 몇 번이나 갈아엎었던</strong> 경험이 있다. 처음에는 그냥 컴포넌트 안에서 <code>['user', userId]</code> 같은 배열을 인라인으로 박아 썼다가, 무효화할 때마다 같은 키를 여러 곳에 적느라 오타를 내기 시작했고, 그래서 <code>QUERY_KEYS</code> 같은 상수 객체로 옮겼다. 그러다 TkDodo의 글을 읽고 query key factory 패턴으로 넘어갔고, 한참 뒤에는 <code>@lukemorales/query-key-factory</code> 라이브러리를 도입했고, 그러다 v5가 나오고서 <code>queryOptions</code>로 또 한 번 갈아엎었다.</p>
<h2 id="querykey가-없던-시절"><a class="anchor" href="#querykey가-없던-시절">queryKey가 없던 시절</a></h2>
<p>본격적인 이야기에 앞서 한 가지 짚고 넘어가자. 우리는 지금 너무 당연하게 <code>TanStack Query</code>, <code>SWR</code> 같은 라이브러리를 쓰고 있지만, 이 라이브러리가 없던 시절에는 비동기 데이터를 어떻게 다뤘을까?</p>
<p>가장 흔한 형태는 <code>useState</code>, <code>useEffect</code>, <code>fetch</code>, <code>axios</code> 등을 조합하는 것이었을것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/users/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">userId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">err</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(err);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [userId]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이 코드의 문제는 명백하다. 같은 <code>userId</code>를 보는 컴포넌트가 페이지에 두 개만 있어도 <strong>동일한 요청이 두 번 나간다.</strong> 이유는 캐시가 없기 때문이다. 그리고 사용자가 다른 페이지로 갔다가 돌아오면 또 다시 처음부터 fetch한다. 데이터가 1초 전에 가져온 것인지 1시간 전에 가져온 것인지 구분할 방법이 없으니, "캐시된 값을 보여주면서 백그라운드에서 갱신"같은 동작을 흉내내기도 어렵다. (이것을 구현하기위해 자체적으로 캐시 시스템을 도입할 수 있지만 관리하기 상당히 까다롭다고 생각한다.)</p>
<p>이걸 해결하려고 등장한 것이 Redux + redux-thunk(혹은 redux-saga) 조합이었다. 데이터 fetching 로직을 thunk로 빼서 store에 결과를 저장해두면, 다른 컴포넌트에서도 같은 데이터를 재사용할 수 있었다. 하지만 매번 액션 타입을 정의하고, reducer를 작성하고, 로딩/성공/실패 상태를 직접 관리해야 했다. 데이터 하나를 가져오기 위한 보일러플레이트가 어마어마했다. (필자는 이 시기에 실무를 시작했고, "왜 데이터 하나 가져오는데 파일을 여러개나 만들어야 하지?"라는 의문을 품었다.)</p>
<p>위 흐름의 본질은 결국 <strong>"이 요청이 어떤 요청인지를 식별할 수 있어야, 같은 요청을 다시 하지 않을 수 있다."</strong> 그리고 그 "어떤 요청인지"의 식별자가 바로 queryKey의 정체이다.</p>
<p>SWR과 React Query(현 TanStack Query)는 이 문제를 정면으로 풀었다. "비동기 요청에는 식별자가 있어야 하고, 같은 식별자라면 캐시를 공유한다." 이 단순한 원칙 하나로 위의 모든 보일러플레이트가 사라진 것이다.</p>
<h2 id="querykey의-본질"><a class="anchor" href="#querykey의-본질">queryKey의 본질</a></h2>
<p>그렇다면 queryKey는 정확히 무엇일까? TanStack Query 공식 문서는 이렇게 정의한다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>핵심적으로 TanStack Query는 query key를 기준으로 쿼리 캐싱을 관리한다. query key의 최상위 수준은 배열이어야 한다... query key가 직렬화 가능하고 <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>에서 deps가 바뀌면 effect가 다시 실행되듯이, queryKey가 바뀌면 TanStack Query가 자동으로 새 데이터를 fetch한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">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는 서로 다르다. 다르면 캐시 미스이고, 캐시 미스이면 fetch한다. 자동이다. 이 단순함 덕분에 우리가 직접 "userId가 바뀌었으니 다시 fetch해야 한다"는 로직을 짤 필요가 없다.</p>
<p>그렇다면 렌더링마다 새로 만들어지는 배열을 TanStack Query는 어떻게 같은 키로 알아볼까? 배열의 참조를 비교하지 않고, queryKey를 <code>hashKey</code>로 직렬화한 문자열(<code>queryHash</code>)을 캐시의 키로 쓴다. 이 함수는 plain object의 키를 정렬하면서 <code>JSON.stringify</code>로 직렬화하므로, 객체 안의 키 순서는 해시에 영향을 주지 않고 배열의 요소 순서는 영향을 준다. 값이 <code>undefined</code>인 속성은 직렬화 과정에서 사라져서, <code>{ a: 1, b: undefined }</code>와 <code>{ a: 1 }</code>은 같은 키가 된다. 이 동작을 구현 코드와 함께 따라간 내용은 <a href="/251230" target="_blank" rel="noopener noreferrer">queryKey 비교 원리</a>에 따로 정리해 두었다.</p>
<h2 id="querykey-작성-규칙"><a class="anchor" href="#querykey-작성-규칙">queryKey 작성 규칙</a></h2>
<p>queryKey가 캐시의 식별자이자 의존성 배열이라는 점을 이해했다면, 작성 규칙도 자연스럽게 따라온다. 공식 문서가 권하는 규칙을 정리하면 다음과 같다.</p>
<h3 id="배열로-쓰기"><a class="anchor" href="#배열로-쓰기">배열로 쓰기</a></h3>
<p>문자열로 넘겨도 동작은 한다(내부적으로 배열로 변환된다). 하지만 일관성을 위해 처음부터 배열로 쓰는 것이 좋다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 비권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, queryFn });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], queryFn });</span></span></code></pre></figure>
<h3 id="queryfn이-쓰는-변수를-모두-넣기"><a class="anchor" href="#queryfn이-쓰는-변수를-모두-넣기">queryFn이 쓰는 변수를 모두 넣기</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 잘못된 예: userId가 쿼리키에 없다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 올바른 예</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><code>useEffect</code>의 deps와 똑같은 사고방식이다. 함수 안에서 사용하는 변수는 모두 키(=의존성)에 들어가야 한다. 이걸 어기면 사용자가 다른 사용자로 바뀌었는데도 이전 사용자의 데이터가 그대로 보이는, 추적하기 어려운 버그가 생긴다.</p>
<h3 id="generic에서-specific-순으로-놓기"><a class="anchor" href="#generic에서-specific-순으로-놓기">generic에서 specific 순으로 놓기</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 좋다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, todoId]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안 좋다 (순서가 뒤집혀 있음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[{ filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span></code></pre></figure>
<p>이 순서가 중요한 이유는 <strong>무효화(invalidation)</strong> 때문이다. TanStack Query의 <code>invalidateQueries</code>는 기본적으로 <strong>prefix matching</strong>으로 동작한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...], ['todos', 'detail', ...] 모두 매치된다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// list 쿼리만 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...]만 매치된다</span></span></code></pre></figure>
<p>키를 트리 구조로 설계해두면, "이 도메인의 모든 데이터를 다시 받아와라"부터 "이 정확한 항목 하나만 다시 받아와라"까지 한 줄로 표현할 수 있다. (이게 처음에 봤을 때는 별 거 아닌 것 같다가, 한 번 잘못 설계해놓고 무효화 범위가 의도와 다르게 동작하는 걸 겪어보면 그 가치가 사무치게 와닿는다.)</p>
<h2 id="querykey-관리의-변천사"><a class="anchor" href="#querykey-관리의-변천사">queryKey 관리의 변천사</a></h2>
<p>여기까지가 queryKey의 동작 원리와 사용법에 관한 이야기였다. 이제 본격적으로 <strong>queryKey 관리가 어떤 흐름으로 변경되었을까?</strong> 라는 질문으로 넘어가자.</p>
<p>필자가 실무에서 거쳐온 단계를 시간순으로 정리해보겠다.</p>
<h3 id="인라인-배열"><a class="anchor" href="#인라인-배열">인라인 배열</a></h3>
<p>가장 단순한 형태이다. 컴포넌트 안에서 고정 문자열과 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:#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>문제는 코드베이스가 커지면서 시작된다. 사용자 정보를 수정하는 mutation에서 무효화를 걸어야 하는데, "사용자 관련 쿼리 키가 뭐였더라?"를 매번 검색해서 찾아야 한다. 어떤 곳은 <code>['user', userId]</code>로 적어놓고, 어떤 곳은 <code>['users', userId]</code>(복수형)로 적어놓는 일이 생긴다. 둘은 완전히 다른 캐시 슬롯이라 무효화가 한쪽에만 적용된다.</p>
<h3 id="상수-객체"><a class="anchor" href="#상수-객체">상수 객체</a></h3>
<p>오타를 막기 위해 쿼리키를 상수로 모아둔다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> QUERY_KEYS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  USER: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  POSTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  COMMENTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용처</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QUERY_KEYS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">USER</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>오타는 사라진다. 그런데 여전히 키를 조립하는 책임은 사용처에 있다. <code>[QUERY_KEYS.USER, userId]</code>라는 조합을 누군가는 <code>[QUERY_KEYS.USER, userId, 'detail']</code>로 쓰고, 또 누군가는 <code>['user', 'detail', userId]</code>로 쓴다. 어떤 게 맞는지 컨벤션을 별도로 외워야 하는 시점이 온다.</p>
<h3 id="query-key-factory"><a class="anchor" href="#query-key-factory">Query Key Factory</a></h3>
<p>이 패턴은 TkDodo의 <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</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>콜로케이션(co-location)</strong> 이다. TkDodo는 키를 전역 파일에 모으는 것을 권하지 않는다. 대신 기능(feature) 디렉토리 안에 <code>queries.ts</code>를 두고, 그 안에 키와 hook을 함께 둔다.</p>
<pre><code>src/
└── features/
    └── todos/
        ├── index.tsx
        └── queries.ts   # 키와 훅을 모두 여기에
</code></pre>
<p>이렇게 하면 "todos에 뭘 수정하려면 todos 폴더만 보면 된다"는 단순한 멘탈 모델이 만들어진다. 함께 변하는 것을 함께 둔다는 원칙의 충실한 구현이다.</p>
<h3 id="lukemoralesquery-key-factory"><a class="anchor" href="#lukemoralesquery-key-factory">@lukemorales/query-key-factory</a></h3>
<p>3번째 패턴을 매번 손으로 짜다 보면 보일러플레이트가 쌓인다. 그리고 여러 도메인의 키를 합쳐 관리하고 싶을 때 표준화된 인터페이스가 아쉬워진다. <a href="https://github.com/lukemorales/query-key-factory" target="_blank" rel="noopener noreferrer">@lukemorales/query-key-factory</a>는 이 패턴을 라이브러리화한 결과물이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createQueryKeys, mergeQueryKeys } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@lukemorales/query-key-factory'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [{ filters }],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todos</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> mergeQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(users, todos);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.todos.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users._def);            </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 user 쿼리</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p><code>createQueryKeys</code>가 자동으로 prefix를 붙여주고, <code>mergeQueryKeys</code>로 도메인을 합칠 수 있다. 그리고 <code>_def</code>라는 약속된 속성으로 도메인 전체 키에 접근할 수 있다. 수기 factory에서 매번 <code>as const</code>를 붙이며 타입을 직접 좁혀야 했던 작업들이 사라진다.</p>
<p>이 라이브러리는 한동안 사실상 표준처럼 쓰였다. (필자도 한참 즐겨 썼다.) 그런데 queryOptions가 나오면서 상황이 달라졌다.</p>
<h3 id="queryoptions-v5-공식"><a class="anchor" href="#queryoptions-v5-공식">queryOptions (v5 공식)</a></h3>
<p>TanStack Query v5의 가장 중요한 변화 중 하나가 바로 <code>queryOptions</code> API의 도입이다. v4에서 v5로 넘어오면서 모든 hook의 인자가 단일 객체로 통일되었는데, 이 변화의 진짜 목적은 그 객체를 <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">The Query Options 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>branded type</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">Uncovering the unique symbol Behind DataTag</a> 글은 이 장치를 "타입 정보를 위한 전용 주차 공간"에 비유한다. 일반 string 키였다면 다른 라이브러리나 사용자 코드의 키와 충돌할 수 있지만, <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>이 점은 query key factory의 설계 방향에 묵직한 함의를 던진다. 이전 세대의 패턴들은 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>의 진가는 도메인별 factory와 결합할 때 드러난다. v5 공식 문서가 권장하는 형태는 다음과 같다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoQueries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodoFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), filters],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodos</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">30</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>이 패턴이 좋은 이유를 하나씩 풀어보자.</p>
<h3 id="계층-구조와-타입-추론"><a class="anchor" href="#계층-구조와-타입-추론">계층 구조와 타입 추론</a></h3>
<p><code>todoQueries.all()</code>이나 <code>todoQueries.lists()</code>는 그냥 배열을 반환하지만, <code>todoQueries.detail(1)</code>은 <code>queryOptions</code>를 통해 만들어진 데이터 태그가 붙은 객체를 반환한다. 무효화에는 배열을, 쿼리 호출에는 옵션 객체를 쓰면 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));                                </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 옵션 객체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() }); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배열</span></span></code></pre></figure>
<h3 id="컴포넌트별-옵션-덮어쓰기"><a class="anchor" href="#컴포넌트별-옵션-덮어쓰기">컴포넌트별 옵션 덮어쓰기</a></h3>
<p><code>queryOptions</code>의 결과는 결국 객체이니, 호출 시점에서 일부 옵션을 합성할 수 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">title</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  select</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">todo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todo.title,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 컴포넌트별로 다른 select 적용</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>이 패턴이 특히 강력한 이유는, <code>select</code>의 반환 타입이 자동으로 추론되어 <code>data</code>의 타입이 <code>string</code>으로 좁혀진다는 점이다. 컴포넌트 입장에서는 필요한 부분만 골라쓰면서, 도메인 정의는 한 곳에 그대로 둘 수 있다.</p>
<h3 id="줄어드는-usequery-커스텀-훅"><a class="anchor" href="#줄어드는-usequery-커스텀-훅">줄어드는 <code>useQuery</code> 커스텀 훅</a></h3>
<p>v4 시절의 일반적인 패턴은 도메인별 커스텀 훅을 만드는 것이었다.</p>
<p>이 방식의 문제는, <strong>prefetch가 필요해지는 순간 같은 정의를 또 한 번 작성해야 한다는 것</strong>이었다. <code>useTodoDetail</code>은 hook이라 컴포넌트 밖에서 호출할 수 없으니, 라우터의 loader나 이벤트 핸들러에서는 다시 <code>queryClient.prefetchQuery({ queryKey: [...], queryFn: ... })</code>를 적어야 했다.</p>
<p><code>queryOptions</code>를 쓰면 이 중복이 사라진다.</p>
<p>같은 정의 하나가 어디서든 동작한다. 그래서 TkDodo는 "v5에서는 hook을 만들기보다 queryOptions를 정의하라"고 권한다. hook은 필요할 때만 얇게 감싸는 도구가 되고, 도메인 정의는 hook 없이도 자족적으로 존재한다.</p>
<h2 id="mutation-invalidation"><a class="anchor" href="#mutation-invalidation">Mutation Invalidation</a></h2>
<p>queryKey의 계층 구조가 진짜로 빛을 발하는 자리는 mutation 후 무효화이다. TanStack Query의 <a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-invalidation" target="_blank" rel="noopener noreferrer">Query Invalidation</a> 문서에 따르면 <code>invalidateQueries</code>는 <strong>prefix matching</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 도메인 전체"를 무효화하려고 두 줄을 따로 호출하거나, 별도 prefix 상수를 만들어 관리해야 했을 것이다. (그리고 새 도메인 키가 추가될 때마다 그 상수에 추가하는 걸 깜빡하면, 무효화에서 누락되는 버그가 발생한다.)</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">Leveraging the Query Function Context</a>에 따르면, <strong>queryKey와 queryFn의 의존성을 강제로 동기화</strong>할 수 있기 때문이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> sortBy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'name'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>이 코드는 queryFn이 외부 변수에 의존하고 있는 위험한 코드다. 그리고 <code>sortBy</code>가 바뀌어도 캐시가 갱신되지 않는다. 키에 의존성을 안 넣었기 때문이다. 그런데 <code>queryFn</code>이 외부 클로저에서 변수를 끌어다 쓰는 한, 이런 실수는 언제든 일어날 수 있다.</p>
<p>해결책은 단순하다. <code>queryFn</code>이 외부 변수에 의존하지 않게 만드는 것이다. <strong>모든 의존성을 queryKey에서 꺼내 쓰면</strong>, queryKey에 안 들어간 변수는 애초에 함수 안에서 쓸 수 없게 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { sortBy }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [, { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sortBy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>이렇게 짜두면 의존성이 새로 생겼을 때 queryKey에 안 넣고는 함수 안에서 쓸 방법이 없다. 컴파일러가 "그런 키 없는데?"라고 잡아준다. 키와 함수의 동기화를 컨벤션이 아니라 <strong>타입 시스템에 위임</strong>하는 것이다.</p>
<h2 id="어디까지-분리할-것인가"><a class="anchor" href="#어디까지-분리할-것인가">어디까지 분리할 것인가</a></h2>
<p>여기까지 읽으면 한 가지 의문이 들 수 있다. "그럼 모든 쿼리를 다 <code>queryOptions</code>로 빼야 하나?"</p>
<p>필자의 답은 늘 그렇듯, <strong>"상황에 따라 다르다"</strong> 이다.</p>
<p>기억해둘 것은, <strong>추상화가 늘 좋은 것은 아니라는 점</strong>이다. 한 번만 쓰이는 쿼리를 굳이 도메인 factory로 빼면, 코드를 읽는 사람이 두 파일을 오가야 할 뿐이다. queryKey 관리 패턴의 진화는 "더 정교한 도구를 항상 써야 한다"가 아니라, <strong>"필요해진 시점에 사다리를 한 칸씩 올라갈 선택지가 있다"</strong> 는 의미로 받아들이는 게 좋다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, queryKey는 <strong>TanStack Query가 비동기 데이터를 식별하고 캐싱하는 가장 근본적인 단위</strong>이다. 그 작은 배열 안에는 캐시 슬롯의 식별자, 의존성 배열, 무효화의 범위, v5에 들어와서는 데이터 타입 정보까지 응축되어 있다. 그리고 이 한 점에 그렇게 많은 책임이 모여 있기 때문에, 어떻게 작성하고 어떻게 관리할지가 코드베이스 전체의 인지 부하에 직결된다.</p>
<p>각 단계는 그 시점에 누군가가 마주친 실제 문제에 대한 답이었다. 그래서 단순히 "지금은 v5니까 무조건 <code>queryOptions</code>만 쓰면 된다"가 아니라, <strong>"내 코드베이스가 지금 어떤 단계의 문제를 겪고 있는가"</strong> 를 먼저 보는 게 맞는 순서이다. 인라인 배열로도 충분한 프로젝트에 도메인 factory를 도입하는 것은 그 자체로 과잉 설계가 될 수 있다.</p>
<p>이 글을 읽는 독자 분들도 자신의 프로젝트에서 한 번쯤 점검해보시길 바란다. queryKey가 코드 전체에 어떻게 흩어져 있는지, 무효화는 어떤 방식으로 이루어지고 있는지, 그리고 그 구조가 지금의 팀 규모와 도메인 복잡도에 맞는지를 말이다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, Query Keys</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-options" target="_blank" rel="noopener noreferrer">TanStack Query, Query Options</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/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"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tanstack.com/blog/announcing-tanstack-query-v5" target="_blank" rel="noopener noreferrer">TanStack, Announcing TanStack Query v5</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[queryKey 비교 원리]]></title>
            <link>https://hooninedev.com/251230</link>
            <guid isPermaLink="false">https://hooninedev.com/251230</guid>
            <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 TanStack Query가 두 queryKey를 같은 키로 판단하는 방식에 대한 이야기를 해보려고 한다. 렌더링마다 새 배열로 만들어지는 queryKey가 왜 매번 캐시 미스를 내지 않는지, 객체의 키 순서나 undefined 값이 캐시에 영향을 주는지 궁금했던 TanStack Query 사용자를 위한 글로, 읽고 나면 키를 같다고 보는...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>TanStack Query가 두 queryKey를 같은 키로 판단하는 방식</strong>에 대한 이야기를 해보려고 한다.</p>
<p>렌더링마다 새 배열로 만들어지는 queryKey가 왜 매번 캐시 미스를 내지 않는지, 객체의 키 순서나 <code>undefined</code> 값이 캐시에 영향을 주는지 궁금했던 TanStack Query 사용자를 위한 글로, 읽고 나면 키를 같다고 보는 규칙과 그 규칙이 캐시에 주는 영향을 알 수 있다. 결론부터 말하면 TanStack Query는 queryKey를 <code>hashKey</code>로 직렬화한 문자열을 캐시의 키로 쓰고, 이 과정에서 객체의 키 순서는 무시되고 배열의 요소 순서는 그대로 남는다.</p>
<p>queryKey는 TanStack Query가 쿼리 캐시를 관리하는 기준이 되는 배열이다. 같은 키는 같은 데이터를 뜻하고, <code>['user', userId]</code>의 <code>userId</code>가 바뀌어 키가 달라지면 캐시 미스가 나서 새로 fetch한다.</p>
<h2 id="querycache-내부"><a class="anchor" href="#querycache-내부">QueryCache 내부</a></h2>
<p>TkDodo의 <a href="https://tkdodo.eu/blog/inside-react-query" target="_blank" rel="noopener noreferrer">Inside React Query</a>에 따르면, <code>QueryCache</code>는 결국 <strong>메모리에 들고 있는 자료구조 하나</strong>일 뿐이다. v5의 <a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/queryCache.ts" target="_blank" rel="noopener noreferrer">공식 구현</a>에서 그 자료구조는 plain object가 아니라 <code>Map&#x3C;string, Query></code>이다. 필드 타입은 <code>QueryStore</code>이고 생성자에서 <code>new Map&#x3C;string, Query>()</code>를 넣는다. 저장과 조회는 <code>queryHash</code>를 키로 한다. 키는 queryKey의 직렬화된 형태(<code>queryHash</code>)이고, 값은 <code>Query</code> 클래스의 인스턴스이다. 이 글의 코드와 실행 결과는 <code>@tanstack/query-core</code> 5.104.1 기준이다.</p>
<p>옛 버전에서 쓰던 plain object와 달리 <code>Map</code>은 프로토타입에서 물려받은 키와 부딪힐 일이 없다.</p>
<p><code>useQuery</code>가 호출될 때마다 일어나는 일은 단순하다. <strong>queryKey를 해시값으로 변환하고, 이 해시값으로 Map에서 lookup한다.</strong> 있으면 캐시된 <code>Query</code> 인스턴스를 가져오고, 없으면 새로 만들어 <code>set</code>한다.</p>
<p>여기서 한 가지 의문이 자연스럽게 따라온다. <strong>왜 굳이 queryKey를 문자열로 직렬화할까?</strong> 그냥 <code>Map&#x3C;QueryKey, Query></code>처럼 배열 자체를 키로 쓰면 되지 않나?</p>
<p>이 의문의 답은 자바스크립트의 동등성 모델에 있다. 네이티브 <code>Map</code>은 키 비교를 <strong>참조 동등성(reference equality)</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>참조 동등성을 구조적 동등성(structural equality)으로 변환하는 것</strong>이다. queryKey의 내용만으로 결정론적인 문자열을 만들고, 그 문자열을 Map의 키로 쓴다. 그러면 "내용이 같으면 같은 키"라는 우리가 원하던 의미론이 회복된다. <code>JSON.stringify</code>는 그 변환을 해주는 가장 단순한 도구일 뿐이다.</p>
<h2 id="hashkey의-키-정렬"><a class="anchor" href="#hashkey의-키-정렬">hashKey의 키 정렬</a></h2>
<p>해시값을 만드는 함수는 <code>hashKey</code>이다. <a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/utils.ts#L284-L295" target="_blank" rel="noopener noreferrer"><code>packages/query-core/src/utils.ts</code></a>에 정의된 공식 구현은 이렇게 생겼다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MutationKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">val</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    isPlainObject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">result</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            result[key] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val[key]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">            return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          }, {} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>JSON.stringify</code>에 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter" target="_blank" rel="noopener noreferrer">replacer 콜백</a>을 끼워 <strong>plain object의 키를 사전순으로 정렬</strong>한 뒤 직렬화한다. 정확히는 <code>sort()</code>의 기본 비교인 UTF-16 코드 단위 순서라서 대문자 키가 소문자 키보다 앞에 온다.</p>
<p>이 정렬이 왜 본질적인가 하면, 문자열 직렬화에는 한 가지 더 강한 조건이 따라붙기 때문이다. <strong>의미가 같은 입력은 언제나 같은 문자열로 변환되어야 한다.</strong> 그런데 일반적인 <code>JSON.stringify</code>는 키 순서를 그대로 둔다. <code>{ a: 1, b: 2 }</code>와 <code>{ b: 2, a: 1 }</code>은 의미상 같은 객체인데도 서로 다른 문자열로 직렬화되고, 결국 둘은 서로 다른 캐시 슬롯이 된다. 이러면 같은 데이터를 두 번 요청하는 사태가 다시 발생한다.</p>
<p>이걸 일관되게 막는 기법이 <strong>canonical form(정규형)</strong> 이다. 의미상 같은 입력은 항상 유일한 하나의 표현에 대응되도록 강제하는 것이다. <code>hashKey</code>의 replacer가 plain object의 키를 정렬하는 이유가 이것이다. 어떤 순서로 들어왔든 출력이 같아지도록 만들어서, 의미가 같은 객체는 언제나 같은 문자열이 되게 한다. 반대 방향은 보장하지 않는다는 점은 뒤에서 다룬다.</p>
<p>배열을 정렬하지 않는 것도 같은 원리의 뒷면이다. 배열은 순서 자체에 의미가 실린 자료구조라서, 정렬해버리면 정보가 손실된다. 객체의 키 순서는 우연이고, 배열의 요소 순서는 의도이다. <code>hashKey</code>는 그 둘을 다르게 취급한다. 메인테이너 TkDodo가 <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a>에서 queryKey를 가장 일반적인 것부터 가장 구체적인 것 순서로 구성하라고 권하는 것도 배열 순서가 의미를 갖기 때문이다. 그가 드는 이유는 무효화다. 앞부분이 같은 키들을 <code>['todos']</code> 하나로 한꺼번에 무효화할 수 있다. 이 비교는 해시가 아니라 뒤에서 볼 prefix 매칭이 맡는다.</p>
<p>키 정렬이 적용되는 대상은 <strong>plain object</strong> 뿐이다. 같은 파일 안의 <code>isPlainObject</code>는 <code>Object.prototype.toString</code> 결과가 <code>[object Object]</code>인지와 프로토타입이 <code>Object.prototype</code>(또는 <code>null</code>)인지를 검사해서 <strong>순수 객체 리터럴</strong>과 <strong>클래스 인스턴스</strong>를 가른다. 그래서 <code>{ foo: 1 }</code> 같은 리터럴은 정렬되지만, <code>class User { ... }</code>로 만든 인스턴스는 정렬 없이 통과한다. queryKey에 클래스 인스턴스를 그대로 넣으면 키를 정렬하지 않으므로 필드를 대입한 순서대로 직렬화되어, 값이 같아도 다른 해시가 나올 수 있다.</p>
<p>쓰는 쪽에서 보면 결과는 둘이다.</p>
<h3 id="객체의-키-순서는-무관하다"><a class="anchor" href="#객체의-키-순서는-무관하다">객체의 키 순서는 무관하다</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 같은 캐시 슬롯을 공유한다</span></span></code></pre></figure>
<p>키 정렬이 없었다면 객체 리터럴을 쓸 때마다 키 순서를 외우고 있어야 했을 것이다.</p>
<h3 id="배열의-요소-순서는-중요하다"><a class="anchor" href="#배열의-요소-순서는-중요하다">배열의 요소 순서는 중요하다</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status, page], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page, status], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 다른 캐시이다</span></span></code></pre></figure>
<h2 id="직렬화가-바꾸는-값"><a class="anchor" href="#직렬화가-바꾸는-값">직렬화가 바꾸는 값</a></h2>
<p><code>undefined</code> 값은 직렬화 과정에서 사라진다는 사실도 알아두면 좋다. <code>{ a: 1, b: undefined }</code>와 <code>{ a: 1 }</code>은 같은 해시값을 만든다. (필자는 이걸 모르고 "undefined를 명시적으로 넣었으니 다른 캐시지!"라고 생각한 실수를 한 적이 있다.)</p>
<p>배열 안에서는 다르게 동작한다. 배열 원소의 <code>undefined</code>는 사라지지 않고 <code>null</code>이 된다. 그래서 <code>['user', undefined]</code>와 <code>['user', null]</code>은 같은 키이고, <code>['user']</code>와는 다른 키다. <code>['user', userId]</code>에서 <code>userId</code>가 아직 <code>undefined</code>인 경우가 여기에 해당한다. <code>enabled: false</code>로 fetch를 막아 두어도 캐시에는 <code>["user",null]</code> 슬롯이 만들어진다. <code>useQuery</code>가 내부에서 쓰는 <code>QueryObserver</code>를 같은 옵션으로 만들어 확인했다.</p>
<p><code>undefined</code>만 그런 것이 아니다. <code>hashKey</code>는 <code>JSON.stringify</code> 위에 서 있으므로, JSON이 표현하지 못하는 값은 대부분 에러 없이 다른 값으로 바뀐다. 아래 코드를 2026-10-08에 <code>@tanstack/query-core</code> 5.104.1, Node v24.16.0으로 돌렸고 주석이 실제 출력이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hashKey, QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user"]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'f'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cb</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["f",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["m",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'d'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'2025-12-30T00:00:00Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["d","2025-12-30T00:00:00.000Z"]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'mapA'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'b'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// mapA</span></span></code></pre></figure>
<p>같은 방식으로 다른 값들도 돌려 보면 이렇게 정리된다.</p>
<table>
<thead>
<tr>
<th>키에 넣은 값</th>
<th>직렬화 결과</th>
<th>같은 키로 취급되는 것</th>
</tr>
</thead>
<tbody>
<tr>
<td>배열 원소인 <code>undefined</code>, <code>NaN</code>, <code>Infinity</code>, 함수</td>
<td><code>null</code></td>
<td>그 자리에 <code>null</code>을 넣은 키</td>
</tr>
<tr>
<td>객체 속성인 <code>undefined</code>, 함수</td>
<td>속성이 사라진다</td>
<td>그 속성이 없는 키</td>
</tr>
<tr>
<td><code>Map</code>, <code>Set</code></td>
<td><code>{}</code></td>
<td>내용과 상관없이 모든 <code>Map</code>, <code>Set</code>, 빈 객체</td>
</tr>
<tr>
<td><code>Date</code></td>
<td>ISO 문자열</td>
<td>같은 ISO 문자열</td>
</tr>
<tr>
<td><code>BigInt</code></td>
<td><code>TypeError</code>를 던진다</td>
<td>없음</td>
</tr>
<tr>
<td>순환 참조</td>
<td>고리가 plain object로만 이어지면 <code>RangeError</code>, 배열이나 클래스 인스턴스를 거치면 <code>TypeError</code>를 던진다</td>
<td>없음</td>
</tr>
</tbody>
</table>
<p>가장 위험한 것은 <code>Map</code>과 <code>Set</code>이다. 위 코드에서 <code>new Map([['a', 1]])</code>을 키로 넣은 데이터가 <code>new Map([['b', 2]])</code>로 조회되어 나왔다. 에러가 없으니 다른 데이터를 화면에 그리고 있다는 단서도 없다.</p>
<p>에러로 드러나는 것은 <code>BigInt</code>와 순환 참조 둘뿐이다. 순환 참조는 무엇이 순환하느냐에 따라 에러가 갈린다. 고리가 plain object로만 이어지면 <code>RangeError: Maximum call stack size exceeded</code>로 끝나고, <code>arr.push(arr)</code>처럼 배열이나 클래스 인스턴스를 하나라도 거치면 <code>TypeError: Converting circular structure to JSON</code>으로 끝난다. replacer가 plain object마다 새 객체를 만들어 돌려주므로 <code>JSON.stringify</code>의 순환 감지가 같은 객체를 다시 만나지 못하고, 배열과 클래스 인스턴스는 replacer가 그대로 돌려주므로 순환 감지에 걸린다. 반대로 <code>Date</code>는 <code>toJSON</code>으로 ISO 문자열이 되므로, 캐시 조회에서는 같은 시각이면 같은 키가 되어 안전한 편이다.</p>
<p>필자는 한 번 <code>Date</code>를 그대로 넣어두고 "왜 같은 시점인데 캐시가 갱신되지?"라며 한참을 헤맨 적이 있다. 같은 시각을 가리키는 <code>Date</code>는 인스턴스가 달라도 같은 ISO 문자열이 되므로 같은 해시를 만든다. 매번 다른 키가 나왔다면 같은 시점처럼 보여도 시각 자체가 달랐던 것이다. 렌더 중에 <code>new Date()</code>를 만들면 렌더마다 밀리초 단위로 다른 시각이 들어가고, 그때마다 새 키가 된다.</p>
<p>그래서 queryKey에는 문자열, 숫자, 불리언, <code>null</code>과 이것들로 이루어진 배열과 plain object만 넣는 편이 안전하다.</p>
<h2 id="querykeyhashfn"><a class="anchor" href="#querykeyhashfn">queryKeyHashFn</a></h2>
<p>이 제약에는 탈출구가 있다. TanStack Query는 <code>queryKeyHashFn</code>이라는 옵션으로 <strong>해시 함수 자체를 갈아끼울 수 있게</strong> 열어둔다. 내부적으로는 <code>hashQueryKeyByOptions(queryKey, options)</code>가 옵션에 <code>queryKeyHashFn</code>이 있으면 그걸, 없으면 기본 <code>hashKey</code>를 호출하도록 분기한다.</p>
<p>갈아끼운다는 것은 <code>hashKey</code>를 통째로 대체한다는 뜻이다. 앞에서 본 키 정렬도 함께 사라지므로, 정렬이 필요하면 직접 구현해야 한다. 이 옵션이 쓸모 있는 경우는 <code>BigInt</code>처럼 기본 직렬화가 던지는 값이다. 아래 코드도 같은 환경에서 돌렸다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bigintSafeHash</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'bigint'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  defaultOptions: { queries: { queryKeyHashFn: bigintSafeHash } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">})</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'9007199254740993'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined</span></span></code></pre></figure>
<p>두 번째 출력은 <code>BigInt</code>와 같은 숫자를 담은 문자열이 같은 키가 된다는 뜻이다. 마지막 출력은 키 정렬이 사라진 결과로, 키 순서만 다른 객체를 더는 같은 키로 보지 않는다.</p>
<p>등록하는 자리도 결과를 바꾼다. 위처럼 <code>QueryClient</code>의 <code>defaultOptions</code>나 <code>setQueryDefaults</code>로 등록하면 <code>setQueryData</code>와 <code>getQueryData</code>도 그 함수를 쓴다. 두 API가 해시하기 전에 <code>defaultQueryOptions</code>로 기본 옵션을 합치기 때문이다. 반면 <code>useQuery</code> 호출에만 적으면 imperative API는 기본 <code>hashKey</code>를 써서, 같은 키가 캐시 안에서 두 슬롯으로 갈린다. v3.2.0 베타 시절에는 전역 기본값도 <code>setQueryData</code>에 적용되지 않았고, <a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">Issue #1343</a>의 제보자가 v3.2.0-beta.30에서 고쳐진 것을 확인했다.</p>
<p>그래서 실무에서는 탈출구를 쓰기보다 <strong>queryKey를 만드는 시점에 직렬화 가능한 형태로 변환해서 넣는 쪽</strong>이 훨씬 안전하다. 해시 함수를 직접 쓰면 키 정렬과 등록 위치를 모두 챙겨야 하기 때문이다.</p>
<h2 id="필터의-키-비교"><a class="anchor" href="#필터의-키-비교">필터의 키 비교</a></h2>
<p>두 키를 같다고 판단하는 방식은 하나 더 있다. 지금까지 말한 "같은 키"는 해시 문자열이 같다는 뜻이고, 해시가 쓰이는 곳은 캐시 조회와 <code>exact: true</code> 필터다. <code>invalidateQueries</code>나 <code>findAll</code> 같은 필터는 기본적으로 <code>partialMatchKey</code>로 판단하는데, 이 함수는 해시 문자열이 아니라 원래 queryKey의 구조를 재귀로 비교한다. 배열은 앞에서부터 맞춰 보고, 객체는 필터 쪽에 적힌 키만 본다. 아래 코드도 같은 환경에서 돌렸다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { partialMatchKey } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [{ status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todo'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span></code></pre></figure>
<p>해시를 거치지 않으므로 <code>queryKeyHashFn</code>을 갈아끼워도 이 매칭은 바뀌지 않는다. 앞 예시처럼 정렬 없는 해시 함수를 등록한 <code>QueryClient</code>에서 키 순서만 다른 객체를 <code>exact: true</code>로는 찾지 못하지만, prefix 매칭으로는 찾는다.</p>
<p>그래서 해시로 같은 키가 필터에서도 같다는 보장은 없다. <code>['user', undefined]</code>로 만든 쿼리는 <code>['user', null]</code> 필터의 prefix 매칭에 걸리지 않고, <code>NaN</code>이 든 키는 자기 자신으로도 매칭되지 않는다. 반대로 <code>Date</code>나 <code>Map</code>을 필터에 넣으면 열거할 속성이 없어서 같은 자리의 어떤 <code>Date</code>나 객체와도 매칭된다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>정리하면, TanStack Query는 queryKey 배열의 참조를 비교하지 않는다. <code>hashKey</code>가 plain object의 키를 정렬하며 <code>JSON.stringify</code>로 만든 문자열(<code>queryHash</code>)을 <code>Map</code>의 키로 쓴다. 그래서 객체의 키 순서는 캐시에 영향을 주지 않고, 배열의 요소 순서는 영향을 주며, 값이 <code>undefined</code>인 속성은 해시에서 없는 것과 같다. JSON이 표현하지 못하는 값은 대부분 에러 없이 다른 값으로 바뀌어, 서로 다른 키가 조용히 같은 키가 된다. <code>queryKeyHashFn</code>으로 해시 함수를 바꿀 수는 있지만 키 정렬까지 함께 버리게 되므로, 키를 만드는 시점에 직렬화 가능한 값으로 바꿔 넣는 편이 안전하다. 무효화 같은 필터는 기본적으로 해시를 거치지 않고 queryKey의 구조를 앞에서부터 맞춰 보므로, 해시에서 같은 키가 필터에서도 같다고 기대하지 않는 편이 좋다. 결국 캐시 조회는 <code>hashKey</code>로 키의 내용을 비교하고 필터는 키의 앞부분을 비교하므로, queryKey에는 직렬화해도 뜻이 바뀌지 않는 단순한 값만 넣으면 두 비교가 모두 예상대로 동작한다.</p>
<p>이 판단 기준이 queryKey를 어떻게 작성하고 관리할지로 이어지는 이야기, 즉 인라인 배열에서 query key factory를 거쳐 <code>queryOptions</code>까지 오게 된 흐름은 <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a>에서 다룬다.</p>
<p>이 글을 읽는 독자 분들도 다음에 queryKey에 객체나 <code>Map</code>을 넣을 때, 그 값이 어떤 문자열로 직렬화될지 한 번쯤 떠올려 보시길 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, Query Keys</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/classes/QueryClient" target="_blank" rel="noopener noreferrer">TanStack Query, QueryClient</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[ErrorBoundary 배치]]></title>
            <link>https://hooninedev.com/251203</link>
            <guid isPermaLink="false">https://hooninedev.com/251203</guid>
            <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 ErrorBoundary 를 몇 개 두고 어디에 둘 것인가에 대한 이야기를 해보려고 한다. React Router 와 TanStack Query 로 화면을 만들면서 실패를 라우트의 ErrorBoundary, react-error-boundary, useQuery 의 isError 중 어디서 받을지 고민하는 프론트엔드 개발자를 위한 글이다....]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong><code>ErrorBoundary</code> 를 몇 개 두고 어디에 둘 것인가</strong>에 대한 이야기를 해보려고 한다. React Router 와 TanStack Query 로 화면을 만들면서 실패를 라우트의 <code>ErrorBoundary</code>, <code>react-error-boundary</code>, <code>useQuery</code> 의 <code>isError</code> 중 어디서 받을지 고민하는 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 <code>ErrorBoundary</code> 를 둘 자리를 정하는 기준과, 자리마다 fallback 과 재시도 조건을 맞추는 방법을 얻을 수 있다.</p>
<p>먼저 결론부터 적으면, <code>ErrorBoundary</code> 의 개수는 취향이 아니라 두 가지가 정한다. <strong>무엇이 던지느냐</strong>와 <strong>그것이 죽었을 때 화면에 무엇이 남아야 하느냐</strong>다. 이 글은 뒤의 것으로 시작하고, 앞의 것은 필요한 자리에서 짚는다.</p>
<h2 id="내줘도-되는-범위"><a class="anchor" href="#내줘도-되는-범위">내줘도 되는 범위</a></h2>
<p><code>ErrorBoundary</code> 를 어디에 둘지 물으면 보통 어느 컴포넌트를 감쌀지를 생각한다. 그 질문으로는 답이 안 나온다. 감쌀 수 있는 컴포넌트는 언제나 여러 개고 어느 것을 골라도 코드는 돌아가기 때문이다.</p>
<p>바꿔 물어야 한다. <strong>이게 죽으면 화면에 무엇이 남아야 하나.</strong></p>
<p>이 질문에는 자리마다 답이 다르다. 화면의 뼈대가 되는 데이터가 없으면 그 화면은 성립하지 않는다. 이름도 상태도 없는데 동작 버튼만 덩그러니 두는 것은 의미가 없다. 반대로 곁가지 목록 하나가 실패했다고 화면 전체를 가리면, 사용자는 멀쩡히 볼 수 있었던 나머지를 전부 잃는다. 되돌리기 어려운 동작은 또 다르다. 실패했다는 사실을 누른 그 자리에서 알려야 한다.</p>
<p><strong><code>ErrorBoundary</code> 가 하는 일은 실패를 잡는 것이 아니라 fallback 이 그려지는 범위를 정하는 것이다.</strong> fallback 은 실패했을 때 원래 화면 대신 그리는 것이다. 감싼 만큼이 사라진다. 그러니 <code>ErrorBoundary</code> 의 위치는 잡고 싶은 것이 아니라 <strong>내줘도 되는 범위</strong>로 정해진다.</p>
<p>그 범위가 네 단계로 갈린다.</p>
<table>
<thead>
<tr>
<th>층 이름</th>
<th>무엇을 받나</th>
<th>무엇으로 되돌리나</th>
</tr>
</thead>
<tbody>
<tr>
<td>라우트</td>
<td>loader 가 던진 것, 아래에서 아무도 안 잡은 것</td>
<td><code>revalidate()</code></td>
</tr>
<tr>
<td>화면</td>
<td>렌더 중에 던져진 것</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>영역</td>
<td>그 안에서 던져진 것</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>컴포넌트 안</td>
<td><code>useQuery</code> 의 실패, mutation 의 실패</td>
<td><code>refetch()</code>, 토스트</td>
</tr>
</tbody>
</table>
<p><picture><source type="image/webp" srcset="/content/251203/1.720w.webp 720w, /content/251203/1.1440w.webp 1440w, /content/251203/1.1800w.webp 1800w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251203/1.png" alt="라우트 ErrorBoundary 안에 화면 ErrorBoundary 가 있고 그 안에 영역 ErrorBoundary 와 컴포넌트 안이 나란히 놓인 중첩 사각형. 왼쪽에서 loader 가 던진 것은 라우트 ErrorBoundary 로, 렌더 중에 던져진 것은 화면 ErrorBoundary 로, 그 영역의 실패는 영역 ErrorBoundary 로, 안 던지는 것은 컴포넌트 안으로 화살표가 들어온다. 컴포넌트 안에서 위로 향하는 화살표에는 빨간 가위표와 올라가지 않는다는 표시가 붙어 있다" style="max-width:min(720px, 100%);height:auto;" width="1800" height="1000" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>이름은 넷이지만 종류는 둘이다. <strong>라우트 <code>ErrorBoundary</code> 만 라우터의 것이고 나머지 둘은 트리에 두는 <code>ErrorBoundary</code> 다.</strong> 화면 <code>ErrorBoundary</code> 와 영역 <code>ErrorBoundary</code> 는 같은 컴포넌트이고 붙어 있는 자리만 다르다. 맨 아래의 컴포넌트 안은 <code>ErrorBoundary</code> 가 아니라 컴포넌트가 직접 그리는 분기다.</p>
<p>아래 절들이 이 표의 각 행을 왜 지울 수 없는지를 본다.</p>
<h2 id="지울-수-없는-세-층"><a class="anchor" href="#지울-수-없는-세-층">지울 수 없는 세 층</a></h2>
<p><code>react-error-boundary</code> 를 설치하고 나면 라우터 쪽은 지워도 될 것처럼 보인다. 이름이 같으니 하는 일도 같아 보인다. 그런데 어느 하나를 지우면 안 되는 이유가 세 가지 있다. 여기서 세는 셋은 위 표의 라우트, 화면, 영역이다. 컴포넌트 안은 <code>ErrorBoundary</code> 가 아니라 빠진다.</p>
<h3 id="errorboundary-밖의-loader"><a class="anchor" href="#errorboundary-밖의-loader">ErrorBoundary 밖의 loader</a></h3>
<p><code>ErrorBoundary</code> 는 <code>getDerivedStateFromError</code> 와 <code>componentDidCatch</code> 로 만들어진 클래스라 <strong>React 트리 안에서 잡힌 것</strong>만 온다. loader 는 렌더가 시작되기 전에 트리 밖에서 도는 함수다. 거기서 던진 것은 React 를 거치지 않으므로 아무리 감싸도 보이지 않는다. 같은 에러를 여러 자리에서 던져 보며 도착지를 확인한 과정은 <a href="/251117" target="_blank" rel="noopener noreferrer">에러 전이</a>에 정리해 두었다.</p>
<p>그래서 loader 를 쓰는 라우트가 하나라도 있으면 <strong>라우트 <code>ErrorBoundary</code> 를 지울 수 없다.</strong> 지우는 순간 그 실패는 갈 곳이 없어진다.</p>
<h3 id="revalidate-가-못-푸는-캐시"><a class="anchor" href="#revalidate-가-못-푸는-캐시">revalidate 가 못 푸는 캐시</a></h3>
<p>반대 방향도 막힌다. 라우트 <code>ErrorBoundary</code> 의 복구 수단은 <code>revalidate()</code> 인데, 이것은 loader 를 다시 돌릴 뿐 쿼리 캐시는 건드리지 않는다.</p>
<p>loader 가 없는 라우트를 생각해 보자. 화면 안에서 <code>useSuspenseQuery</code> 가 실패하면 그것도 결국 라우트 <code>ErrorBoundary</code> 가 받기는 한다. 라우터가 라우트 트리를 <code>RenderErrorBoundary</code> 라는 자기 클래스로 감싸 두었고, 그 클래스도 <code>getDerivedStateFromError</code> 를 가지고 있기 때문이다. 그런데 그 <code>ErrorBoundary</code> 의 재시도를 눌러 봐야 <strong>다시 돌릴 loader 도 없고 캐시에 박힌 에러도 그대로다.</strong> 받기는 받았는데 되돌릴 수단이 없다.</p>
<p><strong>받는 것과 되돌리는 것은 다른 일이다.</strong> <code>ErrorBoundary</code> 를 배치할 때 이 둘을 같이 보지 않으면, 받기는 하는데 아무도 못 푸는 fallback 이 생긴다.</p>
<h3 id="범위를-좁히는-아래-errorboundary"><a class="anchor" href="#범위를-좁히는-아래-errorboundary">범위를 좁히는 아래 ErrorBoundary</a></h3>
<p>셋째는 막히는 것이 아니라 너무 많이 잃는 것이다.</p>
<p>라우터는 실패했을 때 <code>ErrorBoundary</code> 를 하나 고른다. 고르는 방법이 소스에 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>매치된 라우트를 뒤에서부터 훑어 <code>ErrorBoundary</code> 를 가진 첫 라우트를 고르고, 없으면 맨 앞으로 보낸다. 중첩 라우트의 자식에 <code>ErrorBoundary</code> 가 없으면 그 자리를 <strong>부모가</strong> 맡고, 부모에도 없으면 루트가 맡는다.</p>
<p>루트가 맡으면 화면이 통째로 사라진다. 실패한 것은 안쪽 영역 하나뿐인데 헤더도 내비게이션도 같이 없어진다. 자식 라우트에 <code>ErrorBoundary</code> 를 붙이면 fallback 이 <code>&#x3C;Outlet /></code> 자리에만 그려진다.</p>
<p><strong>그래서 아래에 <code>ErrorBoundary</code> 를 하나 더 두는 것은 겹치는 일이 아니다.</strong> 같은 실패를 두 번 잡는 게 아니라 <strong>어디까지 지울지를 바꾸는 것</strong>이다. 셋을 겹쳐 두는 것처럼 보이는 배치는, 실은 서로 다른 것을 서로 다른 범위로 받게 두는 것이다.</p>
<h2 id="영역-errorboundary-와-컴포넌트-안-사이"><a class="anchor" href="#영역-errorboundary-와-컴포넌트-안-사이">영역 ErrorBoundary 와 컴포넌트 안 사이</a></h2>
<p>층을 넷으로 나누고 나면 마지막 갈림이 남는다. 없어도 화면이 성립하는 영역 하나를 <strong><code>ErrorBoundary</code> 로 올릴지 그 자리에서 받을지</strong>다.</p>
<p>어느 쪽이든 그 영역만 잃고 나머지는 지킨다. 갈리는 것은 잃는 범위가 아니라 <strong>그 자리에 무엇을 대신 그리느냐</strong>다.</p>
<p><code>ErrorBoundary</code> 로 올리면 코드가 줄어든다. 안에서 <code>useSuspenseQuery</code> 를 부르면 그 컴포넌트에는 <code>isPending</code> 도 <code>isError</code> 도 없다. 대기는 바깥 <code>Suspense</code> 가, 실패는 바깥 <code>ErrorBoundary</code> 가 받는다. 컴포넌트는 데이터가 있는 경우만 그린다.</p>
<p>아래의 <code>QueryAsyncBoundary</code> 는 그 <code>Suspense</code> 와 <code>ErrorBoundary</code> 를 한데 묶은 컴포넌트다. <code>QueryErrorResetBoundary</code> 안에 <code>ErrorBoundary</code> 를 두고 <code>onReset</code> 에 <code>reset</code> 을 이은 다음, 그 안을 <code>Suspense</code> 로 감쌌다. 이름에 <code>Query</code> 를 붙인 것은 의도다. <code>AsyncBoundary</code> 라고 부르면 어떤 비동기에나 쓸 수 있을 것처럼 읽히는데, 안에 <code>QueryErrorResetBoundary</code> 가 들어 있어서 그렇지 않다. 같은 이유로 <code>pendingFallback</code> 에 기본값을 두지 않았다. 기본값이 있으면 호출 지점 한 줄만 봐서는 무엇이 깔리는지 알 수 없다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>대신 그 영역이 통째로 fallback 으로 바뀐다. 목록처럼 통째로 사라져도 남길 것이 없는 자리라면 손해가 없다.</p>
<p>그 자리에서 받으면 반대다. <code>useQuery</code> 를 부르고 네 가지 상태(대기, 실패, 빈 결과, 결과)를 직접 그린다. 분기가 늘어나는 대신 <strong>그 자리에 맞춘 문구와 동작</strong>을 둘 수 있다. 실패한 줄에만 작은 재시도 버튼을 붙이고 싶다면 이쪽이다. <code>ErrorBoundary</code> 로 올리면 그 줄의 모양을 fallback 컴포넌트가 정하게 되는데, 그 fallback 은 대개 화면 <code>ErrorBoundary</code> 와 공유하는 것이라 한 줄짜리 실패에는 과하다.</p>
<p>기준은 이렇게 정리된다. <strong>영역이 통째로 사라져도 되면 <code>ErrorBoundary</code> 가 낫고, 그 자리에 맞춘 문구나 동작이 필요하면 <code>useQuery</code> 가 낫다.</strong> <code>ErrorBoundary</code> 는 컴포넌트에서 분기를 걷어내 주지만 화면을 정할 자유를 같이 가져간다.</p>
<p><code>ErrorBoundary</code> 로 올리지 않기로 했다면 <strong>네 가지 상태를 전부 핸들링 해줘야 한다.</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isPending</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isRefetching</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">commentsOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(postId))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isPending) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"alert"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글을 불러오지 못했어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">disabled</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isRefetching}>재시도&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (data.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>아직 댓글이 없어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p><code>isError</code> 를 빠뜨리면 실패가 조용히 빈 상태로 흘러든다. 실패한 쿼리의 <code>data</code> 는 <code>undefined</code> 인데, <code>data == null || data.length === 0</code> 같은 검사는 그 <code>undefined</code> 와 빈 배열을 구분하지 않기 때문이다. 화면에는 아직 댓글이 없다고 뜨고 서버는 그 순간 500 을 주고 있다.</p>
<p><code>isError</code> 를 먼저 거르면 그 아래에서 <code>data</code> 가 배열로 좁혀지고 <code>== null</code> 검사가 사라진다.</p>
<h2 id="층마다-다른-fallback"><a class="anchor" href="#층마다-다른-fallback">층마다 다른 fallback</a></h2>
<p>층을 놓았으니 각 층이 화면에 무엇을 그릴지 정한다. 여기서 라우트 <code>ErrorBoundary</code> 와 트리에 두는 <code>ErrorBoundary</code> 가 또 갈리는데, <strong>고르는 기준은 취향이 아니라 그 층이 에러를 어떻게 전달받느냐</strong>다.</p>
<h3 id="스스로-읽는-라우트-errorboundary"><a class="anchor" href="#스스로-읽는-라우트-errorboundary">스스로 읽는 라우트 ErrorBoundary</a></h3>
<p>라우트 <code>ErrorBoundary</code> 는 fallback 에 에러를 넘겨줄 필요가 없다. 컴포넌트가 <code>useRouteError()</code> 로 직접 읽고 복구 수단도 스스로 가져온다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RootErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRouteError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">revalidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRevalidator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{revalidate} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retrying</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'loading'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>그래서 라우트에는 컴포넌트만 꽂으면 된다. 자식 라우트의 <code>ErrorBoundary</code> 도 같은 모양이고 같은 훅을 쓴다. 둘을 가르는 것은 코드가 아니라 <strong>붙어 있는 라우트</strong>다. 어느 라우트에 붙었느냐가 곧 fallback 이 그려지는 범위다.</p>
<h3 id="넘겨받는-errorboundary"><a class="anchor" href="#넘겨받는-errorboundary">넘겨받는 ErrorBoundary</a></h3>
<p><code>react-error-boundary</code> 의 <code>ErrorBoundary</code> 는 반대다. 에러도 reset 함수도 자기가 들고 있으므로 fallback 쪽에 내려줘야 한다. 그래서 세 가지 prop 이 있고 타입 정의가 셋을 서로 배타로 묶어 둔다.</p>
<p><strong><code>fallback</code></strong> 은 완성된 엘리먼트를 그대로 받는다. <code>fallback={&#x3C;p role="alert">댓글을 불러오지 못했어요&#x3C;/p>}</code> 처럼 쓴다. 에러도 reset 함수도 안 준다. 메시지가 고정이고 재시도할 방법도 없을 때만 쓸 만하다.</p>
<p><strong><code>FallbackComponent</code></strong> 는 컴포넌트를 받아 <code>error</code> 와 <code>resetErrorBoundary</code> 를 props 로 넘긴다. 여러 <code>ErrorBoundary</code> 가 같은 fallback 을 공유할 때 깔끔한데, props 이름이 <code>resetErrorBoundary</code> 로 고정이라 받는 쪽이 그 이름을 알아야 한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CommentsFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{CommentsFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>코드의 <code>reset</code> 은 TanStack Query 의 <code>QueryErrorResetBoundary</code> 가 내려주는 함수다. 에러 상태로 남은 쿼리를 풀어 재시도가 다시 요청하게 만들고, 왜 필요한지는 <a href="/251128" target="_blank" rel="noopener noreferrer">재시도 버튼이 안 듣는 이유</a>에서 소스로 따라간다.</p>
<p><code>ErrorFallback</code> 은 <code>onRetry</code> 를 받으므로 이름이 안 맞는다. 그래서 이름을 옮기는 컴포넌트가 하나 더 든다.</p>
<p><strong><code>fallbackRender</code></strong> 는 함수를 받아 그 자리에서 렌더한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>필자는 이것을 골랐다. 이유는 <strong>그 자리에서 이름을 갈아 끼울 수 있기 때문</strong>이다. 위에서 <code>resetErrorBoundary</code> 가 <code>onRetry</code> 로 바뀌었다. 그 덕분에 <code>ErrorFallback</code> 은 <code>error</code> 와 <code>onRetry</code> 만 아는 컴포넌트가 되고, 복구 수단이 <code>revalidate</code> 인 라우트 <code>ErrorBoundary</code> 와 <code>resetErrorBoundary</code> 인 <code>ErrorBoundary</code> 가 <strong>같은 fallback 을 쓴다.</strong></p>
<p><strong>두 층의 화면이 어긋나지 않는 것이 이 선택의 값이다.</strong> 층을 넷으로 나누면 사용자가 보는 실패 화면도 넷이 될 위험이 있는데, 이름을 한 번 갈아 끼우는 것으로 하나가 된다.</p>
<h2 id="재시도를-붙이지-않을-실패"><a class="anchor" href="#재시도를-붙이지-않을-실패">재시도를 붙이지 않을 실패</a></h2>
<p>fallback 에 재시도 버튼을 달아도 모든 실패가 풀리지는 않는다. <code>ErrorBoundary</code> 는 자기 상태만 되돌리기 때문이다. 쿼리의 에러는 <code>QueryErrorResetBoundary</code> 의 <code>reset</code> 을 <code>onReset</code> 에 이어야 다시 요청되고, 서버가 200 으로 준 예상 밖의 값을 읽다 난 렌더 에러는 <code>queryFn</code> 에서 미리 검사해 쿼리의 에러로 바꿔야 풀리며, <code>React.lazy</code> 의 청크 로드 실패는 새로 고침으로만 풀린다. 세 경우를 소스로 확인한 과정은 앞서 링크한 "재시도 버튼이 안 듣는 이유"에 따로 정리해 두었다. 이렇게 복구가 되는 실패와 안 되는 실패가 갈리니 버튼도 갈라야 한다.</p>
<p>모든 실패에 같은 버튼을 보여주면 사용자에게 <strong>할 수 없는 행동을 안내하는 셈</strong>이 된다. 404 에서 재시도를 눌러 봐야 같은 404 가 온다. 권한이 없어서 받은 403 도 같다. 청크 로드 실패는 위에서 본 대로 아예 안 듣는다.</p>
<p>공유하는 fallback 안에서 한 번만 갈라 두면 된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> describe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isChunkLoadError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새 버전이 배포됐어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새로 고침하면 이어서 볼 수 있어요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reload'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isRouteErrorResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 404</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'찾을 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'주소가 바뀌었거나 삭제된 항목이에요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 400</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'요청을 처리할 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'입력한 내용을 다시 확인해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'불러오지 못했어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'잠시 후 다시 시도해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'retry'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>isRouteErrorResponse</code> 가 여기 같이 있는 이유가 있다. 라우트 <code>ErrorBoundary</code> 와 트리에 두는 <code>ErrorBoundary</code> 가 fallback 을 공유하므로 이 함수가 <strong>두 종류의 에러를 다 받는다.</strong> 라우터가 던진 <code>Response</code> 에서 상태 코드를 읽는 방법과 직접 만든 HTTP 에러에서 읽는 방법이 다르기 때문에 둘 다 본다.</p>
<p>거대한 분기를 처음부터 만들 필요는 없다. <strong>다시 눌러서 결과가 달라질 실패와 그렇지 않은 실패</strong>만 갈라 두면 된다.</p>
<h2 id="errorboundary-가-실패를-보는-시각"><a class="anchor" href="#errorboundary-가-실패를-보는-시각">ErrorBoundary 가 실패를 보는 시각</a></h2>
<p><code>ErrorBoundary</code> 를 다 놓았는데 아직 하나가 남았다. <strong><code>ErrorBoundary</code> 는 언제 보는가.</strong></p>
<p>층은 위에서 아래로 정했지만 실행 순서는 반대다. 재시도가 전부 소진된 뒤에야 <code>ErrorBoundary</code> 가 무언가를 본다. 그래서 재시도 조건은 <strong><code>ErrorBoundary</code> 설계의 일부</strong>다.</p>
<p>기본값이 자리마다 다르다. 둘 다 같은 <code>createRetryer</code> 를 쓰는데 넘기는 값이 다르다. 쿼리는 아무것도 넘기지 않아 <code>retryer.js</code> 의 기본을 받는다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> retry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> config.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>mutation 은 <code>mutation.js</code> 에서 직접 0 을 넣는다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retry</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.options.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span></code></pre></figure>
<p><strong>mutation 이 0 이라는 것은 요청이 한 번 오류로 끝나면 그 동작이 바로 실패한다는 뜻이다.</strong> 되돌리기 어려운 동작에 재시도가 없는 것은 안전한 기본값이지만, 그 동작이 멱등하다면 한 번쯤 더 보내는 편이 사용자에게 낫다.</p>
<p>양쪽에 같은 조건을 명시하는 편이 낫다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> retryOnServerError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (failureCount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>failureCount</code> 는 0 에서 시작하고 <strong>첫 실패 때 0 으로 물어본다.</strong> 그래서 <code>MAX_RETRY</code> 가 2 면 총 세 번 보내고 멈춘다. 재시도 횟수이지 시도 횟수가 아니다.</p>
<p><strong>4xx 를 뺀 것이 핵심이다.</strong> 요청 자체가 틀린 것이라 몇 번을 보내도 같은 답이 온다. 그런데 뺄 이유가 낭비만은 아니다. 기본 지연이 지수적으로 늘어난다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultRetryDelay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1e3</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> **</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> failureCount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3e4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>첫 재시도가 1초, 그 다음이 2초이므로 두 번만 재시도해도 3초가 지나간다. 4xx 를 거르지 않으면 <strong>고칠 수 없는 실패를 3초 동안 숨기는 셈</strong>이다.</p>
<p>렌더 에러와 청크 실패에는 이 시간이 없다. 요청을 보내지 않으니 재시도 자체가 없고 던진 순간 <code>ErrorBoundary</code> 가 본다. <strong>같은 fallback 이 어떤 실패에는 3초 뒤에, 어떤 실패에는 즉시 뜨는 이유가 이것이다.</strong></p>
<p>재시도를 켜기 전에 확인할 전제가 하나 있다. 그 요청이 <strong>멱등한가.</strong> 같은 요청을 두 번 보내도 결과가 같아야 한다. 서버가 그것을 보장하지 않으면 재시도는 버그를 만드는 기능이다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>이 글에서는 실패를 받을 자리마다 무엇을 놓을지를 정해보았다. 내용을 정리해보면 아래와 같다.</p>
<ul>
<li><code>ErrorBoundary</code> 의 위치는 내줘도 되는 범위로 정해진다. 무엇을 감쌀지가 아니라 이게 죽으면 무엇이 남아야 하는지로 묻는다.</li>
<li>세 층의 <code>ErrorBoundary</code> 는 어느 하나도 지울 수 없다. loader 가 던진 것은 트리의 <code>ErrorBoundary</code> 가 못 받고, <code>revalidate</code> 는 쿼리 캐시를 못 풀고, 아래에 하나 더 두는 것은 범위를 좁히는 일이다.</li>
<li>영역을 통째로 버려도 되면 <code>ErrorBoundary</code>, 그 자리의 문구가 필요하면 <code>useQuery</code> 다. 후자를 고르면 네 가지 상태를 전부 다뤄야 한다.</li>
<li><code>fallbackRender</code> 로 이름을 갈아 끼우면 층이 달라도 fallback 이 하나가 된다.</li>
<li>재시도는 던진 쪽의 상태를 풀어야 듣는다. 쿼리는 <code>reset</code>, 렌더 에러는 <code>queryFn</code> 으로 올려서, 청크는 새로 고침이다.</li>
<li><code>ErrorBoundary</code> 가 실패를 보는 시각은 재시도 조건이 정한다. 4xx 를 거르지 않으면 고칠 수 없는 실패를 몇 초 숨긴다.</li>
</ul>
<p><code>ErrorBoundary</code> 하나와 토스트면 충분하지 않냐고 물을 수 있다. 화면이 두세 개인 제품이라면 그럴듯한 말이고, 사실 <strong>층의 개수는 제품이 정한다.</strong> 화면이 한 장이면 내줄 것도 한 장뿐이라 범위의 차이가 안 보인다. 화면 안에 독립적인 영역이 늘어날수록 그 차이가 커진다.</p>
<p>다만 개수와 무관하게 남는 것이 있다. 트리에 <code>ErrorBoundary</code> 를 몇 개 두든 <code>loader</code> 가 던진 것은 거기로 가지 않고, 재시도는 던진 쪽의 상태를 풀지 않으면 듣지 않는다. <strong>줄일 수 있는 것은 층이지 이 사실들이 아니다.</strong></p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">TanStack Query, Suspense</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/react-query-error-handling" target="_blank" rel="noopener noreferrer">TkDodo, React Query Error Handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/mastering-mutations-in-react-query" target="_blank" rel="noopener noreferrer">TkDodo, Mastering Mutations in React Query</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>에러핸들링</category>
        </item>
        <item>
            <title><![CDATA[재시도 버튼이 안 듣는 이유]]></title>
            <link>https://hooninedev.com/251128</link>
            <guid isPermaLink="false">https://hooninedev.com/251128</guid>
            <pubDate>Fri, 28 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 ErrorBoundary 의 재시도 버튼이 왜 안 듣는지에 대한 이야기를 해보려고 한다. react-error-boundary 의 fallback 에 재시도 버튼을 달았는데 눌러도 같은 화면이 다시 나오는 프론트엔드 개발자를 위해, 실패 상태가 남는 세 경우와 경우마다 푸는 방법을 정리한 글이다. 짧게 답하면 ErrorBoundary 는 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong><code>ErrorBoundary</code> 의 재시도 버튼이 왜 안 듣는지</strong>에 대한 이야기를 해보려고 한다.</p>
<p><code>react-error-boundary</code> 의 fallback 에 재시도 버튼을 달았는데 눌러도 같은 화면이 다시 나오는 프론트엔드 개발자를 위해, 실패 상태가 남는 세 경우와 경우마다 푸는 방법을 정리한 글이다. 짧게 답하면 <code>ErrorBoundary</code> 는 자기 상태만 되돌리고 실패를 만든 상태는 던진 쪽에 그대로 남기 때문이다.</p>
<p>예시는 TanStack Query 와 <code>react-error-boundary</code> 를 함께 쓰는 구성이다. 라이브러리의 동작은 설치본 소스를 열어 확인했고, 인용한 코드는 <code>@tanstack/react-query</code> 5.104.1, <code>react-error-boundary</code> 6.1.6, React 19.2.3 의 빌드 파일과 구조가 같다(2026-10-08 대조). 들여쓰기와 줄 나눔은 읽기 쉽게 바꾼 곳이 있다.</p>
<h2 id="재시도가-듣지-않는-세-경우"><a class="anchor" href="#재시도가-듣지-않는-세-경우">재시도가 듣지 않는 세 경우</a></h2>
<p>원인은 세 가지이고 푸는 자리도 저마다 다르다. 공통점은 하나다. <strong><code>ErrorBoundary</code> 는 자기 상태만 되돌린다.</strong> 던진 쪽이 들고 있는 상태는 던진 쪽에서 풀어야 한다.</p>
<h3 id="reset-이-푸는-쿼리-에러"><a class="anchor" href="#reset-이-푸는-쿼리-에러">reset 이 푸는 쿼리 에러</a></h3>
<p><code>resetErrorBoundary()</code> 가 하는 일은 <code>ErrorBoundary</code> 의 내부 상태(<code>didCatch</code>)를 되돌리는 것뿐이다. children 이 다시 마운트되면 쿼리 훅은 캐시를 다시 읽는다. 그런데 그 쿼리는 캐시에 <strong>에러 상태로 박혀 있다.</strong> 그래서 훅은 렌더 중에 같은 에러를 바로 던지고 <code>ErrorBoundary</code> 는 다시 fallback 을 그린다.</p>
<p>왜 재요청하지 않고 옛 에러를 쓰는지도 소스에 있다. <code>errorBoundaryUtils.js</code> 가 재마운트 때의 재요청을 이렇게 끈다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (options.suspense </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) options.retryOnMount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>바깥의 가드부터 읽어야 한다. <strong>이 조건은 던지는 쿼리에만 걸린다.</strong> <code>suspense</code> 이거나 <code>throwOnError</code> 를 켠 쿼리가 <code>isReset()</code> 이 거짓인 채로 마운트되면 <code>retryOnMount</code> 가 <code>false</code> 가 되어 재요청이 꺼진다. 던지지 않는 <code>useQuery</code> 는 해당이 없어 재마운트하면 그냥 다시 요청한다.</p>
<p>다만 <code>retryOnMount</code> 를 끄는 것이 효과를 내는 것은 캐시에 데이터가 한 번도 없었던 쿼리, 즉 첫 로드에서 실패한 쿼리뿐이다. 데이터가 있던 쿼리는 실패하면 <code>isInvalidated</code> 가 참이 되어 stale 로 취급되고, 재마운트 때 <code>refetchOnMount</code> 로 다시 요청한다. 이 글이 말하는 쿼리 에러는 첫 로드의 실패다.</p>
<p>그렇다고 재요청이 영원히 꺼져 있지는 않다. fallback 이 떠 있는 동안에는 그 쿼리를 보는 컴포넌트가 없어 비활성 쿼리가 되고, <a href="https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">TanStack Query 의 기본값</a>대로 5분이 지나면 캐시에서 지워진다. 그 뒤에 누르면 캐시에 에러가 없으니 처음부터 다시 요청한다. 그래서 <code>onReset</code> 을 빠뜨린 코드도 한참 뒤에 누르면 되는 것처럼 보인다. 재현이 들쭉날쭉하다면 누른 시점이 <code>gcTime</code> 의 앞인지 뒤인지부터 본다.</p>
<p><picture><source type="image/webp" srcset="/content/251128/1.720w.webp 720w, /content/251128/1.1440w.webp 1440w, /content/251128/1.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251128/1.png" alt="위쪽은 onReset 을 잇지 않았을 때의 흐름으로 재시도 클릭, ErrorBoundary 해제, 재마운트, 캐시의 에러를 다시 던짐이 이어지고 마지막에서 첫 칸으로 빨간 화살표가 되돌아와 같은 fallback 이라고 적혀 있다. 아래쪽은 onReset 을 이었을 때로 재시도 클릭, onReset 과 reset() 호출, ErrorBoundary 해제와 재마운트, 다시 요청이 파란 화살표로 한 방향으로 이어진다" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong><code>retryOnMount</code> 가 꺼지는 것은 <code>ErrorBoundary</code> 로 올린 쿼리뿐이고, 그래서 <code>ErrorBoundary</code> 와 쿼리를 같이 풀어야 한다.</strong> <code>isReset()</code> 이 읽는 플래그를 세우는 것이 <code>QueryErrorResetBoundary</code> 다. 소스를 열면 상태가 boolean 하나다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	isReset </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p>플래그를 세우기만 하고 아무도 내리지 않는다면 그 뒤의 에러에서도 <code>retryOnMount</code> 가 꺼지지 않아 매번 다시 요청할 것이다. 내리는 쪽은 쿼리 훅이다. 재마운트된 훅은 렌더 중에 플래그를 읽고 <code>retryOnMount</code> 를 끄지 않는다. 같은 플래그를 보는 <code>getHasError</code> 도 캐시의 에러를 던지지 않는다. 그리고 화면에 붙은 뒤 effect 에서 플래그를 내린다. 같은 <code>errorBoundaryUtils.js</code> 에 있는 함수다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useClearResetErrorBoundary</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">errorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">		errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">clearReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	}, [errorResetBoundary]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>그래서 boolean 하나로 이번 한 번만 다시 시도하게 된다. 재시도 한 번을 순서대로 적으면 이렇다.</p>
<ol>
<li><code>reset()</code> 이 <code>isReset</code> 플래그를 세운다.</li>
<li>재마운트된 쿼리 훅이 렌더 중에 플래그를 보고 <code>retryOnMount</code> 를 끄지 않는다.</li>
<li>플래그가 서 있으면 훅은 캐시의 에러 대신 요청 중 상태를 보고 다시 요청한다(<code>useSuspenseQuery</code> 는 렌더 중에 요청을 보내며 suspend 한다).</li>
<li>요청이 성공해 화면에 붙으면 effect 의 <code>clearReset()</code> 이, 다시 실패하면 그 요청의 <code>catch</code> 가 플래그를 내린다.</li>
<li>플래그가 내려갔으므로 그 뒤의 에러에는 다시 <code>retryOnMount</code> 가 꺼진다.</li>
</ol>
<p>이 <code>reset</code> 을 <code>ErrorBoundary</code> 의 <code>onReset</code> 에 이어 주면 된다. TanStack Query 의 문서와 소스 주석도 이렇게 잇는 코드를 예제로 싣고 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Props</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{pendingFallback}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>react-error-boundary</code> 의 <code>resetErrorBoundary</code> 는 이렇게 생겼다. 빌드된 파일이라 이름이 한 글자로 줄어 있지만 구조는 그대로 읽힌다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">didCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  t </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.props.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.({ args: e, reason: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"imperative-api"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }), </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(d));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>didCatch</code> 가 참일 때만 <code>onReset</code> 을 부르고 상태를 초기값 <code>d</code>(<code>didCatch: false</code>)로 되돌린다. <code>setState</code> 는 핸들러가 끝난 뒤에 렌더되므로, children 이 다시 마운트될 때는 <code>reset()</code> 이 세운 플래그가 이미 서 있다. 그래서 <code>onReset</code> 에 <code>reset</code> 을 잇기만 하면 된다.</p>
<p><code>QueryErrorResetBoundary</code> 로 감싸지 않고 <code>useQueryErrorResetBoundary()</code> 로 <code>reset</code> 을 꺼내 <code>onReset</code> 에 이어도 재시도는 동작한다. 감싼 경계가 없으면 이 훅이 모듈 전역의 기본값을 돌려주기 때문이다. 대신 <a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">Suspense 가이드</a>가 적은 대로 리셋이 전역에 걸리고, 앱 전체가 <code>isReset</code> 플래그 하나를 공유한다.</p>
<h3 id="reset-이-풀-수-없는-렌더-에러"><a class="anchor" href="#reset-이-풀-수-없는-렌더-에러">reset 이 풀 수 없는 렌더 에러</a></h3>
<p>둘째는 서버가 200 으로 예상과 다른 모양을 주고 그것을 읽는 렌더가 <code>TypeError</code> 를 던지는 경우다. 같은 <code>ErrorBoundary</code> 가 받았고 <code>onReset</code> 도 이어져 있는데 재시도가 안 듣는다.</p>
<p><code>reset</code> 이 푸는 것은 <strong>에러 상태인 쿼리</strong>다. 그런데 이 쿼리는 성공했다. 서버가 200 을 줬고 캐시에는 그 값이 정상 데이터로 들어 있다. 에러를 낸 것은 그 값을 읽은 렌더다. 그래서 <code>reset</code> 은 풀 것이 없고, 재마운트된 컴포넌트는 캐시의 같은 값을 렌더 중에 읽어 같은 줄에서 다시 던진다. 화면에 붙기 전에 던지므로 쿼리를 구독할 기회도 없다. <code>staleTime</code> 을 0 으로 두어도 다시 요청하지 않는 이유다. 이 쿼리도 <code>gcTime</code> 이 지나면 캐시에서 지워져 다시 요청되지만, 서버가 같은 값을 주는 한 같은 줄에서 다시 던진다.</p>
<p>고치는 자리는 <strong><code>queryFn</code></strong> 이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isArray</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data.comments)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TypeError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments 가 배열이 아니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p><code>fetch</code> 응답의 <code>json()</code> 은 <code>Promise&#x3C;any></code> 를 돌려주므로 그 뒤에 붙인 타입은 검사가 아니라 선언이고, 서버가 준 값을 확인하는 런타임 검사는 직접 놓아야 한다. <strong>그 검사를 <code>queryFn</code> 으로 올리면</strong> 같은 실패가 <strong>쿼리의 에러</strong>가 된다. 캐시에는 에러 상태로 남고, <code>reset</code> 이 그것을 풀고, 재시도가 다시 요청한다.</p>
<p><code>ErrorBoundary</code> 가 받아 주니 런타임 검사는 나중에 하자고 미루면, 받기는 하는데 되돌릴 수 없는 fallback 이 생긴다.</p>
<h3 id="새로-고침만-푸는-lazy"><a class="anchor" href="#새로-고침만-푸는-lazy">새로 고침만 푸는 lazy</a></h3>
<p>셋째는 청크 로드 실패다. 이번에는 <code>reset</code> 도 <code>queryFn</code> 도 관계가 없다. 상태를 들고 있는 것이 <code>lazy</code> 자체다.</p>
<p>React 의 <code>lazyInitializer</code> 는 거부를 <code>payload</code> 에 적어 두고 그 뒤로는 매번 같은 것을 다시 던진다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>다시 <code>import()</code> 하지 않는다. <code>lazy()</code> 호출은 모듈 최상위에서 한 번 일어났고 그 <code>payload</code> 는 앱이 사는 동안 그대로다. <code>ErrorBoundary</code> 를 풀어 재마운트해도 같은 에러가 다시 온다.</p>
<p>그렇다면 <code>lazy</code> 를 새로 만들어 <code>import()</code> 를 다시 부르면 어떨까. 지금까지는 브라우저가 막았다. 모듈 맵이 실패한 결과를 기억해서 같은 URL 을 다시 받지 않았다. 이 동작을 바꾸는 <a href="https://github.com/whatwg/html/pull/10327" target="_blank" rel="noopener noreferrer">HTML 명세 변경</a>이 2026-07-15 에 병합됐다. 2026-10-08 에 확인한 엔진별 상태는 이렇다. Firefox 는 <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2055211" target="_blank" rel="noopener noreferrer">155 에 반영해</a> 2026-09-01 에 출시했다. WebKit 은 2026-08-19 에 <a href="https://bugs.webkit.org/show_bug.cgi?id=319492" target="_blank" rel="noopener noreferrer">main 에 들어갔고</a>, 그 버그 기록에는 이 변경이 실린 Safari 안정판 버전이 적혀 있지 않다. Chrome 은 <a href="https://chromestatus.com/feature/5214647044145152" target="_blank" rel="noopener noreferrer">chromestatus</a> 에서 아직 Proposed 다. 그러니 Chrome 에서는 새 <code>import()</code> 도 같은 실패를 돌려준다.</p>
<p>이것은 Vite 처럼 브라우저의 네이티브 <code>import()</code> 로 청크를 받는 빌드의 이야기다. webpack 런타임은 청크를 script 태그로 받고 실패한 청크 기록을 지우므로, 새 <code>lazy</code> 로 다시 부르면 다시 요청한다.</p>
<p>어느 쪽이든 이미 만든 <code>lazy</code> 는 다시 시도하지 않으므로, 이 실패의 기본 복구는 페이지를 다시 받는 것이다. 브라우저가 다시 받아 주게 되어도 전부 풀리지는 않는다. 청크 로드 실패는 네트워크가 끊겨서도 나고, <a href="https://vite.dev/guide/build#load-error-handling" target="_blank" rel="noopener noreferrer">Vite 문서</a>가 설명하듯 새 배포가 옛 청크를 지워서도 난다. 지워진 청크는 다시 요청해도 없으니 그 경우의 복구는 여전히 새로 고침이다. 원인을 하나로 단정할 수 없으므로 fallback 문구도 새 버전이 나왔다고 못박기보다 새로 고침을 권하는 편이 낫다.</p>
<p>정리하면 쿼리의 에러는 <code>reset</code> 으로, 렌더 에러는 <code>queryFn</code> 에서 미리 쿼리의 에러로 바꿔서, 청크 실패는 새로 고침으로 푼다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>재시도 버튼이 안 듣는다면 버튼이나 <code>ErrorBoundary</code> 보다 먼저 <strong>무엇이 던졌고 그 상태가 어디에 남아 있는지</strong>를 확인해 보시길 바란다. 쿼리 캐시인지, 서버가 준 값을 읽은 렌더인지, <code>lazy</code> 인지에 따라 푸는 자리가 다르다.</p>
<p><code>ErrorBoundary</code> 를 화면의 어디에 몇 개 둘지, 재시도 버튼을 아예 붙이지 않을 실패는 무엇인지는 <a href="/251203" target="_blank" rel="noopener noreferrer">ErrorBoundary 배치</a>에서 다룬다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>에러핸들링</category>
        </item>
        <item>
            <title><![CDATA[에러 전이]]></title>
            <link>https://hooninedev.com/251117</link>
            <guid isPermaLink="false">https://hooninedev.com/251117</guid>
            <pubDate>Mon, 17 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 프론트엔드에서 에러가 어디까지 올라가는가에 대한 이야기를 해보려고 한다. ErrorBoundary 를 그리는 일은 쉽다. 라우트에 하나 두고, 화면마다 ErrorBoundary 로 감싸고, 그림을 그리면 빈 곳이 없다. 어려운 것은 그 ErrorBoundary 가 실제로 무엇을 받는지 아는 것이다. 제목의 "전이" 는 던져진 에러가 어느 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>프론트엔드에서 에러가 어디까지 올라가는가</strong>에 대한 이야기를 해보려고 한다.</p>
<p><code>ErrorBoundary</code> 를 그리는 일은 쉽다. 라우트에 하나 두고, 화면마다 <code>ErrorBoundary</code> 로 감싸고, 그림을 그리면 빈 곳이 없다. 어려운 것은 그 <code>ErrorBoundary</code> 가 <strong>실제로 무엇을 받는지</strong> 아는 것이다.</p>
<p>제목의 "전이" 는 <strong>던져진 에러가 어느 자리까지 올라가 누가 받는가</strong>를 뜻한다. 던진 자리와 받는 자리가 늘 같지 않아서 따로 세는 것이다.</p>
<p>그래서 세어 보기로 했다. 같은 <code>new Error('boom')</code> 을 자리만 바꿔 가며 일곱 번 던지고, 그것이 <code>ErrorBoundary</code> 에 닿는지 아니면 <code>ErrorBoundary</code> 를 지나쳐 어디로 가는지를 보았다.</p>
<p>아래 위젯이 그 실험이다. 왼쪽에서 던질 자리를 고르면 점선 상자 안에서 실제로 그 에러가 던져진다. 점선 상자가 <code>ErrorBoundary</code> 이고, <code>ErrorBoundary</code> 가 받으면 안이 fallback 으로 바뀐다. 받지 못한 것은 아무 일도 안 일어난 것처럼 보이므로, 위젯이 <code>window</code> 의 <code>error</code> 와 <code>unhandledrejection</code> 을 같이 듣고 그것이 어디로 갔는지 아래에 적는다.</p>
<div class="interactive-widget" data-widget="error-propagation"></div>
<p>일곱 중 넷만 <code>ErrorBoundary</code> 에 닿는다. 나머지 셋은 <code>ErrorBoundary</code> 안에서 던져졌는데도 <code>ErrorBoundary</code> 를 그대로 지나쳐 전역으로 나간다.</p>
<p>이 글에서는 그 갈림이 왜 생기는지를 에러의 종류별로 확인한다. 컴파일 타임에 끝나는 것, 렌더와 생명주기에서 나는 것, 서버 데이터에서 오는 것, 페이지 전환에서 나는 것, 외부 라이브러리가 던지는 것, 이벤트와 비동기에서 나는 것이다. 각각이 어디로 전이되는지를 알아보는 것이다. <strong>받을 자리를 몇 층으로 나누고 어떻게 되돌릴지는 다루지 않는다.</strong> 전이 경로를 모르고 층부터 그리면 그 층이 빈 상자가 되기 때문에 순서를 이렇게 잡았다.</p>
<p>확인은 두 가지로 했다. 라이브러리 동작은 기억이 아니라 설치본 소스를 열어 읽었고, 던져 보고 확인할 수 있는 것은 위 위젯으로 실제로 돌렸다.</p>
<h2 id="errorboundary"><a class="anchor" href="#errorboundary">ErrorBoundary</a></h2>
<p>먼저 <code>ErrorBoundary</code> 자체를 정확히 두자. <code>ErrorBoundary</code> 는 <strong>클래스 컴포넌트의 생명주기 메서드 두 개</strong>다. 다른 이름으로 부를 뿐이다.</p>
<p><code>react-error-boundary</code> 에는 두 가지 구현체만 존재한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">static </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDerivedStateFromError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">componentDidCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e, t) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>React 공식 문서가 두 메서드가 도는 시점을 갈라 둔다. <code>getDerivedStateFromError</code> 는 <strong>렌더 단계</strong>에서 돌아 fallback 을 그릴 상태를 만들고, <code>componentDidCatch</code> 는 <strong>커밋 단계</strong>에서 돌아 로깅 같은 부수 효과를 맡는다. 어느 쪽이든 <strong>React 가 트리 안에서 잡아 넘겨준 것</strong>만 받는다.</p>
<p>그러니 <code>ErrorBoundary</code> 가 받는 것의 정의는 하나다. <strong>React 가 잡을 수 있는 자리에서 던져졌는가.</strong></p>
<p>React 문서는 받지 않는 쪽도 적어 두었다. 아래 네 가지가 그것이다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>이벤트 핸들러, 서버 렌더링, <code>ErrorBoundary</code> 자신이 던진 것, 비동기 코드(예를 들어 <code>setTimeout</code> 이나 <code>requestAnimationFrame</code> 콜백); 예외가 하나 있는데 <code>useTransition</code> 훅이 돌려주는 <code>startTransition</code> 함수다. 그 transition 함수 안에서 던진 것은 Error Boundary 가 잡는다.</p></div><div class="quote-original" lang="en"><p>Event handlers, Server side rendering, Errors thrown in the error boundary itself (rather than its children), Asynchronous code (e.g. <code>setTimeout</code> or <code>requestAnimationFrame</code> callbacks); an exception is the usage of the <code>startTransition</code> function returned by the <code>useTransition</code> Hook. Errors thrown inside the transition function are caught by error boundaries</p></div></blockquote>
<p>위 위젯의 결과가 이 문장과 정확히 맞는다. 일곱 자리가 두 도착지로 갈린다.</p>
<p><picture><source type="image/webp" srcset="/content/251117/1.720w.webp 720w, /content/251117/1.1440w.webp 1440w, /content/251117/1.1760w.webp 1760w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/1.png" alt="왼쪽에 던진 자리 일곱 개가 세로로 놓이고 오른쪽에 도착지 두 개가 있다. 렌더 중, useEffect 안, startTransition 안, lazy 의 import 거부 넷은 파란 화살표로 ErrorBoundary 로 가고, onClick 핸들러 안, setTimeout 콜백 안, Promise 거부 셋은 회색 화살표로 window 로 간다" style="max-width:min(720px, 100%);height:auto;" width="1760" height="940" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><code>startTransition</code> 이 예외인 이유는 그 안의 작업이 React 의 스케줄러를 거치기 때문이다. React 가 그 실행을 자기 손으로 감싸고 있으므로 잡아서 트리로 되돌릴 수 있다. 같은 이유로 <code>setTimeout</code> 은 못 잡는다. 그 콜백이 도는 시점에는 React 가 그 자리에 없다.</p>
<p>우선 하나 알아야할 내용이 있다. <strong>함수 컴포넌트로는 <code>ErrorBoundary</code> 를 만들 수 없다.</strong></p>
<p>React 는 던져진 에러를 만나면 그 자리에서 부모 방향으로 올라가며 받을 경계를 찾는다. 그 순회를 맡은 <code>throwException</code> 은 fiber 마다 붙어 있는 <code>tag</code> 를 보는데, 멈춰 서는 값은 클래스 컴포넌트와 루트 둘뿐이다. 함수 컴포넌트의 <code>tag</code> 는 그 둘이 아니라서 탐색이 그냥 지나친다. <strong>경계가 되지 못하는 이유는 API 가 없어서가 아니라 탐색이 애초에 들여다보지 않기 때문이다.</strong></p>
<p><picture><source type="image/webp" srcset="/content/251117/2.720w.webp 720w, /content/251117/2.1440w.webp 1440w, /content/251117/2.2016w.webp 2016w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/2.png" alt="가로로 네 상자가 놓인다. 왼쪽부터 함수 컴포넌트 Child 와 FnBoundary 가 tag 0, 클래스 컴포넌트 ErrorBoundary 가 tag 1, 루트 HostRoot 가 tag 3 이다. Child 에서 출발한 화살표가 FnBoundary 를 지나 ErrorBoundary 에서 멈추고, HostRoot 로 이어지는 선은 점선이다" style="max-width:min(720px, 100%);height:auto;" width="2016" height="424" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>그 <code>tag</code> 는 <code>React.Component</code> 를 상속했는지가 정한다. 그래서 <code>getDerivedStateFromError</code> 를 함수에 <code>static</code> 으로 붙여 봐도 소용이 없다. 붙여서 돌려 보니 그 함수는 한 번도 불리지 않았고, 에러는 경계를 찾지 못해 루트까지 올라가 React 가 트리를 화면에서 걷어냈다.</p>
<p>그러니 <code>react-error-boundary</code> 를 설치하는 것은 없는 기능을 더하는 일이 아니다. 6.1.6 의 <code>ErrorBoundary</code> 도 <code>Component</code> 를 상속한 클래스이고 두 메서드를 그대로 갖는다. 라이브러리가 하는 일은 그 클래스를 한 번만 쓰고 감춰 주는 것이다.</p>
<h2 id="컴파일-타임에-끝나는-것"><a class="anchor" href="#컴파일-타임에-끝나는-것">컴파일 타임에 끝나는 것</a></h2>
<p>종류를 세는 김에 가장 앞의 것부터 정리하자. 타입 에러다.</p>
<p>이것만은 사용자에게 닿지 않는다. 없는 속성을 읽거나 인자의 타입이 맞지 않으면 빌드가 막히고, 막힌 코드는 배포되지 않는다. 그래서 전이 경로를 따질 일이 없다. <strong>이 글에서 타입 에러가 빠지는 것은 중요하지 않아서가 아니라 런타임에 존재하지 않기 때문이다.</strong></p>
<p>문제는 그 다음이다. 타입 검사는 <strong>어디까지</strong> 보장하는가.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getComments</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Comment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>반환 타입이 <code>Promise&#x3C;Comment[]></code> 라고 적혀 있으므로 이 함수를 부르는 모든 코드가 배열을 받는다고 믿는다. 그런데 <code>Response</code> 의 <code>json()</code> 은 TypeScript 5.9.3 의 <code>lib.dom.d.ts</code> 에 이렇게 선언돼 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(): </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Promise</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><code>any</code> 다. 여기서부터 타입 검사가 끊긴다. 서버가 준 값은 컴파일러가 본 적이 없고, 그 뒤에 붙인 타입 인자나 반환 타입 표기는 <strong>검사가 아니라 선언</strong>이다. 그 선언을 런타임에 확인하는 코드는 아무도 넣어 주지 않는다.</p>
<p>그래서 서버가 <code>200</code> 으로 <code>{ commits: null }</code> 을 주면 <code>commits.length</code> 를 읽는 줄이 렌더 중에 <code>TypeError</code> 를 던진다. HTTP 는 성공이었고 타입도 통과했는데 화면이 깨진다.</p>
<p><strong>타입이 끝나는 자리가 런타임 검사를 놓을 자리다.</strong> 그 검사를 어디에 두느냐에 따라 같은 실패가 다른 경로로 간다. 렌더에서 읽다가 터지면 렌더 에러가 되어 <code>ErrorBoundary</code> 로 가고, 데이터를 받는 자리에서 먼저 검사해 던지면 그 요청의 실패가 된다. 아래 <strong>렌더와 생명주기</strong> 섹션에서 그 내용을 다룬다.</p>
<h2 id="렌더와-생명주기"><a class="anchor" href="#렌더와-생명주기">렌더와 생명주기</a></h2>
<p>React 트리 안에서 던진 것은 전이가 단순하다. <strong>가장 가까운 <code>ErrorBoundary</code> 가 받는다.</strong></p>
<p>렌더 중의 예외가 여기에 들어간다. 위의 <code>commits.length</code> 가 그렇고, 배열인 줄 알았던 값에 <code>map</code> 을 부르는 것도 그렇다. 이 부류는 던지는 것 말고는 할 수 있는 일이 없어서 언제나 <code>ErrorBoundary</code> 에 닿는다.</p>
<p><code>useEffect</code> 안에서 던진 것도 잡힌다. effect 는 커밋 뒤에 React 가 직접 실행하므로 그 실행을 감쌀 수 있다. 다만 effect <strong>안에서 부른 비동기 콜백</strong>은 다르다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)          </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 가 받는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 를 지나친다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span></code></pre></figure>
<p>두 코드는 같은 <code>useEffect</code> 안에 있지만 전이 경로가 다르다. <strong><code>ErrorBoundary</code> 안에 있느냐가 아니라 React 가 그 실행을 붙들고 있느냐가 기준이다.</strong></p>
<p>이 부류에서 한 가지만 더 기억하면 된다. React 19 의 개발 모드 콘솔은 잡힌 에러에 컴포넌트 이름을 붙여 준다. 위젯을 돌렸을 때 렌더와 effect 와 transition 은 <code>The above error occurred in the &#x3C;Thrower> component</code> 였고 <code>lazy</code> 만 <code>occurred in one of your React components</code> 였다. <code>lazy</code> 는 거부되는 시점에 아직 컴포넌트가 없어서 이름을 못 적는다. 스택만 보고 자리를 찾을 때 이 차이가 단서가 된다.</p>
<h2 id="서버-데이터"><a class="anchor" href="#서버-데이터">서버 데이터</a></h2>
<p>여기서부터가 프론트엔드 개발하면서 핸들링해야하는 까다로운 부분이다. 이유는 서버 실패가 <strong>자동으로 에러가 되지 않기</strong> 때문이다.</p>
<h3 id="fetch-는-서버-오류를-알아서-거부하지-않는다"><a class="anchor" href="#fetch-는-서버-오류를-알아서-거부하지-않는다">fetch 는 서버 오류를 알아서 거부하지 않는다</a></h3>
<p>MDN 이 이 점을 분명히 적어 두었다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p><code>fetch()</code> 프로미스는 요청 자체가 실패했을 때만 거부된다. 예를 들어 URL 형식이 잘못됐거나 네트워크 오류가 났을 때다. 서버가 오류를 뜻하는 HTTP 상태 코드(<code>404</code>, <code>504</code> 등)로 응답한 경우에는 거부되지 않는다.</p></div><div class="quote-original" lang="en"><p>A <code>fetch()</code> promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A <code>fetch()</code> promise does not reject if the server responds with HTTP status codes that indicate errors (<code>404</code>, <code>504</code>, etc.).</p></div></blockquote>
<p>그러니까 <code>fetch</code> 만 쓰면 <code>500</code> 응답은 <strong>성공한 Promise</strong> 다. 던져지지 않았으니 <code>ErrorBoundary</code> 도 모르고 데이터 라이브러리도 모른다. TanStack Query 문서도 이 점을 짚는다. 쿼리가 실패했다고 판정되려면 <code>queryFn</code> 이 던지거나 거부된 Promise 를 돌려줘야 하는데, <code>axios</code> 는 알아서 던지지만 <code>fetch</code> 는 그렇지 않다는 것이다.</p>
<p>그래서 서버 실패를 에러로 만드는 것은 <strong>직접 해야 하는 일</strong>이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/todos/'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todoId)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">response.ok) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Network response was not ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>이 세 줄이 없으면 이 절의 나머지는 전부 의미가 없다. 던져지지 않은 것은 어디로도 전이되지 않는다.</p>
<h3 id="실패-하나가-갈리는-다섯-갈래"><a class="anchor" href="#실패-하나가-갈리는-다섯-갈래">실패 하나가 갈리는 다섯 갈래</a></h3>
<p>실패가 실패가 되고 나면 그 다음 갈림이 시작된다. 같은 <code>500</code> 한 번이 <strong>어떻게 불렀느냐</strong>에 따라 다섯 군데로 흩어진다. 옵션을 건드리지 않은 기본값 기준이다.</p>
<p><strong><code>useQuery</code> 는 에러를 던지지 않는다.</strong> <code>useQuery.js</code> 를 열어 보면 <code>throwOnError</code> 라는 문자열이 아예 없다. 던질지 말지는 <code>query-core</code> 의 <code>shouldThrowError</code> 가 정한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">throwOnError;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>값이 없으면 <code>!!undefined</code> 이므로 <code>false</code> 다. 그래서 실패는 <code>query.error</code> 에만 들어가고 컴포넌트는 정상적으로 렌더된다. 바깥의 <code>ErrorBoundary</code> 는 끝까지 자기 차례가 온 줄 모른다.</p>
<p><strong><code>useSuspenseQuery</code> 는 던지는데 항상은 아니다.</strong> 이 훅은 옵션을 펼친 뒤 <code>throwOnError</code> 를 덮어쓴다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBaseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">options,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  enabled: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  suspense: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  throwOnError: defaultThrowOnError,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  placeholderData: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, QueryObserver, queryClient);</span></span></code></pre></figure>
<p>덮어쓰기가 <code>...options</code> 뒤에 오므로 <strong>사용자가 넘긴 <code>throwOnError</code> 는 무시된다.</strong> 그리고 그 자리에 들어가는 기본 판정이 <code>suspense.js</code> 에 한 줄로 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultThrowOnError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> query.state.data </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><strong>보여줄 캐시가 있으면 던지지 않는다.</strong> 실무에서 이 분기가 갈리는 자리는 배경 재요청이다. 처음 들어온 사용자는 캐시가 비어 있으니 실패가 <code>ErrorBoundary</code> 로 가고 fallback 을 본다. 이미 화면에 있던 사용자가 다른 탭에 갔다 돌아와 재요청이 돌고 그게 실패하면, 캐시에 옛 데이터가 있으므로 던지지 않는다. 화면은 낡은 값을 그대로 보여주고 스스로 바뀌지 않는다.</p>
<p>화면이 안 깨지는 것은 대체로 좋은 동작이다. <strong>화면이 알려주지 않는다는 것이 같이 딸려 온다</strong>는 사실을 알고 고르는 것과 모르고 당하는 것은 다르다.</p>
<p><strong>mutation 이 돌려주는 두 함수는 둘 다 <code>ErrorBoundary</code> 로 안 간다.</strong> 이유가 서로 다르다. <code>useMutation.js</code> 를 열면 한쪽은 거부를 직접 삼킨다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">observer.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(noop);</span></span></code></pre></figure>
<p>이것이 <code>mutate</code> 다. 같은 파일에서 <code>mutateAsync</code> 는 <code>result.mutate</code> 를 그대로 내보내는데, 그 <code>result.mutate</code> 는 <code>mutationObserver.js</code> 가 <code>mutate: this.mutate</code> 로 실어 보낸 것이라 결국 위 줄이 감싼 것과 같은 함수다. <strong>한쪽만 <code>.catch(noop)</code> 을 거친다.</strong> 그 거부는 <code>await</code> 한 자리에서 터지는 것이지 렌더 중에 던져지는 것이 아니므로 이쪽도 <code>ErrorBoundary</code> 와는 무관하다.</p>
<p><strong>그렇다고 mutation 이 <code>ErrorBoundary</code> 와 영영 무관한 것은 아니다.</strong> 훅 본문에 스위치가 하나 더 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.error </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(observer.options.throwOnError, [result.error])) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result.error;</span></span></code></pre></figure>
<p><code>throwOnError</code> 를 주면 이 줄이 <strong>렌더 중에</strong> 던지고, 그때는 <code>ErrorBoundary</code> 로 간다. 돌려받은 두 함수가 <code>ErrorBoundary</code> 에 안 닿는 것과 훅이 안 던지는 것은 다른 이야기다.</p>
<p><picture><source type="image/webp" srcset="/content/251117/3.720w.webp 720w, /content/251117/3.1440w.webp 1440w, /content/251117/3.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/3.png" alt="왼쪽의 서버 500 한 번에서 화살표 다섯 개가 뻗어 useQuery, useSuspenseQuery, throwOnError 켠 훅, mutate, mutateAsync 로 가고, 각각이 다시 query.error, ErrorBoundary, ErrorBoundary, mutation.error, 호출부의 catch 로 간다. 가운데 ErrorBoundary 둘만 점선으로 묶여 ErrorBoundary 가 받는 둘로 표시되어 있다" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>정리하면 같은 <code>500</code> 의 도착지는 다섯이다. <code>query.error</code> 필드, <code>mutation.error</code> 필드, 호출부의 <code>catch</code>, 그리고 <code>ErrorBoundary</code> 로 가는 두 경우다. <code>ErrorBoundary</code> 로 가는 둘은 <code>useSuspenseQuery</code> 가 캐시 없이 실패했을 때와 <code>throwOnError</code> 를 켰을 때다. <strong>실패의 종류가 아니라 호출 방식이 종착지를 정한다.</strong></p>
<h2 id="페이지-전환"><a class="anchor" href="#페이지-전환">페이지 전환</a></h2>
<p>화면을 옮길 때 나는 실패는 둘로 갈린다. 갈리는 기준은 <strong>React 트리 안이냐 밖이냐</strong>다.</p>
<h3 id="loader-는-트리-밖에서-돈다"><a class="anchor" href="#loader-는-트리-밖에서-돈다">loader 는 트리 밖에서 돈다</a></h3>
<p>라우터의 <code>loader</code> 는 렌더가 시작되기 전에 실행되는 함수다. React 컴포넌트가 아니므로 <code>getDerivedStateFromError</code> 도 <code>componentDidCatch</code> 도 닿지 않는다. <code>react-error-boundary</code> 로 아무리 감싸도 그 <code>ErrorBoundary</code> 는 loader 의 실패를 볼 수 없다.</p>
<p>대신 라우터가 자기 <code>ErrorBoundary</code> 체계를 따로 갖는다. React Router 문서가 이렇게 적는다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>route module 은 코드에서 난 에러를 자동으로 잡아 가장 가까운 <code>ErrorBoundary</code> 를 그린다.</p></div><div class="quote-original" lang="en"><p>route modules will automatically catch errors in your code and render the closest <code>ErrorBoundary</code>.</p></div></blockquote>
<p>가장 가까운 것을 고르는 방법은 소스에 있다. <code>findNearestBoundary</code> 가 이렇게 고른다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>매치된 라우트를 뒤에서부터 훑어 <code>ErrorBoundary</code> 를 가진 첫 라우트를 고르고, 없으면 맨 앞의 라우트로 보낸다. <strong>그래서 아래쪽 라우트에 <code>ErrorBoundary</code> 를 하나 더 두는 것은 겹치는 일이 아니라 fallback 이 그려지는 범위를 좁히는 일이다.</strong></p>
<p>읽는 쪽도 다르다. 라우트 <code>ErrorBoundary</code> 는 에러를 props 로 받지 않고 <code>useRouteError()</code> 로 직접 꺼낸다. 상태 코드가 실린 것인지는 <code>isRouteErrorResponse(error)</code> 로 가른다. 이 둘은 React 의 <code>ErrorBoundary</code> 에는 없는 도구다.</p>
<h3 id="lazy-의-거부는-트리-안이다"><a class="anchor" href="#lazy-의-거부는-트리-안이다">lazy 의 거부는 트리 안이다</a></h3>
<p>같은 페이지 전환이라도 코드를 늦게 받는 쪽은 반대다. <code>lazy(() => import('./Tab'))</code> 의 <code>import()</code> 가 거부되면 React 가 그것을 받아 <strong>가장 가까운 <code>ErrorBoundary</code></strong> 로 던진다. 위젯의 네 번째 버튼이 이 경로다.</p>
<p>배포가 나가면 옛 청크 파일이 사라지는데 배포 전에 열어 둔 화면은 여전히 옛 주소를 들고 있다. 그 상태로 그 코드를 요구하면 <code>import()</code> 가 거부되고 Chrome 은 <code>TypeError: Failed to fetch dynamically imported module</code> 을 던진다.</p>
<p>여기서 한 가지가 더 붙는다. <strong><code>lazy</code> 는 거부를 기억한다.</strong> React 의 <code>lazyInitializer</code> 가 결과를 <code>payload</code> 에 적어 두는데, 거부되면 상태를 바꾸고 이유를 저장한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._result </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error;</span></span></code></pre></figure>
<p>그 뒤로 이 컴포넌트를 렌더할 때마다 마지막 분기가 돈다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>다시 <code>import()</code> 하지 않는다. <code>lazy()</code> 호출은 모듈 최상위에서 한 번 일어났고 그 <code>payload</code> 는 앱이 사는 동안 그대로다. <strong><code>ErrorBoundary</code> 를 풀어 재마운트해도 같은 에러가 다시 온다.</strong> 이 실패의 복구가 페이지를 다시 받는 것뿐인 이유가 여기 있다.</p>
<p>같은 페이지 전환인데 loader 의 실패는 라우터가 받고 <code>lazy</code> 의 실패는 React 가 받는다. <strong><code>ErrorBoundary</code> 를 어디에 둘지 정하기 전에 이 둘을 갈라 두지 않으면 둘 중 하나는 갈 곳이 없다.</strong></p>
<h2 id="errorboundary-밖의-전역-핸들러"><a class="anchor" href="#errorboundary-밖의-전역-핸들러">ErrorBoundary 밖의 전역 핸들러</a></h2>
<p><code>ErrorBoundary</code> 가 못 잡은 셋은 어디로 갔는가. 바로 <strong>브라우저의 전역 핸들러</strong>다.</p>
<p>이벤트 핸들러와 <code>setTimeout</code> 콜백에서 던진 것은 결국 <code>window</code> 의 <code>error</code> 이벤트로 간다. Promise 거부는 다른 이벤트로 간다. MDN 의 정의는 이렇다.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p><code>unhandledrejection</code> 이벤트는 거부 핸들러가 없는 자바스크립트 Promise 가 거부될 때 스크립트의 전역 스코프로 보내진다. 보통은 <code>window</code> 이고 <code>Worker</code> 일 수도 있다.</p></div><div class="quote-original" lang="en"><p>The <code>unhandledrejection</code> event is sent to the global scope of a script when a JavaScript Promise that has no rejection handler is rejected; typically, this is the <code>window</code>, but may also be a <code>Worker</code>.</p></div></blockquote>
<p>이 둘이 브라우저가 마지막으로 받는 자리다. 에러 모니터링 도구가 브라우저 에러를 잡는 자리도 대부분 여기다.</p>
<p>React 19 는 트리 쪽에도 받는 자리를 하나 더 뒀다. <code>createRoot</code> 의 옵션이다. 공식 문서가 셋을 이렇게 가른다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>언제 부르나</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>onCaughtError</code></td>
<td>React 가 Error Boundary 안에서 에러를 잡았을 때</td>
</tr>
<tr>
<td><code>onUncaughtError</code></td>
<td>에러가 던져졌는데 Error Boundary 가 잡지 못했을 때</td>
</tr>
<tr>
<td><code>onRecoverableError</code></td>
<td>React 가 스스로 복구했을 때</td>
</tr>
</tbody>
</table>
<p>받는 것과 고치는 것은 다르다. <strong>전역 핸들러가 잡았다고 화면이 복구되지는 않는다.</strong> <code>onClick</code> 안에서 던진 에러를 <code>window</code> 가 받아 모니터링 도구로 보내도, 그 순간 사용자의 화면에서는 버튼이 그냥 안 눌린 것처럼 보인다. 보고와 복구는 다른 일이다.</p>
<h2 id="마무리"><a class="anchor" href="#마무리">마무리</a></h2>
<p>프론트엔드 에러 처리를 도구 목록으로 외우면 자꾸 구멍이 남는다. <code>ErrorBoundary</code>, <code>throwOnError</code>, <code>useRouteError</code>, <code>lazy</code>, <code>unhandledrejection</code> 을 다 알고도 그렇다. 도구를 몰라서가 아니라 <strong>무엇이 어디로 가는지를 안 세어 봐서</strong>다.</p>
<p>이 글에서 다룬것을 정리하면 아래와 같다.</p>
<ul>
<li><code>ErrorBoundary</code> 는 React 가 잡아 넘겨준 것만 받는다. 이벤트 핸들러와 비동기 콜백은 그 자리에 없다.</li>
<li>타입 검사는 응답에서 끝난다. <code>json()</code> 이 <code>any</code> 를 돌려주는 지점부터는 선언이지 검사가 아니다.</li>
<li>서버 실패는 저절로 에러가 되지 않는다. <code>fetch</code> 는 <code>500</code> 에 거부하지 않으므로 던지는 일을 직접 해야 한다.</li>
<li>던져진 뒤에는 호출 방식이 종착지를 정한다. 같은 실패가 필드로도, 호출부로도, <code>ErrorBoundary</code> 로도 간다.</li>
<li>페이지 전환은 둘로 갈린다. loader 는 트리 밖이라 라우터가 받고 <code>lazy</code> 는 트리 안이라 React 가 받는다.</li>
<li><code>ErrorBoundary</code> 밖에는 전역 핸들러가 있다. 잡히기는 하지만 화면이 복구되지는 않는다.</li>
</ul>
<p>그래서 <code>ErrorBoundary</code> 를 그리기 전에 할 일은 감쌀 컴포넌트를 고르는 것이 아니다. <strong>이 화면에서 실패할 수 있는 자리를 적고, 각각이 위 여섯 중 어디에 해당하는지 표시하는 것</strong>이다. 표시되지 않은 자리가 곧 구멍이다.</p>
<p>지금 여러분의 화면에서 실패할 수 있는 자리가 몇 개이고, 그중 몇 개가 <code>ErrorBoundary</code> 에 닿으며, 닿지 않는 것들은 어디로 가고 있는지 알아보면 좋겠다.</p>
<p>도착지마다 무엇으로 받을지는 <a href="/251203" target="_blank" rel="noopener noreferrer">ErrorBoundary 배치</a>에서 다룬다. 층을 몇 개로 나눌지, 실패에 대한 화면 범위를 어떻게 처리할지다. fallback 의 다시 시도 버튼이 실제로 다시 시도하게 만들려면 무엇을 같이 풀어야 하는지는 <a href="/251128" target="_blank" rel="noopener noreferrer">재시도 버튼이 안 듣는 이유</a>에서 따로 다룬다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React, Component 의 Error Boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react-dom/client/createRoot" target="_blank" rel="noopener noreferrer">React, createRoot 의 에러 콜백</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/lazy" target="_blank" rel="noopener noreferrer">React, lazy</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-functions" target="_blank" rel="noopener noreferrer">TanStack Query, Query Functions</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event" target="_blank" rel="noopener noreferrer">MDN, unhandledrejection</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch" target="_blank" rel="noopener noreferrer">MDN, fetch</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://axios.rest/pages/advanced/error-handling" target="_blank" rel="noopener noreferrer">axios, Error handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>에러핸들링</category>
        </item>
        <item>
            <title><![CDATA[React Fiber 완전 정복]]></title>
            <link>https://hooninedev.com/250520</link>
            <guid isPermaLink="false">https://hooninedev.com/250520</guid>
            <pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 React의 심장이라 할 수 있는 Fiber 아키텍처에 대한 이야기를 해보려고 한다. "작업 단위를 나눠서 처리한다"는 한 줄 정의는 알지만, 그것이 React 내부에서 어떻게 구현되는지 설명하기는 어려운 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 Fiber가 왜 등장했고 Fiber 노드가 어떻게 생겼으며, double bufferi...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 React의 심장이라 할 수 있는 <strong>Fiber 아키텍처</strong>에 대한 이야기를 해보려고 한다.</p>
<p>"작업 단위를 나눠서 처리한다"는 한 줄 정의는 알지만, 그것이 React 내부에서 어떻게 구현되는지 설명하기는 어려운 프론트엔드 개발자를 위한 글이다. 끝까지 읽으면 Fiber가 왜 등장했고 Fiber 노드가 어떻게 생겼으며, double buffering과 Lane, Work Loop가 어떻게 맞물려 Concurrent Features를 가능하게 하는지 React 소스코드를 근거로 설명할 수 있다.</p>
<p>필자가 React를 처음 접했을 때, <strong>"Fiber"</strong> 라는 단어는 면접 단골 질문 정도로만 인식되었다. "React의 렌더를 위해 작업 단위를 나눠서 처리"라는 한 줄짜리 정의를 외우고, 그게 전부인 줄 알았다. 하지만 실제로 React의 소스코드를 들여다보기 시작하면서, Fiber가 단순한 개념이 아니라 React 렌더링의 <strong>모든 것</strong>을 관장하는 런타임 아키텍처라는 사실을 깨닫게 되었다.</p>
<h2 id="왜-fiber가-등장했을까"><a class="anchor" href="#왜-fiber가-등장했을까">왜 Fiber가 등장했을까?</a></h2>
<p>이 질문에 답하려면 먼저 Fiber 이전의 세상, 즉 React 15까지 사용되던 <strong>Stack Reconciler</strong>가 어떤 문제를 가지고 있었는지 이해해야 한다.</p>
<p>Stack Reconciler는 이름 그대로 <strong>재귀(recursive) 호출 기반</strong>의 재조정 엔진이었다. 컴포넌트 트리를 위에서 아래로 재귀적으로 순회하면서, 한 번 렌더링을 시작하면 전체 트리를 끝까지 처리해야만 멈출 수 있었다. 이것은 마치 전화 통화 중에 상대방이 말을 끝낼 때까지 절대 끊을 수 없는 것과 같은 상황이었다. (상대방이 3시간짜리 인생 상담을 시작했는데 중간에 끊을 수 없다고 생각해보라. 끔찍하다.)</p>
<p>Stack Reconciler은 구체적으로 다음과 같은 한계가 있었다.</p>
<ul>
<li><strong>렌더링 중 중단 불가</strong>: 모든 트리를 한 번에 처리해야 했으므로, 복잡한 UI에서는 메인 스레드가 수십~수백 밀리초 동안 점유되었다</li>
<li><strong>우선순위 개념 부재</strong>: 사용자가 버튼을 클릭하든, 백그라운드 데이터가 갱신되든, 모든 업데이트가 동일한 방식으로 처리되었다</li>
<li><strong>애니메이션/제스처 대응 어려움</strong>: 60fps를 유지하려면 한 프레임당 약 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의 콜 스택(call stack)에 직접 의존한다는 점이다. 재귀 호출이 깊어지면 콜 스택에 프레임이 쌓이고, 이 모든 프레임이 해소될 때까지 브라우저의 메인 스레드는 다른 일을 할 수 없다.</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>반복(iterative loop)</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> 루프 안에서 한 번에 하나의 작업 단위(unit of work)만 처리하고, 시간이 부족하면 루프를 빠져나와 브라우저에게 제어권을 돌려준다는 것이다.</p>
<p>다만 이 예시는 개념을 보여주려고 <code>requestIdleCallback</code>을 쓴 것이고, 실제 React는 이를 사용하지 않는다. 이 API는 브라우저가 정한 유휴 기간에만 불리므로, 메인 스레드가 바쁜 페이지에서는 React 작업이 계속 밀릴 수 있다고 필자는 본다. 그래서 React의 Scheduler 패키지는 <code>MessageChannel</code>로 다음 매크로태스크를 예약해 작업을 이어 가고, 그 사이사이 메인 스레드에 제어권을 돌려준다. <code>setTimeout</code>이 아니라 <code>MessageChannel</code>을 고른 이유는 <a href="/250515" target="_blank" rel="noopener noreferrer">React가 MessageChannel을 쓰는 이유</a>에 따로 정리해 두었다.</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>Fiber 방식에서는 렌더링 중에도 사용자 이벤트(버튼 클릭, 타이핑 등)에 즉시 반응할 수 있다. 작업을 잘게 쪼개 실행하기 때문에, 브라우저가 숨 쉴 틈이 생기는 것이다.</p>
<p>두 방식의 차이를 직접 체험해보고 싶다면 <strong><a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">여기를</a></strong> 클릭하면 된다. Stack Reconciler와 Fiber Reconciler의 동작 차이를 눈으로 확인할 수 있다.</p>
<p>이것이 바로 Andrew Clark이 문서에서 강조한 Fiber의 핵심 목표다.</p>
<ul>
<li><strong>작업을 일시 중지하고 나중에 다시 돌아올 수 있다</strong></li>
<li><strong>서로 다른 유형의 작업에 우선순위를 부여할 수 있다</strong></li>
<li><strong>이전에 완료된 작업을 재사용할 수 있다</strong></li>
<li><strong>더 이상 필요 없는 작업을 중단할 수 있다</strong></li>
</ul>
<h2 id="fiber-node-내부-구조"><a class="anchor" href="#fiber-node-내부-구조">Fiber Node 내부 구조</a></h2>
<p>여기까지 읽으면 자연스럽게 떠오르는 질문이 하나 있다. "그래서 Fiber 노드는 내부적으로 어떻게 생겼는가?"</p>
<p>React 팀은 Fiber의 내부 구현에 대한 공식 문서를 별도로 제공하지 않고 있다. 하지만 Andrew Clark의 react-fiber-architecture 문서와 실제 React 소스코드(<code>ReactFiber.js</code>)를 통해 그 구조를 파악할 수 있다.</p>
<p>Fiber 노드를 필자는 <strong>작업 지시서(Work Order)</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로 렌더링해달라"는 요청서일 뿐, 실제 렌더링 로직이나 상태는 담고 있지 않다.</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>이 함수이고 <code>prototype.isReactComponent</code>가 있으면 → <code>tag = ClassComponent(1)</code></li>
<li><code>type</code>이 함수이면 → <code>tag = FunctionComponent(0)</code></li>
<li><code>type</code>이 문자열(<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가지 이상의 태그가 존재한다. React는 이 tag 값을 기반으로 <code>beginWork</code>에서 어떤 처리 로직을 실행할지 결정하는 것이다.</p>
<p><strong>type</strong>은 재조정(reconciliation) 과정에서 핵심적인 역할을 한다. 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 조작이 발생하거나, 컴포넌트의 내부 상태가 의도치 않게 유지 또는 소실될 수 있다.</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>단일 연결 리스트(Singly Linked List) 형태의 트리</strong>다. 일반적인 트리 구조에서는 자식 배열(<code>children[]</code>)을 두는 것이 직관적이지만, Fiber는 의도적으로 이를 피했다.</p>
<p>왜일까? 배열 기반의 자식 구조에서는 순회를 위해 인덱스를 관리해야 하고, 중간에 중단했다가 재개할 때 "어디까지 처리했는지"를 별도로 추적해야 한다. 반면 linked list 구조에서는 현재 노드의 참조만 기억하면 언제든 이어서 순회할 수 있다. 이것이 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의 훅(hooks) 상태를 저장하며, <strong>updateQueue</strong>는 아직 처리되지 않은 상태 업데이트(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="double-buffering-current-트리와-workinprogress-트리"><a class="anchor" href="#double-buffering-current-트리와-workinprogress-트리">Double buffering: current 트리와 workInProgress 트리</a></h2>
<p>Fiber를 이해하는 데 있어 빠뜨릴 수 없는 핵심 개념이 바로 <strong>double buffering</strong>이다.</p>
<p>이 개념을 이해하기 위해 게임 그래픽을 떠올려보자. 게임에서 화면을 그릴 때, 현재 화면에 직접 픽셀을 그리면 반쯤 그려진 프레임이 사용자에게 보이는 <strong>화면 깜빡임(flicker)</strong> 현상이 발생한다. 이를 방지하기 위해 게임 엔진은 <strong>두 개의 버퍼</strong>를 사용한다. 하나의 버퍼에 다음 프레임을 완전히 그린 후, 완성되면 화면에 표시되는 버퍼를 한 번에 교체하는 것이다.</p>
<p>React Fiber도 정확히 같은 전략을 사용한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">currentFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgressFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">workInProgressFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> currentFiber;</span></span></code></pre></figure>
<p><strong>current 트리</strong>는 현재 화면에 반영되어 있는 Fiber 트리. 사용자가 보고 있는 UI의 상태를 나타내고, <strong>workInProgress 트리</strong>는 다음 렌더링을 위해 백그라운드에서 준비 중인 Fiber 트리를 나타낸다.</p>
<p>두 트리는 <code>alternate</code> 속성으로 서로를 참조한다. 모든 변경 작업은 workInProgress 트리에서 수행되며, 작업이 완료되면 <code>root.current = finishedWork</code> 한 줄로 트리가 교체된다. 이전의 workInProgress가 새로운 current가 되고, 이전의 current는 다음 렌더링에서 workInProgress로 재활용되는 것이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createWorkInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 최초 렌더: 새 Fiber를 생성하고 alternate를 연결</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFiber</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current.tag, pendingProps, current.key, current.mode);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.stateNode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.stateNode; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// DOM 노드는 공유!</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    current.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 재렌더: 기존 alternate를 재사용, effect만 초기화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.pendingProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.flags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.deletions </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // lanes, child, memoizedState 등을 복사</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.childLanes </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.childLanes;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.child;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>여기서 핵심을 짚어보자. <code>stateNode</code>(실제 DOM 노드)는 current와 workInProgress 사이에서 <strong>공유</strong>된다. Fiber 객체를 매번 새로 만드는 것이 아니라, 기존 alternate를 재사용하면서 변경된 필드만 업데이트한다. 이 덕분에 매 렌더마다 가비지 컬렉션(GC) 부담 없이 효율적으로 트리를 구성할 수 있는 것이다.</p>
<p>만약 props나 state에 변경이 없다면? 해당 서브트리를 통째로 건너뛰는 <strong>bailout 최적화</strong>가 가능해진다. 게임의 double buffering이 프레임 단위의 최적화라면, Fiber의 double buffering은 <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 기반에서는 범위(range) 비교로만 분류할 수 있었고, 특정 업데이트만 선택적으로 처리하는 데 한계가 있었다.</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>를 떠올려보자. 고속도로에는 여러 차선(lane)이 있고, 각 차선은 서로 다른 용도를 가진다. 1차선은 추월 차선(긴급), 2차선은 주행 차선(일반), 갓길은 비상용이다. 각 차량(업데이트)은 자신의 성격에 맞는 차선에 배정되고, 고속도로 관리 시스템(스케줄러)은 어떤 차선의 차량을 먼저 통과시킬지 결정한다.</p>
<p>React의 Lane도 이와 같다. 각 업데이트에 <strong>비트 하나(lane)</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:#6A737D;--shiki-light:#6A737D">// 각 업데이트는 하나의 lane(단일 비트)을 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000000010</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> InputContinuousLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000001000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> DefaultLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">          /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000100000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TransitionLane1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000100000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> IdleLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0001000000000000000000000000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배치(batch)는 여러 비트의 OR 조합이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncUpdateLanes</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SyncLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InputContinuousLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> DefaultLane;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 lane이 batch에 포함되는지 확인은 단순 비트 연산</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> isIncluded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (lane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> lanes) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>총 31개의 lane이 31비트 정수에 들어가도록 설계되어 있는데, 이는 V8 엔진의 <strong>SMI(Small Integer)</strong> 최적화를 활용하기 위함이다. 31비트 이하의 정수는 V8에서 포인터 태깅으로 처리되어 힙 할당 없이 스택에서 직접 연산할 수 있다. 주요 lane의 우선순위는 <strong>낮은 비트일수록 높다</strong>.</p>
<p>이 구조 덕분에 React는 비트 연산 한 번으로 어떤 작업을 먼저 처리할지 결정할 수 있게 되었다. <code>getNextLanes()</code> 함수는 <code>pendingLanes</code>에서 가장 높은 우선순위의 lane 그룹을 골라내고, 중단된(suspended) lane은 건너뛰며, 데이터를 받은(pinged) lane은 우선 재시도하는 등의 정교한 스케줄링이 가능해진 것이다.</p>
<p>또한 <strong>기아 상태(starvation) 방지</strong>를 위해 각 lane에 만료 시간이 부여된다. Sync/InputContinuous는 250ms, Transition은 5000ms가 지나면 <code>expiredLanes</code>에 추가되어 동기적으로 강제 처리된다. 아무리 우선순위가 낮아도 영원히 무시당하지는 않는다는 뜻이다. (우선순위가 낮다고 영원히 무시당하면 그건 우선순위 시스템이 아니라 차별 시스템이다.)</p>
<h2 id="fiber의-output"><a class="anchor" href="#fiber의-output">Fiber의 output</a></h2>
<p>여기까지 Fiber의 구조를 살펴봤다면, 이제 궁금해지는 것이 하나 있다. 이 Fiber 노드들이 어떻게 <strong>실제 DOM</strong>으로 변환되는 것일까?</p>
<p>output은 실제 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><strong>호스트 컴포넌트</strong>(div, span, img 등)만 실제 DOM 노드를 생성한다. 브라우저는 <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 트리와 output의 관계는 다음과 같다.</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>output 수집은 <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>그 다음 부모 호스트 컴포넌트가 자식들의 output을 수집한다.</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>마지막으로, 사용자 정의 컴포넌트는 자식의 output을 그대로 전달한다.</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-loop-fiber-순회의-심장"><a class="anchor" href="#work-loop-fiber-순회의-심장">Work Loop: Fiber 순회의 심장</a></h3>
<p>React의 렌더링은 <code>ReactFiberWorkLoop.js</code>에 정의된 Work Loop에서 시작된다. React는 상황에 따라 두 가지 Work Loop를 사용한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동기 렌더링: 중단 없이 모든 Fiber를 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopSync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동시성 렌더링: Scheduler가 양보하라고 할 때까지 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopConcurrentByScheduler</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">shouldYield</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>두 함수의 차이를 주목하라. <code>workLoopSync</code>는 <code>workInProgress</code>가 <code>null</code>이 될 때까지 <strong>무조건</strong> 돈다. 반면 <code>workLoopConcurrentByScheduler</code>는 Fiber 하나를 처리할 때마다 <code>shouldYield()</code>를 묻고, Scheduler가 양보하라고 답하면 루프를 빠져나온다.</p>
<p>Scheduler는 이번 태스크가 시작된 뒤 <strong>5ms</strong>가 지났는지를 보고 답한다. 5ms는 작업 조각의 크기가 아니라 양보를 검사하는 기준이라, Fiber 하나를 처리하는 데 그보다 오래 걸리면 그 작업은 쪼개지지 않는다. 같은 파일에는 Transition 같은 non-idle 작업을 25ms마다 양보하게 하는 <code>workLoopConcurrent</code>도 있다. 하지만 이 함수는 <code>enableThrottledScheduling</code> 플래그 뒤에 있고, v19.3.0의 모든 빌드에서 이 플래그가 꺼져 있다.</p>
<h3 id="performunitofwork"><a class="anchor" href="#performunitofwork">performUnitOfWork</a></h3>
<p><code>performUnitOfWork</code>는 하나의 Fiber 노드를 처리하는 함수다. Fiber 순회의 핵심이 이 함수에 담겨 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> current</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, unitOfWork, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  unitOfWork.memoizedProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.pendingProps;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (next </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(unitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>beginWork</code>는 현재 노드를 처리하고 첫 번째 자식을 반환한다. 그리고 <code>pendingProps</code>를 <code>memoizedProps</code>로 확정하고 자식이 있으면 자식으로, 없으면 <code>completeUnitOfWork</code>를 호출한다</p>
<h3 id="beginwork"><a class="anchor" href="#beginwork">beginWork</a></h3>
<p><code>beginWork</code>는 Fiber 노드를 위에서 아래로 순회하며, 각 노드에서 필요한 계산을 수행하는 함수다. <code>ReactFiberBeginWork.js</code>에 정의되어 있으며, 내부적으로 Fiber의 <code>tag</code>에 따라 거대한 <strong>switch문</strong>으로 분기한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">workInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">renderLanes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // bailout 체크: props와 context가 변경되지 않았다면 스킵</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (current </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> oldProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.memoizedProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> newProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress.pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (oldProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> newProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hasContextChanged</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bailoutOnAlreadyFinishedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  switch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress.tag) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FunctionComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateFunctionComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ClassComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateClassComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> HostComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateHostComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SuspenseComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateSuspenseComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ... 약 25가지 이상의 케이스</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>핵심은 맨 위의 <strong>bailout 체크</strong>다. props와 context가 이전과 동일하다면 <code>bailoutOnAlreadyFinishedWork</code>로 해당 서브트리를 통째로 건너뛴다. 이것이 React의 성능 최적화에서 가장 중요한 경로 중 하나다.</p>
<p><code>beginWork</code>의 반환값은 <strong>첫 번째 자식 Fiber</strong>이다. 자식이 있으면 그 자식이 다음 <code>workInProgress</code>가 되고, 없으면(<code>null</code>) <code>completeUnitOfWork</code>로 진입한다.</p>
<h3 id="completework"><a class="anchor" href="#completework">completeWork</a></h3>
<p><code>completeWork</code>는 리프 노드에서 시작하여 부모 방향으로 올라가며 작업을 마무리하는 함수다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  do</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 1. completeWork로 현재 노드의 작업 마무리 (DOM 생성 등)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, completedWork, renderLanes);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 2. 형제가 있으면 형제로 이동 (다시 beginWork 시작)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> siblingFiber</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (siblingFiber </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> siblingFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 3. 형제가 없으면 부모로 올라감</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.return;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>completeWork</code>에서 수행하는 주요 작업은 다음과 같다.</p>
<ul>
<li><strong>HostComponent의 경우</strong>: 실제 DOM 노드를 생성(<code>createInstance</code>)하고, 자식 DOM들을 append한다. 이미 존재하는 DOM이라면 변경된 props를 수집하여 <code>updateQueue</code>에 저장한다.</li>
<li><strong><code>bubbleProperties()</code></strong>: 자식들의 flags를 <code>subtreeFlags</code>로 집계한다. 이 정보는 Commit Phase에서 서브트리 스킵 최적화에 사용된다.</li>
</ul>
<p>순회를 정리하면 이렇다. <strong>child를 따라 내려가고(beginWork) -> 리프에서 완료 후 sibling으로 이동 -> 형제가 없으면 return을 따라 올라감(completeWork)</strong>. 이것이 Fiber의 깊이 우선 탐색 순서인 것이다.</p>
<h3 id="scheduler의-task-queue"><a class="anchor" href="#scheduler의-task-queue">Scheduler의 task queue</a></h3>
<p>React의 Scheduler 패키지는 내부적으로 <strong>두 개의 min-heap(최소 힙)</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을 결정하는 timeout은 어떻게 정해질까? 각 업데이트의 우선순위(Priority Level)에 따라 고유한 timeout이 부여된다.</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>이 timeout 값들은 동시에 <strong>기아 상태(starvation) 방지</strong> 메커니즘이기도 하다. 아무리 우선순위가 낮아도 timeout이 지나면 만료 상태가 되어 강제 실행된다. 높은 우선순위 작업이 계속 들어온다고 해서 낮은 우선순위 작업이 영원히 무시당하는 일은 없는 것이다.</p>
<h2 id="render-phase와-commit-phase"><a class="anchor" href="#render-phase와-commit-phase">Render Phase와 Commit Phase</a></h2>
<p>지금까지 Fiber의 구조와 스케줄링을 살펴봤다. 이제 이 모든 것이 어떻게 조합되어 실제 UI 업데이트가 이루어지는지 전체 흐름을 정리해보자.</p>
<p>Fiber는 내부적으로 <strong>Render Phase</strong>와 <strong>Commit Phase</strong>라는 두 단계를 거친다. 이 분리는 React의 동시성 모델을 가능하게 만드는 핵심 설계인 것이다. Fiber의 작동 흐름을 직접 확인하고 싶다면 아래 이미지를 클릭하면 된다.</p>
<p><a href="https://storied-centaur-55230f.netlify.app/" target="_blank" rel="noopener noreferrer"><picture><source type="image/webp" srcset="/content/250520/2.720w.webp 720w, /content/250520/2.1440w.webp 1440w, /content/250520/2.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/250520/2.png" alt="React Fiber 작동 순서를 단계별로 보여주는 시각화 데모. 렌더 단계부터 레이아웃 단계까지를 컴포넌트 트리와 작업 스택으로 따라간다" width="1680" height="1202" loading="eager" fetchpriority="high" decoding="async"></picture></a></p>
<h3 id="render-phase"><a class="anchor" href="#render-phase">Render Phase</a></h3>
<p>Render Phase는 UI에 <strong>어떤 변경이 필요한지 계산</strong>하는 단계다. 이 단계에서는 DOM에 실제로 아무런 영향을 주지 않는다. 그리고 가장 중요한 특성은 <strong>비동기적으로 중단 및 재개가 가능</strong>하다는 것이다.</p>
<p>이 단계는 앞서 살펴본 <code>beginWork</code>와 <code>completeWork</code> 중심으로 동작한다.</p>
<p><strong>beginWork(fiber)</strong> 에서는 각 Fiber의 타입(FunctionComponent, ClassComponent, HostComponent 등)에 따라 적절한 로직을 실행한다. 그리고 자식 Fiber 노드를 생성하고 연결한다. props가 이전과 동일하다면 memoization을 활용해 스킵할 수 있다(bailout)</p>
<p><strong>completeWork(fiber)</strong> 에서는 DOM 생성 작업이나 effect 정보를 준비한다. 그리고 <code>bubbleProperties()</code>를 통해 자식들의 flags를 <code>subtreeFlags</code>로 집계하고, 부모 방향으로 올라가며 정보를 보완한다</p>
<p>이 단계에서 DOM을 직접 수정하지 않기 때문에, 언제든 작업을 중단하고 나중에 다시 시작하더라도 사용자에게 불완전한 UI가 노출되지 않는다. 이것이 Concurrent 모드의 기반인 것이다.</p>
<h3 id="subtreeflags"><a class="anchor" href="#subtreeflags">subtreeFlags</a></h3>
<p>Render Phase에서 각 Fiber에는 어떤 부수 효과(side effect)가 필요한지 <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>로 연결된 linked list를 사용하여 부수 효과가 있는 Fiber만 모아뒀다. 하지만 이 방식은 언마운트된 Fiber의 참조가 남아 <strong>메모리 누수</strong>를 일으키는 문제가 있었고, Suspense 같은 새로운 패턴을 효율적으로 처리하기 어려웠다.</p>
<p>React 17부터는 이 effect list를 제거하고 <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>이 구조의 가장 큰 장점은 Commit Phase에서 <strong>서브트리 전체를 스킵</strong>할 수 있다는 것이다. 만약 어떤 Fiber의 <code>subtreeFlags &#x26; MutationMask === NoFlags</code>라면, 그 서브트리에는 DOM 변경이 필요한 노드가 전혀 없으므로 통째로 건너뛸 수 있다. 이전의 linked list 방식에서는 불가능했던 최적화다.</p>
<h3 id="commit-phase"><a class="anchor" href="#commit-phase">Commit Phase</a></h3>
<p>Commit Phase는 Render Phase에서 계산된 변경 사항을 <strong>실제 DOM에 반영</strong>하는 단계다. 이 단계는 <strong>항상 동기적</strong>으로 수행되며, 한 번 시작하면 끝까지 중단 없이 실행된다. 사용자가 반쯤 업데이트된 UI를 보게 되는 것을 방지하기 위함이다.</p>
<p>Commit Phase는 내부적으로 다음과 같은 세밀한 순서로 동작한다.</p>
<ol>
<li>
<p><strong>Before Mutation Phase</strong>: <code>commitBeforeMutationEffects()</code></p>
<ul>
<li>DOM이 변경되기 전에 현재 DOM 상태를 읽는다. <code>getSnapshotBeforeUpdate</code> 라이프사이클이 여기서 실행된다. 이 시점에서 <code>current</code> 트리는 아직 화면의 상태를 나타내므로, DOM의 스크롤 위치나 크기 같은 정보를 안전하게 캡처할 수 있다.</li>
</ul>
</li>
<li>
<p><strong>Mutation Phase</strong>: <code>commitMutationEffects()</code></p>
<ul>
<li><strong>실제 DOM 조작</strong>이 수행되는 단계다. 새 노드 삽입, 기존 노드 수정, 불필요한 노드 삭제가 모두 여기서 일어난다. <code>componentWillUnmount</code>도 이 시점에 실행되는데, 아직 <code>current</code>가 이전 트리를 가리키고 있으므로 이전 상태를 읽을 수 있기 때문이다.</li>
</ul>
</li>
<li>
<p><strong>트리 교체</strong>: <code>root.current = finishedWork</code></p>
<ul>
<li>double buffering의 핵심이다. workInProgress 트리가 current 트리로 승격된다. 이 교체가 Mutation 후, Layout 전에 실행되는 이유가 중요하다. <code>componentWillUnmount</code>는 <strong>이전 트리</strong>를 읽어야 하므로 Mutation 단계에서 실행되어야 하고, <code>componentDidMount</code>/<code>componentDidUpdate</code>는 <strong>새 트리</strong>를 읽어야 하므로 Layout 단계에서 실행되어야 하기 때문이다.</li>
</ul>
</li>
<li>
<p><strong>Layout Phase</strong>: <code>commitLayoutEffects()</code></p>
<p>DOM 변경이 완료된 후, 새 DOM 상태를 기반으로 하는 작업들이 실행된다.</p>
<ul>
<li><code>componentDidMount</code>, <code>componentDidUpdate</code> 실행</li>
<li><code>useLayoutEffect</code> 콜백 실행</li>
<li>이 시점에서 <code>current</code>는 이미 새 트리를 가리키므로, DOM을 읽으면 업데이트된 값을 얻을 수 있다</li>
</ul>
</li>
<li>
<p><strong>Passive Effects</strong> (비동기)</p>
<ul>
<li><code>useEffect</code>의 cleanup과 setup은 별도로 스케줄링되어 <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의 모든 설계(double buffering, 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보다 우선순위가 낮기 때문에, 사용자 입력 같은 긴급 업데이트가 들어오면 transition 렌더링을 <strong>중단</strong>하고 긴급 업데이트를 먼저 처리할 수 있다. 이 동안 화면에는 <code>current</code> 트리(이전 상태)가 유지되고, transition은 workInProgress 트리에서 백그라운드로 진행된다.</p>
<p>여기서 double buffering의 가치가 빛난다. 중단된 transition 렌더링은 workInProgress 트리에만 영향을 미치고, 사용자가 보는 화면(current 트리)은 전혀 손상되지 않는 것이다.</p>
<p><code>isPending</code> 플래그는 이 transition이 아직 완료되지 않았음을 나타내어, 로딩 인디케이터를 보여주는 등의 처리가 가능하다.</p>
<h3 id="usedeferredvalue"><a class="anchor" href="#usedeferredvalue">useDeferredValue</a></h3>
<p><code>useDeferredValue(value)</code>는 최초 렌더링에서는 전달된 <code>value</code>를 그대로 반환한다. 이후 렌더링에서 현재 렌더가 긴급한 경우, 이전의 memoized 값을 반환하고 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>이 이를 캐치하고 해당 Fiber를 <code>Incomplete</code>로 마킹한다. 그리고 <code>return</code> 체인을 따라 올라가며 가장 가까운 Suspense 경계를 탐색하고, Suspense 경계가 fallback UI를 표시하도록 전환한다. Promise가 resolve되면 <code>markRootPinged</code>로 해당 lane을 ping하고, React가 suspended 서브트리를 다시 렌더링한다</p>
<p>Concurrent 모드에서는 suspended 컴포넌트의 <strong>형제(sibling) 노드들을 계속 렌더링</strong>할 수 있어, 하나의 데이터 요청이 전체 트리의 렌더링을 차단하지 않는다. 이것이 가능한 이유는 Fiber의 linked list 구조 덕분에 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 경계가 suspend되면, fallback HTML을 먼저 전송하고 데이터가 준비되면 나중에 <code>&#x3C;script></code> 태그로 실제 콘텐츠를 스트리밍한다</li>
<li><strong>클라이언트 (Selective Hydration)</strong>: 각 Suspense 경계가 <strong>독립적으로</strong> hydration될 수 있다. 사용자가 아직 hydration되지 않은 영역을 클릭하면, <code>SelectiveHydrationLane</code>을 통해 해당 경계의 hydration을 <strong>우선적으로</strong> 처리한 뒤 이벤트를 디스패치한다</li>
</ul>
<p>이 모든 것이 가능한 이유는 각 Suspense 경계가 독립적으로 스케줄링 가능한 Fiber 노드이기 때문이다. 결국 Fiber 아키텍처의 "작업을 나누고, 우선순위를 매기고, 중단/재개할 수 있다"는 핵심 설계가 이러한 기능들의 토대가 되는 것이다.</p>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>이 글에서 다룬 내용을 한 문장으로 요약하면, <strong>React Fiber는 재귀를 반복으로 바꾸고, 콜 스택을 힙으로 옮겨, 렌더링을 중단하고 재개할 수 있게 만든 아키텍처</strong>다.</p>
<p>이를 위해 linked list 기반의 트리 구조, double buffering, Lane 기반 우선순위 시스템, MessageChannel 기반 스케줄러 등 수많은 정교한 설계가 조합되었다. 그리고 이 모든 것은 결국 <strong>사용자가 느끼는 UI의 반응성을 극대화하는 것</strong> 이라는 목표를 향하고 있다.</p>
<p>물론 Fiber의 내부 구현은 React 버전이 올라갈 때마다 계속 변화하고 있으며, 이 글에서 다룬 내용 역시 특정 시점의 스냅샷에 불과하다. 하지만 "작업을 나누고, 우선순위를 매기고, 중단하고 재개할 수 있다"는 Fiber의 핵심 철학만큼은 앞으로도 변하지 않을 것이라 생각한다.</p>
<p>이 글을 통해 React Fiber가 단순한 면접 키워드가 아닌, React의 모든 기능을 떠받치는 런타임 아키텍처라는 점이 전달되었기를 바란다. 정답은 없지만, 이 글을 읽는 독자분들도 소스코드를 직접 들여다보며 각자만의 이해를 쌓아가기를 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js" target="_blank" rel="noopener noreferrer">React 소스코드, ReactFiberWorkLoop.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberBeginWork.js" target="_blank" rel="noopener noreferrer">React 소스코드, ReactFiberBeginWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberCompleteWork.js" target="_blank" rel="noopener noreferrer">React 소스코드, ReactFiberCompleteWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js" target="_blank" rel="noopener noreferrer">React 소스코드, ReactFiberLane.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js" target="_blank" rel="noopener noreferrer">React 소스코드, ReactFiber.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js" target="_blank" rel="noopener noreferrer">React 소스코드, Scheduler.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/issues/7942" target="_blank" rel="noopener noreferrer">Issue #7942, Fiber Principles</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/37" target="_blank" rel="noopener noreferrer">React 18 WG, New Suspense SSR Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/27" target="_blank" rel="noopener noreferrer">React 18 WG, Concurrent Scheduling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/blog/2022/03/29/react-v18" target="_blank" rel="noopener noreferrer">React v18.0 Blog Post</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[React가 MessageChannel을 쓰는 이유]]></title>
            <link>https://hooninedev.com/250515</link>
            <guid isPermaLink="false">https://hooninedev.com/250515</guid>
            <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 React가 requestIdleCallback 대신 MessageChannel로 작업을 예약하는 이유에 대한 이야기를 해보려고 한다. Fiber를 공부하다가 "브라우저가 한가할 때 조금씩 일한다"는 설명을 읽었는데, 정작 React 소스코드에서는 MessageChannel을 만나 헷갈렸던 프론트엔드 개발자를 위한 글이다. 답부터 적으면,...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>React가 requestIdleCallback 대신 MessageChannel로 작업을 예약하는 이유</strong>에 대한 이야기를 해보려고 한다.</p>
<p>Fiber를 공부하다가 "브라우저가 한가할 때 조금씩 일한다"는 설명을 읽었는데, 정작 React 소스코드에서는 <code>MessageChannel</code>을 만나 헷갈렸던 프론트엔드 개발자를 위한 글이다. 답부터 적으면, <code>requestIdleCallback</code>은 React가 원하는 만큼 자주 불리지 않았고, <code>setTimeout</code>은 중첩되면 4ms 넘는 지연이 붙는다. 그래서 React의 Scheduler 패키지는 브라우저에서 인위적인 지연 없이 다음 매크로태스크를 예약할 수 있는 <code>MessageChannel</code>을 쓴다. 그 4ms가 실제로 얼마나 큰지는 headless Chrome에서 잰 숫자로 확인한다.</p>
<h2 id="버려진-예약-방식들"><a class="anchor" href="#버려진-예약-방식들">버려진 예약 방식들</a></h2>
<p>Fiber의 개념을 설명할 때는 <code>requestIdleCallback</code>으로 작업을 나눠 실행하는 코드를 흔히 쓴다. 브라우저가 할 일이 없을 때마다 작업 단위를 하나씩 처리하는 모델이다. React도 처음에는 이 API를 실제로 썼고, 지금의 모양에 이르기까지 PR 몇 개를 거쳤다.</p>
<ul>
<li><strong>2017년 1월</strong>: 네이티브 <code>requestIdleCallback</code>을 쓰되, 없는 브라우저에서는 <code>requestAnimationFrame</code>과 <code>postMessage</code>로 흉내 낸 polyfill을 쓰게 했다(<a href="https://github.com/facebook/react/pull/8833" target="_blank" rel="noopener noreferrer">PR #8833</a>). Safari처럼 이 API가 없는 브라우저는 처음부터 polyfill이 맡았다. Safari 정식판에는 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#browser_compatibility" target="_blank" rel="noopener noreferrer">2026년 10월 현재도 이 API가 없다</a>.</li>
<li><strong>2018년 3월과 4월</strong>: 네이티브 API가 있어도 polyfill을 쓰는 플래그를 넣었다. Andrew Clark는 PR 본문에서 그동안 겪어 온 starvation(작업이 실행 기회를 얻지 못하고 계속 밀리는 현상) 문제를 polyfill이 줄이는지 시험하려는 것이라고 적었고, 재현이 어려워 확신하기는 어렵다고 덧붙였다(<a href="https://github.com/facebook/react/pull/12385" target="_blank" rel="noopener noreferrer">PR #12385</a>). 한 달 뒤 내부에서 네이티브보다 낫다고 판단해 플래그를 지우고 polyfill로 굳혔다(<a href="https://github.com/facebook/react/pull/12648" target="_blank" rel="noopener noreferrer">PR #12648</a>).</li>
<li><strong>2018년 11월</strong>: polyfill이 <code>window</code>에 보내던 message 이벤트를 <code>MessageChannel</code>로 옮겼다. <code>window</code>에 보내면 페이지의 다른 message 핸들러까지 매 프레임 불리기 때문이다(<a href="https://github.com/facebook/react/pull/14234" target="_blank" rel="noopener noreferrer">PR #14234</a>).</li>
<li><strong>2019년 7월</strong>: 다음 vsync를 추측해 프레임 끝에서 양보하던 방식 대신, message 이벤트 안에서 5ms 일하고 양보하는 루프를 실험 플래그로 넣었다(<a href="https://github.com/facebook/react/pull/16214" target="_blank" rel="noopener noreferrer">PR #16214</a>). 이 루프는 <code>requestAnimationFrame</code>을 아예 쓰지 않는다.</li>
<li><strong>2019년 8월과 11월</strong>: 8월에 성능 테스트에서 message 루프 쪽 CPU 활용이 더 낫게 나왔다는 보고(<a href="https://github.com/facebook/react/pull/16271" target="_blank" rel="noopener noreferrer">PR #16271</a>, 머지되지 않음)를 거쳐, 11월에 rAF 구현을 지웠다(<a href="https://github.com/facebook/react/pull/17252" target="_blank" rel="noopener noreferrer">PR #17252</a>).</li>
</ul>
<p>starvation이 왜 생기는지는 <code>requestIdleCallback</code>의 정의에서 짐작할 수 있다. <a href="https://w3c.github.io/requestidlecallback/" target="_blank" rel="noopener noreferrer">W3C 명세</a>는 유휴 기간(idle period)을 브라우저가 정한다고 두고, 프레임과 프레임 사이의 남는 시간을 그 예 가운데 하나로 든다. 애니메이션 중에는 이 기간이 자주 오지만 60Hz 화면에서는 대개 16ms보다 짧다. 메인 스레드가 긴 작업으로 바쁘면 이마저 줄어들고, React 작업은 그만큼 밀린다고 필자는 본다. Dan Abramov도 2018년 8월 이슈 댓글에서 React가 이 API를 그만 쓴 이유를 "it's not as aggressive as we need"라고 적었다(<a href="https://github.com/facebook/react/issues/11171#issuecomment-417349573" target="_blank" rel="noopener noreferrer">facebook/react#11171</a>).</p>
<p>그 뒤의 <code>requestAnimationFrame</code> 방식을 버린 이유는 PR #16214 본문에 있다. 이 방식은 다음 vsync(디스플레이가 화면을 갱신하는 주기에 맞춘 신호) 시점을 추측해야 했다. 처음에는 30fps를 가정해 프레임 길이를 33.33ms로 잡고, 연속한 두 프레임의 간격이 모두 그보다 짧으면 둘 중 긴 쪽으로 프레임 길이를 줄였다. 이번 프레임의 마감은 프레임이 시작된 시각에 이 길이를 더한 값이었다(<a href="https://github.com/facebook/react/blob/6dc2734b41aef944e457eaa23ae218952fce0a54/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L123-L336" target="_blank" rel="noopener noreferrer">PR #17252 직전의 SchedulerHostConfig.default.js</a>). 프레임 길이를 줄이는 규칙만 있었으므로, PR 본문의 말대로 페이지가 열린 뒤 주사율이 올라가는 것은 감지해도 내려가는 것은 감지하지 못했다.</p>
<p>message 루프는 vsync 주기 어디에 있든 5ms마다 양보한다. PR 본문은 이렇게 하면 주사율이 아주 높은 화면에서도 메인 스레드가 반응성을 유지할 것이라고 기대했다("should keep the main thread responsive"). 같은 PR은 더 자주 양보하면 다른 브라우저 작업과의 경합이 커질 수 있다는 위험과, 백그라운드 탭에서 message 이벤트가 얼마나 throttling되는지 모른다는 점도 함께 적었다. 지금의 Scheduler 소스 주석은 대부분의 작업은 프레임 경계에 맞출 필요가 없다고 적는다.</p>
<h2 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h2>
<p>rAF를 걷어낸 뒤 남은 예약 수단이 <strong>MessageChannel</strong>이다. 아래는 React v19.3.0의 <a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/forks/Scheduler.js#L530-L561" target="_blank" rel="noopener noreferrer">Scheduler.js 530~561행</a>이고, 주석은 줄였다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> schedulePerformWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> localSetImmediate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // Node.js and old IE.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetImmediate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> MessageChannel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'undefined'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // DOM and Worker environments.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We prefer MessageChannel because of the 4ms setTimeout clamping.</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> channel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> port</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> channel.port2;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  channel.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    port.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We should only fallback here in non-browser environments.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>분기는 셋이다. 지금의 브라우저에는 <code>setImmediate</code>가 없으므로 두 번째 분기의 <code>MessageChannel</code>이 잡힌다. 소스 주석에 따르면 첫 분기는 Node.js와 옛 IE를 위한 것이고, <code>MessageChannel</code>은 Node.js 프로세스가 끝나지 않게 붙잡지만 <code>setImmediate</code>는 그러지 않는다(<a href="https://github.com/facebook/react/issues/20756" target="_blank" rel="noopener noreferrer">facebook/react#20756</a>). 그래서 Jest의 node 환경은 <code>setImmediate</code> 경로를 탄다. Jest 27부터의 jsdom 환경은 전역에서 <code>setImmediate</code>가 빠졌고(<a href="https://github.com/jestjs/jest/pull/11222" target="_blank" rel="noopener noreferrer">jestjs/jest#11222</a>) <code>MessageChannel</code>도 없어서 <code>setTimeout</code> 경로를 탄다.</p>
<p>Scheduler가 이렇게 다음 차례를 예약하는 목적은 메인 스레드를 브라우저에 돌려주는 것이다. 자바스크립트가 메인 스레드를 쥐고 있는 동안 브라우저는 입력을 처리하지도, 화면을 그리지도 못한다. 다만 중간에 양보하는 것은 Transition과 Retry처럼 시간을 쪼개는 렌더뿐이다. React v19.3.0의 Reconciler는 Sync, InputContinuous, Default 같은 blocking lane이 들어 있거나, 오래 밀려 만료된 lane이 있거나, <code>forceSync</code>로 불린 렌더라면 양보 없이 끝까지 렌더한다(<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1168" target="_blank" rel="noopener noreferrer">ReactFiberWorkLoop.js 1168행</a>).</p>
<p>이런 렌더에서 Reconciler는 Fiber 하나를 처리할 때마다 <code>shouldYield()</code>를 묻는다(<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L3073-L3078" target="_blank" rel="noopener noreferrer">ReactFiberWorkLoop.js 3073~3078행</a>). 이 함수는 Scheduler가 내보낸 것이고, 실제 판단은 Scheduler 안의 <code>shouldYieldToHost()</code>가 한다. 기준은 이번 메시지 태스크가 시작된 뒤 흐른 시간이다. 그 시간이 <code>frameInterval</code> 이상이면 양보하고, 커밋 뒤에 <code>requestPaint()</code>가 불렸다면 시간과 관계없이 양보한다. <code>frameInterval</code>의 초깃값은 <code>SchedulerFeatureFlags.js</code>에 정의된 <code>frameYieldMs</code>, 즉 <strong>5ms</strong>다(<a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/SchedulerFeatureFlags.js#L11" target="_blank" rel="noopener noreferrer">11행</a>). 5ms는 작업 조각의 크기가 아니라 양보를 검사하는 시간 기준이다. 컴포넌트 하나를 렌더링하는 데 20ms가 걸리면 그 20ms는 쪼개지지 않는다. 여기까지는 stable 빌드의 동작이다. experimental 빌드에서는 같은 파일의 <code>enableAlwaysYieldScheduler</code>가 켜져 있어, Scheduler가 5ms를 채우지 않고 태스크 하나를 마치면 다음 태스크가 이미 만료되지 않은 한 바로 브라우저에 양보하고 <code>requestPaint()</code> 신호도 보지 않는다.</p>
<h2 id="settimeout의-4ms-지연"><a class="anchor" href="#settimeout의-4ms-지연">setTimeout의 4ms 지연</a></h2>
<p>양보한 뒤 다음 조각을 예약하는 데 왜 <code>setTimeout</code>이 아닌 <code>MessageChannel</code>을 쓸까? HTML 명세의 <a href="https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps" target="_blank" rel="noopener noreferrer">타이머 초기화 단계</a>는 중첩 깊이가 5를 넘은 <code>setTimeout</code>의 지연이 4ms보다 짧으면 4ms로 올린다. headless Chrome에서 <code>setTimeout(fn, 0)</code>을 스스로 다시 거는 체인을 재 보면 1~6번째 호출의 간격은 0~0.1ms였고, 7번째부터 4~5ms(대개 5ms 안팎)가 붙었다. <code>MessageChannel</code>의 메시지에는 이런 최소 지연이 없다. 메시지는 태스크 큐에 들어갈 뿐이라 그 사이에 브라우저가 입력 처리나 렌더링을 끼워 넣을 수 있고, Scheduler는 바로 그 틈을 노린다.</p>
<p>4ms는 작아 보이지만 5ms마다 양보하는 루프에서는 이야기가 다르다. 200ms 분량의 일을 5ms 조각 40개로 나누고, 다음 조각을 <code>MessageChannel</code>과 <code>setTimeout(work, 0)</code>으로 각각 예약해 봤다. 개발자 도구 콘솔에 붙여 넣으면 그대로 돈다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">ms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ms) {} }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> raf</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { frames</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> work</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">round</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ms,'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'frames'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ch.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> work;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> schedule</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ch.port2.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// setTimeout(work, 0) 로 바꿔 비교</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>2026-10-08에 macOS의 Chrome 154.0.8037.98(headless)에서 세 가지를 여덟 번씩 돌린 결과다. Safari와 Firefox에서는 재지 않았다. 표의 세 번째 줄은 <code>busy(5); done += 5;</code>를 <code>busy(200); done += 200;</code>으로 바꿔 일을 쪼개지 않은 것이다.</p>
<table>
<thead>
<tr>
<th>다음 조각 예약</th>
<th>끝날 때까지</th>
<th>그동안 그린 프레임</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MessageChannel</code></td>
<td>201ms</td>
<td>12~13</td>
</tr>
<tr>
<td><code>setTimeout(work, 0)</code></td>
<td>약 365~371ms</td>
<td>21~22</td>
</tr>
<tr>
<td>쪼개지 않음</td>
<td>200ms</td>
<td>0</td>
</tr>
</tbody>
</table>
<p><code>setTimeout</code> 쪽은 앞의 여섯 번 뒤로 조각마다 4~5ms의 빈 시간이 붙어, 같은 일에 약 1.8배의 시간이 걸렸다. 숫자는 기기와 부하에 따라 흔들리지만 약 1.8배라는 비율은 반복해도 유지됐다. 프레임 수가 더 많은 것은 일이 늦게 끝나 재는 구간이 길어졌기 때문이고, 반응성이 더 좋다는 뜻은 아니다. 쪼개지 않으면 일은 200ms에 끝나지만 그동안 프레임이 하나도 그려지지 않는다. <code>MessageChannel</code>은 같은 200ms 안에 일을 끝내면서 대개 약 60fps로 프레임을 계속 내보냈다.</p>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>정리하면, React에 필요했던 것은 브라우저가 한가해질 때까지 기다리는 API가 아니라 짧게 일하고 곧바로 다음 차례를 예약할 수 있는 API였다. <code>requestIdleCallback</code>은 React가 원하는 만큼 적극적으로 불리지 않았고, <code>requestAnimationFrame</code> 방식은 vsync 시점을 추측해야 했으며, <code>setTimeout</code>은 중첩되면 조각마다 4ms 넘게 쉬게 만들었다. 그 조건을 만족한 것이 <code>MessageChannel</code>이다.</p>
<p>이 Scheduler가 나눠 실행하는 작업 단위인 Fiber 노드가 어떻게 생겼고 Work Loop가 그것을 어떻게 순회하는지는 <a href="/250520" target="_blank" rel="noopener noreferrer">React Fiber 완전 정복</a>에서 다룬다. 이 글을 읽는 독자 분들도 React 소스코드에서 <code>MessageChannel</code>을 다시 만나면, 그 자리에 왜 그것이 있는지 한 번쯤 떠올려 보기를 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/v16.0.0/src/renderers/shared/ReactDOMFrameScheduling.js" target="_blank" rel="noopener noreferrer">React 16.0.0의 ReactDOMFrameScheduling.js</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[Biome이 ESLint와 Prettier를 대체할 수 있을까?]]></title>
            <link>https://hooninedev.com/241201</link>
            <guid isPermaLink="false">https://hooninedev.com/241201</guid>
            <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 Biome이라는 도구에 대한 이야기를 해보려고 한다. 필자가 속한 팀에서는 서로 다른 IDE(WebStorm, VSCode 등)를 사용하는 환경에서 일관된 코드 스타일을 유지하는 데 꽤 어려움을 겪고 있었다. IDE별로 설정 파일을 따로 관리해야 하는 번거로움도 있었고, 포매팅 차이로 인해 코드 리뷰에서 로직과 무관한 지적이 오가는 일도 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 Biome이라는 도구에 대한 이야기를 해보려고 한다.</p>
<p>필자가 속한 팀에서는 서로 다른 IDE(WebStorm, VSCode 등)를 사용하는 환경에서 일관된 코드 스타일을 유지하는 데 꽤 어려움을 겪고 있었다. IDE별로 설정 파일을 따로 관리해야 하는 번거로움도 있었고, 포매팅 차이로 인해 코드 리뷰에서 로직과 무관한 지적이 오가는 일도 잦았다.</p>
<p>이런 상황에서 ESLint의 포매팅 관련 규칙들이 Deprecated되면서 새로운 대안을 찾아야 했다. <strong>Prettier + ESLint</strong> 조합은 도구 간 충돌을 방지하기 위한 추가 설정이 필요했고, <strong>@stylistic/eslint-plugin-ts</strong>는 아직 커뮤니티 초기 단계라 안정성 검증이 부족했다. 그러던 중 Biome이라는 도구에 관심을 가지게 되었다.</p>
<p>그렇다면 Biome은 정확히 어떤 도구이고, 정말로 ESLint와 Prettier를 대체할 수 있는 걸까?</p>
<h2 id="biome이-뭔데"><a class="anchor" href="#biome이-뭔데">Biome이 뭔데?</a></h2>
<p>Biome은 웹 프로젝트를 위한 올인원(All-in-One) 툴체인이다. JavaScript, TypeScript, JSX, CSS, JSON, GraphQL 등의 코드 포매팅과 린팅을 하나의 도구에서 통합적으로 제공한다. 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년 $4.5M 벤처 투자를 유치하며 야심차게 출발했지만, 2023년 중반 전 직원이 해고되고 레포지토리가 아카이브되었다. 이후 핵심 컨트리뷰터들이 프로젝트를 포크하여 2023년 8월 Biome으로 새 출발을 했다. Rome 시절의 "과대 약속, 저달성" 이미지에서 벗어나, 실용적이고 꾸준한 릴리스로 신뢰를 쌓아가고 있다.</p>
<p>가장 큰 특징은 Rust로 작성되었다는 점이다. 이것이 성능에서 어떤 차이를 만들어내는지는 뒤에서 자세히 다루겠다.</p>
<h2 id="왜-biome을-사용해야-할까"><a class="anchor" href="#왜-biome을-사용해야-할까">왜 Biome을 사용해야 할까?</a></h2>
<p>Biome을 선택하는 이유는 크게 세 가지로 정리할 수 있다.</p>
<p><strong>하나의 도구로 포매팅과 린팅을 모두 처리할 수 있다.</strong> ESLint + Prettier 조합에서는 두 도구 간의 규칙 충돌을 방지하기 위해 <code>eslint-config-prettier</code> 같은 추가 설정이 필요했다. Biome은 이 복잡성을 근본적으로 제거한다.</p>
<p><strong>압도적인 성능이다.</strong> 공식 벤치마크에 따르면 Prettier보다 약 35배, ESLint보다 약 15배 빠르다. 이 수치가 실제로 어느 정도인지는 뒤에서 직접 비교해 보겠다.</p>
<p><picture><source type="image/webp" srcset="/content/241201/1.720w.webp 720w, /content/241201/1.1440w.webp 1440w, /content/241201/1.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/1.png" alt="Biome 공식 홈페이지의 포매팅 속도 비교. 2,104개 파일 171,127줄 기준 Biome 0.41초, Prettier 14.35초로 약 35배 차이" width="1680" height="736" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong>기존 도구와의 호환성이다.</strong> Prettier와 97% 수준의 포매팅 호환성을 제공하며, ESLint의 주요 규칙들을 빌트인으로 포함하고 있다. <code>eslint-plugin-react-hooks</code>, <code>eslint-plugin-jsx-a11y</code> 등 자주 사용되는 플러그인의 규칙들도 내장되어 있어, 마이그레이션 부담이 상대적으로 적다.</p>
<h2 id="어떻게-사용할까"><a class="anchor" href="#어떻게-사용할까">어떻게 사용할까?</a></h2>
<p>Biome 설정은 상당히 간단하다. <a href="https://biomejs.dev/guides/getting-started/" target="_blank" rel="noopener noreferrer">공식 문서</a>에 친절하게 나와 있으니 참고해 보자.</p>
<p>먼저 Biome을 설치한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> install</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-dev</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-exact</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span></span></code></pre></figure>
<p>이후 설정 파일을 생성한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span></code></pre></figure>
<p>그러면 <code>biome.json</code> 파일이 생성된다. 여기에 팀의 포매팅과 린팅 규칙을 정의하면 된다.</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>에 아래 설정을 추가하면 저장 시 자동으로 포매팅과 린팅이 적용된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.defaultFormatter"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"biomejs.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.codeActionsOnSave"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "quickfix.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "source.organizeImports.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<h2 id="직접-비교해-보자"><a class="anchor" href="#직접-비교해-보자">직접 비교해 보자</a></h2>
<p>말로만 빠르다고 하면 와닿지 않으니, 동일한 프로젝트에서 Biome과 ESLint + Prettier를 직접 비교해 보았다. 왼쪽이 Biome, 오른쪽이 ESLint + Prettier이다.</p>
<h3 id="vite-프로젝트-로컬-실행-시간"><a class="anchor" href="#vite-프로젝트-로컬-실행-시간">Vite 프로젝트 로컬 실행 시간</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome1.720w.webp 720w, /content/241201/biome1.798w.webp 798w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome1.png" alt="Biome 를 쓴 Vite 개발 서버 실행 결과. 506ms 만에 준비됨" width="798" height="330" loading="lazy" fetchpriority="low" decoding="async"></picture>  <picture><source type="image/webp" srcset="/content/241201/lint1.720w.webp 720w, /content/241201/lint1.766w.webp 766w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint1.png" alt="ESLint 와 Prettier 를 쓴 Vite 개발 서버 실행 결과. 630ms 만에 준비됨" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome: <strong>506ms</strong> / ESLint + Prettier: <strong>630ms</strong>로 약 20% 더 빠른 실행 시간을 보여주었다.</p>
<h3 id="vite-프로젝트-빌드-시간"><a class="anchor" href="#vite-프로젝트-빌드-시간">Vite 프로젝트 빌드 시간</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome2.720w.webp 720w, /content/241201/biome2.920w.webp 920w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome2.png" alt="Biome 를 쓴 Vite 빌드 결과. 117.13초" width="920" height="222" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint2.720w.webp 720w, /content/241201/lint2.1022w.webp 1022w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint2.png" alt="ESLint 와 Prettier 를 쓴 Vite 빌드 결과. 131.48초" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome는 <strong>117.13s</strong> / ESLint + Prettier: <strong>131.48s</strong> 로 약 10% 더 빠른 빌드 시간을 보여주었다.</p>
<h3 id="lint-작업"><a class="anchor" href="#lint-작업">Lint 작업</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome3.720w.webp 720w, /content/241201/biome3.894w.webp 894w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome3.png" alt="Biome 의 린트 실행 결과. 0.79초, CPU 0.470초" width="894" height="174" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint3.720w.webp 720w, /content/241201/lint3.974w.webp 974w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint3.png" alt="ESLint 의 린트 실행 결과. 16.32초, CPU 8.600초" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>가장 큰 차이는 Lint 작업에서 나타났다. Biome: <strong>0.79s</strong> (CPU 0.470s), ESLint: <strong>16.32s</strong> (CPU 8.600s)로, <strong>Biome이 약 20배 더 빠른 성능</strong>을 보여주었다. CPU 사용량도 훨씬 효율적이었다.</p>
<p>개발 환경에서의 체감 차이도 크지만, CI/CD 파이프라인에서 수백 개의 파일을 검사할 때 이 차이는 더 극적으로 벌어진다. Biome은 npm 설치 없이 바이너리를 직접 실행할 수 있어, CI 콜드 스타트 시간까지 절약할 수 있다.</p>
<p><picture><source type="image/webp" srcset="/content/241201/3.265w.webp 265w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/3.jpeg" alt="턱을 괴고 골똘히 생각하는 뚱이 캐릭터" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>흐..음.. (이쯤 되면 안 쓸 이유를 찾는 게 더 어렵다.)</p>
<h2 id="왜-이렇게-빠른-걸까"><a class="anchor" href="#왜-이렇게-빠른-걸까">왜 이렇게 빠른 걸까?</a></h2>
<p>"Rust로 만들었으니까 빠르다"는 맞는 말이지만, 그것만으로는 설명이 부족하다. Biome의 성능 우위를 만들어내는 구체적인 기술적 요인을 살펴보자.</p>
<h3 id="rust의-저수준-성능"><a class="anchor" href="#rust의-저수준-성능">Rust의 저수준 성능</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="동시 접속 10, 100, 300 에서의 평균 CPU 사용률 비교. Node.js 는 89~91% 로 거의 고정이고 Rust 는 145~206% 로 여러 코어를 쓴다" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="같은 조건의 평균 메모리 사용량 비교. Node.js 는 64~89MB, Rust 는 6~14MB" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
</tr>
</thead>
</table>
<p>Biome은 시스템 프로그래밍 언어인 Rust로 작성되었다. Rust는 제로 비용 추상화(Zero-cost Abstraction)를 지향하는 언어로, 고수준의 추상화를 사용하더라도 수동으로 최적화한 저수준 코드와 동일한 성능을 낸다. 또한 가비지 컬렉터(GC) 없이 소유권(Ownership) 시스템을 통해 메모리를 관리하기 때문에, GC로 인한 런타임 오버헤드가 발생하지 않는다.</p>
<p>반면 ESLint와 Prettier는 JavaScript로 작성되어 Node.js 런타임 위에서 실행된다. V8 엔진의 JIT(Just-In-Time) 컴파일이 JavaScript를 최적화해 주지만, 인터프리터 언어의 근본적인 한계와 가비지 컬렉션 비용을 완전히 피할 수는 없다.</p>
<h3 id="단일-파싱-아키텍처"><a class="anchor" href="#단일-파싱-아키텍처">단일 파싱 아키텍처</a></h3>
<p>Biome은 하나의 파서(Parser)로 코드를 한 번만 파싱하여 AST(Abstract Syntax Tree, 추상 구문 트리)를 생성한다. 이 AST를 포매팅과 린팅 모두에 재사용한다.</p>
<p>ESLint + Prettier 조합을 사용하면 어떻게 될까? ESLint가 코드를 파싱하여 AST를 만들고 린팅을 수행한 뒤, Prettier가 같은 코드를 다시 파싱하여 별도의 AST를 만들고 포매팅을 수행한다. 동일한 파일에 대해 파싱이 두 번 발생하는 것이다. Biome의 단일 파싱 아키텍처는 이 중복을 원천적으로 제거한다.</p>
<h3 id="네이티브-병렬-처리"><a class="anchor" href="#네이티브-병렬-처리">네이티브 병렬 처리</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/7.720w.webp 720w, /content/241201/7.800w.webp 800w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/7.png" alt="동기 처리와 비동기 처리 비교 다이어그램. 동기는 요청과 응답이 번갈아 오지만 비동기는 요청을 연달아 보내고 응답을 나중에 받는다" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Rust의 동시성 모델을 활용하여 Biome은 파일 처리를 여러 스레드에서 병렬로 수행한다. 작업을 작은 단위로 분할하고, 작업 훔치기(Work-stealing) 스케줄러를 통해 스레드 간 부하를 효율적으로 분산한다. Rust의 소유권 시스템이 컴파일 타임에 데이터 경쟁(Data Race)을 원천 차단하기 때문에, 런타임에서의 동기화 비용도 최소화된다.</p>
<p>Node.js는 기본적으로 이벤트 루프 기반의 싱글 스레드 모델이다. Worker Threads를 사용하면 병렬 처리가 가능하지만, 스레드 생성과 메시지 패싱에 따른 추가 오버헤드가 발생한다. Biome은 OS 수준의 네이티브 스레드를 직접 활용하기 때문에 이런 오버헤드 없이 CPU 코어를 최대한 활용할 수 있다.</p>
<h3 id="메모리-효율적인-ast-처리"><a class="anchor" href="#메모리-효율적인-ast-처리">메모리 효율적인 AST 처리</a></h3>
<p><img src="/content/241201/4.svg" alt="구문 트리 예시. 왼쪽은 단항 연산자 -1, 오른쪽은 이항 연산자 1 + 2 를 연산자 노드와 정수 노드로 나타낸다" width="446" height="360" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome은 CST(Concrete Syntax Tree, 구체 구문 트리)를 사용한다. Biome의 공식 아키텍처 문서에 따르면, 이 CST는 rowan 라이브러리의 내부 포크를 기반으로 구현된 Green/Red Tree 패턴으로, 주석과 공백 등 원본 코드의 모든 정보를 보존한다. rowan의 아레나(Arena) 스타일 메모리 할당은 노드를 연속된 메모리 영역에 배치하여 CPU 캐시 지역성(Cache Locality)을 높이고, 불필요한 객체 할당을 최소화한다.</p>
<p>JavaScript의 객체 기반 AST 처리 방식은 각 노드가 독립적인 힙 객체로 존재하기 때문에, 메모리가 분산되고 GC 압력이 높아진다. Biome의 접근 방식은 더 적은 메모리를 사용하면서도 더 빠른 트리 순회가 가능한 것이다.</p>
<h2 id="그래서-biome을-도입해야-할까"><a class="anchor" href="#그래서-biome을-도입해야-할까">그래서, Biome을 도입해야 할까?</a></h2>
<p>Biome의 성능과 편의성은 분명 매력적이다. 하지만 모든 프로젝트에 무조건 도입하는 것이 정답은 아니라고 생각한다. 몇 가지 현실적인 고려사항을 짚어보자.</p>
<h3 id="biome이-적합한-경우"><a class="anchor" href="#biome이-적합한-경우">Biome이 적합한 경우</a></h3>
<ul>
<li><strong>대규모 코드베이스</strong>를 운영하고 있어 빌드/린트 성능이 중요한 경우</li>
<li>CI/CD 파이프라인에서 코드 검사 시간을 줄이고 싶은 경우</li>
<li>ESLint + Prettier 설정의 복잡성에 지친 경우</li>
<li>새 프로젝트를 시작하면서 간결한 도구 설정을 원하는 경우</li>
</ul>
<p>필자의 팀도 대규모 프로젝트를 운영하면서 CI 파이프라인에서 린팅에 많은 시간이 소요되고 있었고, 개발자들이 느린 린팅 속도에 불편을 느끼고 있어 Biome 도입을 결정했다.</p>
<h3 id="주의해야-할-점"><a class="anchor" href="#주의해야-할-점">주의해야 할 점</a></h3>
<p><strong>플러그인 생태계의 한계가 가장 크다.</strong> ESLint에는 수천 개의 커뮤니티 플러그인이 존재하지만, Biome은 빌트인 규칙 중심으로 운영된다. <code>eslint-plugin-react</code>, <code>eslint-plugin-react-hooks</code>, <code>eslint-plugin-jsx-a11y</code>, <code>eslint-plugin-unicorn</code>, <code>typescript-eslint</code> 등 주요 플러그인의 규칙 상당수가 내장되어 있지만, 각 플러그인의 모든 규칙이 포팅된 것은 아니다. Biome v2에서 GritQL 기반의 플러그인 시스템 도입이 예고되어 있으나, 아직 실험적 단계이다. <code>@next/eslint-plugin-next</code>, <code>eslint-plugin-angular</code> 같은 프레임워크 특화 규칙이 필수적인 프로젝트라면 마이그레이션에 신중할 필요가 있다.</p>
<p><strong>언어 지원 범위도 확인이 필요하다.</strong> JavaScript, TypeScript, JSX, CSS, JSON, GraphQL은 안정적으로 지원하지만, Vue나 Svelte의 SFC(Single File Component) 파일은 <code>&#x3C;script></code> 블록만 부분적으로 지원한다. HTML, YAML, Markdown은 아직 지원하지 않는다.</p>
<p><strong>ESLint도 진화하고 있다는 점을 잊으면 안 된다.</strong> ESLint v9(2024년 4월)에서 도입된 Flat Config(<code>eslint.config.js</code>)는 기존 <code>.eslintrc</code> 방식의 복잡성을 대폭 간소화했다. 거기에 <code>@eslint/json</code>(2024년 10월)과 <code>@eslint/css</code>(2025년 2월)를 출시하며 JavaScript 외 언어까지 린팅 영역을 확장하고 있다. ESLint Stylistic(<code>@stylistic/eslint-plugin</code>) 프로젝트는 Prettier 없이도 ESLint만으로 포매팅을 처리할 수 있는 옵션을 제공한다. Biome의 "올인원" 장점이 ESLint 생태계의 진화로 다소 희석되고 있는 구도인 것이다.</p>
<p>또한 Rome에서 Biome으로 전환된 역사도 기억해 둘 필요가 있다. Rome이 아카이빙되면서 기존 사용자들이 겪은 불편함은 도구 선택에서 프로젝트의 지속 가능성이 얼마나 중요한지를 보여주는 사례이다. 다행히 Biome은 OpenCollective와 GitHub Sponsors를 통한 펀딩으로 운영되며, 꾸준한 릴리스 주기를 유지하고 있다.</p>
<p><picture><source type="image/webp" srcset="/content/241201/8.720w.webp 720w, /content/241201/8.1440w.webp 1440w, /content/241201/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/8.png" alt="npm trends 의 1년 주간 다운로드 그래프. eslint 와 prettier 는 3천만~5천만 대이고 biome 은 축 바닥에 붙어 있다" width="1680" height="854" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>npm trends를 보면 ESLint(주간 약 1억 2,000만)와 Prettier(주간 약 8,200만) 대비 Biome(주간 약 690만)의 다운로드 수는 아직 격차가 크다. 하지만 Biome의 성장 속도는 주목할 만하다. 불과 1년여 만에 주간 다운로드가 3~4배 이상 증가했으며, 특히 신규 프로젝트에서의 채택률이 눈에 띄게 올라가고 있다.</p>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>Biome이 ESLint와 Prettier를 완전히 대체할 수 있느냐는 질문에 대한 필자의 답은 <strong>"아직은 아니지만, 충분히 유력한 대안"</strong> 이다.</p>
<p>성능은 압도적이고, 설정은 간결하며, 개발 속도도 빠르다. 다만 플러그인 생태계의 미성숙함과 일부 언어 지원의 한계는 프로젝트에 따라 걸림돌이 될 수 있다. 프로젝트의 기술 스택과 팀의 요구사항을 면밀히 검토한 뒤 도입 여부를 판단하는 것이 바람직하다.</p>
<p>한 가지 확실한 것은, 프론트엔드 도구 생태계가 "더 빠르고, 더 간결하며, 더 통합된" 방향으로 나아가고 있다는 점이다. Biome이 그 흐름의 선두에 서 있는 것은 부정할 수 없다. 앞으로의 성장이 기대되는 도구임은 분명하다.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>자바스크립트</category>
        </item>
        <item>
            <title><![CDATA[Zustand, 너 뭔데 ProviderLess 인 거야?]]></title>
            <link>https://hooninedev.com/240818</link>
            <guid isPermaLink="false">https://hooninedev.com/240818</guid>
            <pubDate>Sun, 18 Aug 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 Zustand가 어떻게 Provider 없이 상태관리를 해내는지에 대한 이야기를 해보려고 한다. 필자는 Zustand를 사용하면서 늘 당연하게 Provider 없이 상태를 관리해왔다. 그러다 문득 이런 생각이 들었다. React 생태계의 대부분의 라이브러리는 Provider로 앱을 감싸는 것이 거의 의식처럼 굳어져 있다. TanStack ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 Zustand가 어떻게 Provider 없이 상태관리를 해내는지에 대한 이야기를 해보려고 한다.</p>
<p>필자는 Zustand를 사용하면서 늘 당연하게 Provider 없이 상태를 관리해왔다. 그러다 문득 이런 생각이 들었다. React 생태계의 대부분의 라이브러리는 Provider로 앱을 감싸는 것이 거의 의식처럼 굳어져 있다. TanStack React Query는 <code>QueryClientProvider</code>로 감싸야 <code>useQuery</code>를 쓸 수 있고, toss의 overlay-kit도 <code>OverlayProvider</code> 없이는 <code>overlay.open()</code>을 호출할 수 없다. React의 Context API 역시 반드시 Provider로 컴포넌트 트리를 감싸야 한다. 그런데 Zustand는 대체 어떤 마법을 부리기에 그런 과정이 필요 없는 걸까?</p>
<p>궁금해서 Zustand의 소스 코드를 직접 뜯어보았고, 생각보다 흥미로운 구조가 숨어있었다. 그 과정에서 알게 된 내용을 정리해보려 한다.</p>
<h2 id="react에서-상태는-어떻게-흘러가는가"><a class="anchor" href="#react에서-상태는-어떻게-흘러가는가">React에서 상태는 어떻게 흘러가는가</a></h2>
<p>일반적인 React 애플리케이션에서 상태는 아래 그림처럼 동작한다.</p>
<p><picture><source type="image/webp" srcset="/content/240818/3.720w.webp 720w, /content/240818/3.880w.webp 880w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/3.png" alt="React 데이터 흐름 다이어그램. 데이터는 props 로 아래로 흐르고 이벤트는 위로 올라간다" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>컴포넌트 내부 상태는 React가 제공하는 상태 관리 훅(<code>useState</code>, <code>useReducer</code>)을 사용하여 관리한다. 그리고 하위 컴포넌트로의 상태 전달은 props를 통해 이루어진다. 여기까지는 단순한 이야기이다.</p>
<p>문제는 멀리 떨어진 컴포넌트 간에 상태를 공유해야 할 때 발생한다. 이때 React가 제공하는 공식적인 해법이 바로 Context API인데, 이 녀석은 반드시 Provider 컴포넌트로 하위 트리를 감싸야 한다.</p>
<h3 id="왜-context-api는-provider가-필요할까"><a class="anchor" href="#왜-context-api는-provider가-필요할까">왜 Context API는 Provider가 필요할까?</a></h3>
<p>이 질문에 답하려면 React의 내부 동작을 조금 들여다봐야 한다.</p>
<p>React는 컴포넌트 트리를 Fiber라는 내부 자료구조로 관리한다. 각 Fiber 노드는 부모-자식 관계로 연결되어 있고, Context의 값이 변경되면 React는 이 Fiber 트리를 위에서 아래로 순회하면서 해당 Context를 구독하는 컴포넌트를 찾아 리렌더링을 트리거한다.</p>
<p>핵심은 이것이다. <strong>Context의 값 전파는 Fiber 트리의 구조에 의존한다.</strong> Provider가 트리의 어느 지점에 위치하느냐에 따라 값이 전달되는 범위가 결정되고, <code>useContext</code>를 호출한 컴포넌트는 자신의 상위 Fiber 트리를 거슬러 올라가며 가장 가까운 Provider를 찾는다. Provider가 없으면? <code>createContext</code>에 전달한 기본값이 사용될 뿐이다.</p>
<p>즉, Context API는 React의 렌더링 시스템과 긴밀하게 결합되어 있다. 상태의 저장, 전파, 구독 모두가 React의 컴포넌트 트리 내부에서 일어나는 것이다.</p>
<p>그렇다면 Zustand는 이 구조를 어떻게 우회하는 걸까?</p>
<h2 id="zustand는-react-바깥에-산다"><a class="anchor" href="#zustand는-react-바깥에-산다">Zustand는 React 바깥에 산다</a></h2>
<p><picture><source type="image/webp" srcset="/content/240818/4.720w.webp 720w, /content/240818/4.1300w.webp 1300w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/4.png" alt="Flux 패턴 다이어그램. Action, Dispatcher, Store, View 가 단방향으로 연결된다" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Zustand는 Flux 패턴을 기반으로 동작한다. 클로저 내부의 <code>state</code>가 Store 역할을, 사용자 정의 함수들이 Action 역할을, <code>set</code> 함수가 Dispatcher 역할을, React 컴포넌트가 View 역할을 수행한다. 여기서 결정적인 차이가 있다.</p>
<p><strong>Zustand의 스토어는 React 컴포넌트 트리 외부, JavaScript 모듈의 스코프 내에 존재한다.</strong> 컴포넌트 트리 외부라는 것의 의미는 React 내부의 상태관리와 달리, Zustand에서 자주 언급되는 "컴포넌트 트리 외부"라는 표현은 상태가 React의 Fiber 트리와 무관하게 독립적으로 존재한다는 뜻이다. 어떤 컴포넌트든 <code>import</code>만 하면 스토어에 접근할 수 있고, Provider로 앱을 감쌀 필요가 없다. (마치 전역 변수처럼 어디서든 접근 가능하되, 클로저로 잘 보호되어 있는 셈이다.)</p>
<p>어떻게 이렇게 가능할까? 아래 코드를 살펴보자.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { create } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> useStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> create</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  increment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p>이 코드에서 <code>create</code>가 호출되는 시점은 모듈이 로드될 때이다. 즉, React가 렌더링을 시작하기도 전에 스토어는 이미 메모리에 존재하게 된다. 이것이 <strong>모듈 레벨 싱글톤(Module-level Singleton) 패턴</strong>이다.</p>
<h3 id="모듈-레벨-싱글톤이란"><a class="anchor" href="#모듈-레벨-싱글톤이란">모듈 레벨 싱글톤이란?</a></h3>
<p>JavaScript의 ES 모듈 시스템은 <strong>모듈을 최초 한 번만 평가(evaluate)하고, 그 결과를 캐싱</strong>한다. 이후 어디서든 같은 모듈을 <code>import</code>하면 새로 실행하는 것이 아니라 캐싱된 동일한 객체를 반환한다. 즉, <code>import { useStore } from './store'</code>를 컴포넌트 A에서 하든 컴포넌트 B에서 하든, 둘 다 <strong>정확히 같은 스토어 인스턴스</strong>를 참조하게 된다.</p>
<p>별도의 싱글톤 클래스를 구현하거나, 전역 변수(<code>window.store</code>)에 매달 필요가 없다. 모듈 시스템 자체가 "한 번만 생성되고 어디서든 같은 인스턴스에 접근한다"는 싱글톤의 조건을 자연스럽게 충족해주는 것이다. Zustand는 이 언어 레벨의 보장을 그대로 활용하여, 별도의 Provider 없이도 모든 컴포넌트가 하나의 스토어를 공유할 수 있게 설계한 것이다.</p>
<p>여기까지 읽으면 자연스럽게 떠오르는 질문이 하나 있다. 그래서 Zustand의 내부는 구체적으로 어떻게 생겼을까?</p>
<h2 id="zustand-내부-구조"><a class="anchor" href="#zustand-내부-구조">Zustand 내부 구조</a></h2>
<p><a href="https://github.com/pmndrs/zustand/tree/main/src" target="_blank" rel="noopener noreferrer">Zustand의 GitHub 저장소</a>를 들여다보면, 핵심 로직은 놀라울 정도로 간결하다. 크게 두 개의 파일이 핵심인데, <code>vanilla.ts</code>가 스토어의 본체를, <code>react.ts</code>가 React와의 연결 고리를 담당한다.</p>
<h3 id="vanillats"><a class="anchor" href="#vanillats">vanilla.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts" target="_blank" rel="noopener noreferrer">vanilla.ts</a>는 Zustand의 심장부이다. 스토어가 어떻게 생성되고, 상태가 어떻게 관리되는지 이 파일 하나에 모두 담겨있다. 더 쉽게 말해, 클로저에 갇힌 상태와 그 상태를 조작하는 함수들이 이 파일에 정의되어 있다.</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>이 코드를 한 줄 한 줄 뜯어보면 Zustand의 핵심 메커니즘이 드러난다.</p>
<h4 id="클로저를-통한-상태-캡슐화"><a class="anchor" href="#클로저를-통한-상태-캡슐화">클로저를 통한 상태 캡슐화</a></h4>
<ul>
<li>
<p><code>let state: TState</code>라는 변수가 <code>createStoreImpl</code> 함수의 지역 변수로 선언되어 있다. 이 변수는 함수 실행이 끝난 후에도 <code>setState</code>, <code>getState</code> 등의 내부 함수가 참조하고 있기 때문에 가비지 컬렉션되지 않는다. 이것이 클로저의 본질이다.</p>
</li>
<li>
<p>외부에서는 <code>state</code> 변수에 직접 접근할 방법이 없다. 오로지 <code>getState()</code>로 읽고, <code>setState()</code>로 쓸 수 있을 뿐이다. (객체지향에서 말하는 private 필드를 클로저로 구현한 셈이다.)</p>
</li>
</ul>
<h4 id="objectis를-활용한-변경-감지"><a class="anchor" href="#objectis를-활용한-변경-감지"><code>Object.is</code>를 활용한 변경 감지</a></h4>
<ul>
<li>
<p><code>setState</code>는 새로운 상태를 계산한 뒤, <code>Object.is(nextState, state)</code>로 기존 상태와 비교한다. 참조가 동일하면 아무 일도 일어나지 않는다. 이것이 불필요한 리렌더링을 방지하는 첫 번째 방어선이다.</p>
</li>
<li>
<p>그런데 이 <code>Object.is</code> 비교는 <strong>엄격한 참조 동등성(strict reference equality)</strong> 검사이기 때문에, 사용하는 쪽에서 주의해야 할 지점이 있다. 원시값(숫자, 문자열 등)을 하나만 꺼내 쓸 때는 문제가 없다.</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는 <strong><code>useShallow</code></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">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>최상위 프로퍼티들을 하나씩 비교</strong>하여, 실제로 값이 변한 경우에만 리렌더링을 발생시킨다. Redux의 <code>useSelector</code>가 기본적으로 참조 비교를 사용하되 <code>shallowEqual</code>을 두 번째 인자로 넘길 수 있는 것과 비슷한 맥락이다. (다만 <code>useShallow</code>는 이름 그대로 "얕은" 비교이므로, 중첩된 객체의 내부까지는 추적하지 않는다는 점을 기억해두자.)</p>
</li>
</ul>
<h4 id="pubsub-패턴의-리스너-시스템"><a class="anchor" href="#pubsub-패턴의-리스너-시스템">Pub/Sub 패턴의 리스너 시스템</a></h4>
<ul>
<li><code>const listeners: Set&#x3C;Listener> = new Set()</code>라는 한 줄이 Zustand의 구독 시스템 전체이다. 상태가 변경되면 <code>listeners.forEach</code>로 모든 구독자에게 알림을 보낸다.</li>
<li><code>subscribe</code>를 호출하면 리스너가 <code>Set</code>에 추가되고, 반환된 함수를 호출하면 <code>Set</code>에서 제거된다.</li>
<li>이 패턴이 중요한 이유는, <strong>React의 Fiber 트리와 완전히 독립적인 알림 시스템</strong>이기 때문이다. Provider가 트리를 순회하며 구독자를 찾는 방식이 아니라, 스토어가 직접 구독자 목록을 관리하는 방식인 것이다.</li>
</ul>
<h4 id="초기-상태-생성"><a class="anchor" href="#초기-상태-생성">초기 상태 생성</a></h4>
<ul>
<li>
<p>초기 상태를 핸들링하는 마지막 줄 코드를 살펴보자.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span></code></pre></figure>
<p>한 줄에 많은 것이 압축되어 있다. 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>그런데 왜 같은 값을 굳이 두 변수에 나눠 담는 걸까? 핵심은 두 변수의 역할이 다르다는 점이다.</p>
<ul>
<li><strong><code>state</code></strong> 는 <code>let</code>으로 선언된 변수이다. <code>setState</code>가 호출될 때마다 새로운 값으로 교체된다. 즉 <strong>현재 시점의 살아있는 상태</strong>를 나타낸다.</li>
<li><strong><code>initialState</code></strong> 는 <code>const</code>로 선언된 변수이다. 스토어가 생성된 시점의 상태가 영구히 보존된다. 이후 어떤 <code>setState</code>가 호출되더라도 이 값은 변하지 않는다. <strong>스토어의 최초 스냅샷</strong>인 셈이다.</li>
</ul>
<p>이 <code>initialState</code>는 <code>getInitialState()</code> 메서드를 통해 외부에 노출되고, <code>react.ts</code>에서 <code>useSyncExternalStore</code>의 <strong>세 번째 인자(서버 스냅샷)</strong> 로 전달된다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),       </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span></code></pre></figure>
<p>서버 사이드 렌더링(SSR) 환경에서는 브라우저 API가 없고, 사용자 인터랙션도 없으므로 <code>setState</code>가 호출될 일이 없다. 따라서 서버에서는 항상 <code>initialState</code>(= 최초 상태)가 스냅샷으로 사용된다. 클라이언트에서 hydration이 시작될 때, React는 서버에서 렌더링한 HTML과 클라이언트의 초기 렌더링 결과를 비교하는데, 양쪽 모두 동일한 <code>initialState</code>를 기준으로 렌더링했기 때문에 <strong>hydration 불일치를 방지</strong>할 수 있는 것이다.</p>
</li>
</ul>
<h3 id="reactts"><a class="anchor" href="#reactts">react.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/react.ts" target="_blank" rel="noopener noreferrer">react.ts</a>는 위에서 만든 순수 JavaScript 스토어를 React의 렌더링 시스템에 연결하는 역할을 한다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">StateSlice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReadonlyStoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateSlice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> identity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useDebugValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(slice)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> slice</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>여기서 핵심은 <code>useSyncExternalStore</code>이다. 이 훅은 React 18에서 도입된 것으로, <strong>React 외부에 존재하는 상태 저장소를 React의 렌더링 사이클에 안전하게 통합</strong>하기 위해 설계되었다.</p>
<p><code>useSyncExternalStore</code>가 받는 세 가지 인자를 살펴보면 구조가 명확해진다. (앞서 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> 함수가 이 모든 것을 하나로 묶는다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateCreator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, [], []>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(createState)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBoundStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api, selector)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useBoundStore, api)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> useBoundStore</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>createStore</code>로 vanilla 스토어를 생성하고, <code>useBoundStore</code>라는 커스텀 훅으로 감싼 뒤, <code>Object.assign</code>으로 스토어 API의 메서드들(<code>setState</code>, <code>getState</code>, <code>subscribe</code> 등)을 훅 함수 자체에 붙여버린다. 그 결과 반환되는 <code>useBoundStore</code>는 <strong>React 훅이면서 동시에 스토어 API</strong>라는 이중적인 성격을 가지게 된다. (함수인데 메서드도 있는, 꽤나 JavaScript스러운 패턴이다.)</p>
<h2 id="다른-상태관리-라이브러리는-어떨까"><a class="anchor" href="#다른-상태관리-라이브러리는-어떨까">다른 상태관리 라이브러리는 어떨까?</a></h2>
<p>여기까지 이해했다면 자연스럽게 다른 라이브러리들과 비교해보고 싶어질 것이다.</p>
<p>Jotai, Recoil, MobX, Xstate, Redux 등 다양한 상태관리 라이브러리가 존재하겠지만, 필자가 직접 사용해본 라이브러리 위주로 비교해보려고 한다.</p>
<blockquote>
<p>참고로, Jotai와 자주 비교되던 <strong>Recoil</strong>(Meta)은 2025년 1월 저장소가 아카이브되면서 사실상 개발이 중단되었다. React 19 지원도 이루어지지 않은 상태이다. 원자적 상태 모델을 원한다면 현시점에서는 Jotai가 유일한 현실적 선택지라고 할 수 있다.</p>
</blockquote>
<h3 id="redux"><a class="anchor" href="#redux">Redux</a></h3>
<p>Redux도 내부적으로는 모듈 레벨의 스토어를 사용한다. 그런데 왜 Provider가 필요할까?</p>
<p>Redux의 <code>&#x3C;Provider store={store}></code>는 React Context를 통해 스토어 인스턴스를 컴포넌트 트리에 <strong>주입(inject)</strong> 한다. <code>useSelector</code>나 <code>useDispatch</code>는 내부적으로 <code>useContext</code>를 호출하여 Provider가 제공하는 스토어에 접근하는 구조이다. 여기서 중요한 점은, Redux가 Context를 <strong>상태 전파 채널이 아니라 의존성 주입(Dependency Injection) 수단</strong>으로 사용한다는 것이다. Context를 통해 전달되는 것은 상태 값 자체가 아니라 상태를 관리하는 <strong>스토어 객체의 참조</strong>이다. 실제 상태 구독과 업데이트는 스토어 내부의 Pub/Sub으로 처리된다.</p>
<p>이 설계가 가져다주는 이점은 명확하다. 테스트 시 다른 스토어 인스턴스를 Provider로 감싸면 완벽한 격리가 되고, 하나의 앱에서 <code>context</code> prop을 통해 여러 독립적인 스토어 트리를 구성할 수도 있다. Mark Erikson(Redux 메인테이너)이 강조하듯, "Context는 전송 메커니즘(transport mechanism)이지 상태 관리 도구가 아니다."</p>
<h3 id="jotai"><a class="anchor" href="#jotai">Jotai</a></h3>
<p>Jotai는 Redux나 Zustand와 근본적으로 다른 <strong>원자적(atomic) 상태 모델</strong>을 채택한다. 하나의 큰 스토어 객체에 상태를 모아두는 것이 아니라, <strong>각각의 상태 조각을 독립적인 atom으로 분리</strong>하는 접근이다. (Jotai 공식 문서에서도 "Zustand가 Redux와 유사하다면, Jotai는 Recoil과 유사하다"고 설명한다.)</p>
<p>이 구조의 핵심 차이는 <strong>렌더링 최적화 방식</strong>에 있다. Zustand는 하나의 스토어에서 selector를 통해 필요한 부분만 추출하는 <strong>하향식(top-down)</strong> 접근이다. 개발자가 <code>useStore((state) => state.count)</code>처럼 selector를 직접 작성해야 하고, 참조 동일성(referential equality)을 유지하기 위해 때로는 메모이제이션이 필요하다. 반면 Jotai는 atom 간의 <strong>의존성 그래프(dependency graph)</strong> 를 자동으로 구축하여, 특정 atom이 변경되면 그 atom에 의존하는 컴포넌트만 정확히 리렌더링하는 <strong>상향식(bottom-up)</strong> 전파를 수행한다. 스프레드시트나 캔버스 에디터처럼 수십 개의 상태가 서로 얽혀 있는 경우에 이 자동 의존성 추적이 큰 힘을 발휘한다.</p>
<p>Provider 측면에서 Jotai는 흥미로운 중간 지점에 위치한다. 기본적으로 전역 스토어를 사용하여 Provider 없이 동작하지만, 필요하다면 <code>&#x3C;Provider></code>로 감싸서 격리된 스토어 스코프를 만들 수 있다. Jotai 공식 문서의 표현을 빌리면, Jotai는 <strong>"context first, module second"</strong> 이고, Zustand는 <strong>"module first, context second"</strong> 인 것이다.</p>
<h3 id="zustand의-선택"><a class="anchor" href="#zustand의-선택">Zustand의 선택</a></h3>
<p>Zustand는 가장 급진적인 선택을 했다. 기본적으로 모듈 레벨 싱글톤이며, Provider가 아예 없다. 이 선택이 가져다주는 것은 <strong>극도로 단순한 API</strong>이다. <code>create</code>로 스토어를 만들고, 컴포넌트에서 훅을 호출하면 끝이다.</p>
<p>다만 "Provider가 아예 없다"는 말은 정확히는 <strong>기본 설계</strong>에 대한 이야기이다. v4부터는 <code>createStore</code>(vanilla 스토어)와 React의 <code>createContext</code>를 조합하여 <strong>스코프드 스토어(Scoped Store)</strong> 패턴을 구현할 수 있다.</p>
<p><a href="https://tkdodo.eu/blog/zustand-and-react-context" target="_blank" rel="noopener noreferrer">TkDodo(React Query 메인테이너)의 블로그</a>에서 이 패턴을 깊이 있게 다루고 있는데, 그가 제시하는 핵심 논지는 이렇다. 전역 싱글톤 스토어에는 세 가지 한계가 있다.</p>
<ul>
<li><strong>Props로 초기화할 수 없다</strong>: 모듈 로드 시점에 스토어가 생성되므로, 서버에서 내려온 데이터나 부모 컴포넌트의 props를 초기값으로 넣을 방법이 없다.</li>
<li><strong>테스트 격리가 어렵다</strong>: 테스트마다 스토어를 수동으로 리셋해야 한다.</li>
<li><strong>재사용이 불가능하다</strong>: 같은 구조의 스토어가 필요한 컴포넌트를 페이지에 두 개 렌더링하면, 둘이 상태를 공유해버린다.</li>
</ul>
<p>이 세 가지를 모두 해결하는 것이 스코프드 스토어 패턴이다. 핵심 아이디어는 <strong>Context로 상태 값을 전달하는 것이 아니라, 스토어 인스턴스의 참조를 전달</strong>하는 것이다. (Redux의 Provider가 하는 일과 정확히 같은 구조이다.)</p>
<p>구체적인 구현을 보면 이렇다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createStore, useStore } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createContext, useContext, useState } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. 스토어 팩토리 함수 — props를 받아 스토어를 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    items: initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selected: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state.selected);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { selected: next };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. Context 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createSelectionStore>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SelectionContext</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3. Provider — useState로 스토어를 한 번만 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionProvider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">store</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(initialItems));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{store}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 4. 커스텀 훅 — Context에서 스토어를 꺼내 useStore로 구독</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,>(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> store</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(SelectionContext);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">store) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'SelectionProvider가 필요합니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(store, selector);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>이제 같은 페이지에 독립적인 멀티셀렉트 컴포넌트를 원하는 만큼 렌더링할 수 있다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 SelectionProvider가 자신만의 스토어 인스턴스를 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'B'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'C'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'X'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Y'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />  {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위 컴포넌트와 상태가 완전히 독립 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>여기서 주목할 점은, Context를 통해 전달되는 것이 <strong>상태 값이 아니라 스토어 객체</strong>라는 것이다. 상태 값이 변경되어도 Context의 <code>value</code>(= 스토어 참조)는 바뀌지 않으므로, <strong>Context의 값 변경으로 인한 불필요한 리렌더링이 발생하지 않는다.</strong> 실제 리렌더링은 <code>useStore</code> 내부의 <code>useSyncExternalStore</code>가 selector 기반으로 처리한다. Context의 전송 역할과 Zustand의 구독 역할이 깔끔하게 분리되는 것이다.</p>
<p>TkDodo는 디자인 시스템의 멀티셀렉트 컴포넌트에서 이 패턴을 실제로 적용한 사례를 소개했다. 기존에 <code>useState</code> + Context로 내부 상태를 관리하던 구조가 50개 이상의 항목에서 성능 저하를 보였고, Zustand의 selector 기반 구독으로 전환하여 해결했다고 한다.</p>
<p>이 패턴은 v3에서 <code>zustand/context</code>로 제공되던 <code>createContext</code> 헬퍼가 v4에서 제거된 이후, <strong>React의 네이티브 <code>createContext</code> + Zustand의 <code>createStore</code>/<code>useStore</code>를 직접 조합하는 방식</strong>으로 정착했다. v5에서도 이 API는 그대로 유지되고 있으며, <a href="https://github.com/pmndrs/zustand/blob/main/docs/previous-versions/zustand-v3-create-context.md" target="_blank" rel="noopener noreferrer">Zustand 공식 문서</a>에서도 v4+ 마이그레이션 가이드로 이 패턴을 안내하고 있다.</p>
<h2 id="providerless의-그림자"><a class="anchor" href="#providerless의-그림자">ProviderLess의 그림자</a></h2>
<p>물론 Provider가 없다는 것이 장점만 있는 것은 아니다. 필자가 생각하는 주의해야 할 지점들을 정리해보겠다.</p>
<h3 id="ssr에서의-상태-공유-문제"><a class="anchor" href="#ssr에서의-상태-공유-문제">SSR에서의 상태 공유 문제</a></h3>
<p>모듈 레벨 싱글톤은 서버 환경에서 위험할 수 있다. Node.js 서버는 여러 요청을 하나의 프로세스에서 처리하는데, 모듈은 프로세스 내에서 한 번만 로드된다. 이는 서로 다른 사용자의 요청이 <strong>같은 스토어 인스턴스를 공유</strong>할 수 있다는 뜻이다.</p>
<p>Zustand가 <code>getInitialState</code>를 제공하고 <code>useSyncExternalStore</code>의 세 번째 인자로 서버 스냅샷을 넘기는 이유가 여기에 있다. 하지만 이것만으로는 요청 간 상태 격리가 완벽하지 않을 수 있어, SSR 환경에서는 앞서 언급한 스코프드 스토어 패턴(<code>createStore</code> + React Context)을 활용하여 요청마다 새로운 스토어를 생성하는 것이 권장된다.</p>
<h3 id="테스트-격리의-어려움"><a class="anchor" href="#테스트-격리의-어려움">테스트 격리의 어려움</a></h3>
<p>Provider 기반 라이브러리는 테스트마다 다른 Provider로 감싸면 스토어가 자연스럽게 격리된다. 반면 Zustand의 모듈 레벨 싱글톤은 테스트 간에 상태가 누수될 수 있다. 각 테스트의 <code>beforeEach</code>에서 스토어를 명시적으로 리셋해야 하는 것이다. (필자도 이 문제로 한 번 고생한 적이 있다.)</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 테스트 파일에서의 스토어 리셋 예시</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">beforeEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>여기서도 스코프드 스토어 패턴이 해결책이 된다. Provider로 감싸는 방식이라면 각 테스트에서 새로운 스토어를 생성하여 주입하면 되므로, 리셋 로직 없이 완벽한 격리가 가능하다.</p>
<h3 id="다중-인스턴스의-부재"><a class="anchor" href="#다중-인스턴스의-부재">다중 인스턴스의 부재</a></h3>
<p>하나의 애플리케이션에서 같은 구조의 독립적인 스토어 두 개가 필요한 경우, Provider 패턴이라면 각각 다른 Provider로 감싸면 된다. 하지만 모듈 레벨 싱글톤에서는 스토어 생성 함수를 별도로 호출하여 서로 다른 스토어 인스턴스를 만들어야 한다. 예를 들어, 같은 페이지에 독립적인 탭 패널 두 개가 있고 각각의 선택 상태를 별도로 관리해야 한다면, 전역 싱글톤으로는 자연스럽게 표현하기 어렵다.</p>
<p>이런 경우에도 <code>createStore</code> + Context 패턴이 정답이다. 각 탭 패널 컴포넌트가 자신만의 Provider를 렌더링하면, 동일한 스토어 구조를 가진 완전히 독립적인 인스턴스가 만들어진다. Zustand 공식 문서에서도 "재사용 가능한 컴포넌트에 스토어가 필요한 경우"에 이 패턴을 권장하고 있다.</p>
<h2 id="결론"><a class="anchor" href="#결론">결론</a></h2>
<p>지금까지 살펴본 내용을 정리하면, Zustand의 ProviderLess 설계는 다음 네 가지 메커니즘의 조합으로 가능해진다.</p>
<ul>
<li><strong>모듈 레벨 싱글톤</strong>: 스토어가 React 컴포넌트 트리 외부, JavaScript 모듈의 스코프 내에 생성된다.</li>
<li><strong>클로저를 통한 상태 캡슐화</strong>: <code>vanilla.ts</code>의 <code>createStoreImpl</code>에서 <code>state</code> 변수와 <code>listeners</code> Set이 클로저에 갇혀 외부 접근이 차단된다.</li>
<li><strong>자체 Pub/Sub 시스템</strong>: Fiber 트리 순회 대신 <code>Set&#x3C;Listener></code>를 직접 관리하여 상태 변경을 구독자에게 알린다.</li>
<li><strong><code>useSyncExternalStore</code>를 통한 React 통합</strong>: 외부 스토어의 상태 변경을 React의 렌더링 사이클에 안전하게 동기화한다.</li>
</ul>
<p>결국 Zustand가 던지는 질문은 이것이다. "상태가 꼭 React 안에 살아야 하는가?" Zustand의 답은 명확하다. 상태는 React 밖에 두고, 필요할 때 다리만 놓으면 된다는 것이다. 그 다리가 바로 <code>useSyncExternalStore</code>이다.</p>
<p>물론 이 접근 방식이 모든 상황에서 최선인 것은 아니다. SSR, 테스트 격리, 다중 인스턴스 같은 상황에서는 Provider 기반의 설계가 더 적합할 수 있다. 정답은 없지만, 각 라이브러리가 어떤 설계적 트레이드오프를 선택했는지 이해하고 있다면 상황에 맞는 도구를 고를 수 있을 것이다.</p>
<p>이 글을 읽는 분들도 한 번쯤 사용하고 있는 라이브러리의 소스 코드를 직접 열어보기를 권한다. 공식 문서에는 없는 깊이를 발견할 수 있을 것이다.</p>
<p><picture><source type="image/webp" srcset="/content/240818/7.216w.webp 216w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/7.jpeg" alt="&#x22;여기서 잠깐!!&#x22; 이라고 말하는 손그림 캐릭터" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h3 id="아-그리고-새로운-소식"><a class="anchor" href="#아-그리고-새로운-소식">아 그리고 새로운 소식</a></h3>
<p>위 내용을 찾아보다 알게 된 사실인데, <strong>Zustand v5.0.0이 2024년 10월에 정식 릴리스</strong>되었다.</p>
<p>흥미로운 점은 v5에 새로운 기능이 거의 없다는 것이다. v4.x에서 이미 새로운 기능들을 추가하면서 기존 API를 deprecated 처리해왔고, v5는 그 <strong>정리(cleanup) 릴리스</strong>의 성격이 강하다. 주요 변경사항은 아래와 같다. (자세한 내용은 <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></strong> 를 참고하기 바란다.)</p>
<ul>
<li><strong>React 18, TypeScript 4.5 이상</strong>으로 최소 요구사항이 상향되었다.</li>
<li><strong><code>getServerState</code>가 삭제</strong>되었다. (<code>useSyncExternalStore</code>의 세 번째 인자로 대체)</li>
<li><strong>ES5 지원이 중단</strong>되었다.</li>
<li><code>create</code> 함수에서 <strong>커스텀 equality 함수 지정이 제거</strong>되었다.</li>
<li>반복 가능한 객체를 지원하도록 <strong><code>shallow</code> 함수가 개선</strong>되었다.</li>
</ul>
<p>v4에서 v5로 마이그레이션할 때는 먼저 v4 최신 버전으로 업데이트하는 것이 권장된다. v4 최신 버전에서 deprecation 경고가 표시되므로, 이를 먼저 해결한 뒤 v5로 올리면 무리 없이 전환할 수 있다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/useSyncExternalStore" target="_blank" rel="noopener noreferrer">React useSyncExternalStore</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://jotai.org/docs/basics/comparison" target="_blank" rel="noopener noreferrer">Jotai Comparison</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://interbolt.org/blog/react-ui-tearing/" target="_blank" rel="noopener noreferrer">InterBolt, Concurrent React, External Stores, and Tearing</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>프론트엔드</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[압축 알고리즘에 대해 알아보자]]></title>
            <link>https://hooninedev.com/240706</link>
            <guid isPermaLink="false">https://hooninedev.com/240706</guid>
            <pubDate>Sat, 06 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 소프트웨어의 압축 알고리즘에 대한 이야기를 해보려고 한다. 용량이 큰 빌드 결과물을 서버나 스토리지에 올리면서 zip, tar.gz, tar.zst 중 무엇을 쓸지 고민하는 개발자를 위한 글이다. 끝까지 읽으면 주요 압축 형식의 속도와 압축률이 어디서 갈리는지, 그리고 tar.gz와 tar.zst 중 무엇을 고를지 판단하는 기준을 얻을 수...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 소프트웨어의 압축 알고리즘에 대한 이야기를 해보려고 한다.</p>
<p>용량이 큰 빌드 결과물을 서버나 스토리지에 올리면서 zip, tar.gz, tar.zst 중 무엇을 쓸지 고민하는 개발자를 위한 글이다. 끝까지 읽으면 주요 압축 형식의 속도와 압축률이 어디서 갈리는지, 그리고 tar.gz와 tar.zst 중 무엇을 고를지 판단하는 기준을 얻을 수 있다.</p>
<p>사내 프로젝트의 배포 프로세스를 개선하는 업무를 맡게 되었다. 높은 용량의 빌드 결과물을 S3에 업로드해야 하는 구조였는데, 빌드 폴더의 용량이 곧 업로드 시간과 스토리지 비용에 직결된다는 것을 체감하게 되었다. 자연스럽게 "어떻게 하면 효율적으로 압축해서 올릴 수 있을까?"라는 고민이 시작되었다.</p>
<p>그런데 막상 압축에 대해 찾아보니, zip, gzip, zstd, bzip2, xz 등 종류가 생각보다 많았다. 이름은 비슷비슷한데 각각 뭐가 다른 건지, 어떤 상황에서 어떤 걸 써야 하는지 정리된 자료를 찾기가 쉽지 않았다. (압축이라고 하면 다 같은 거 아니었나 싶었는데, 세상은 넓고 압축 방법은 많았다.)</p>
<p>비교에 들어가기 전에 공통 뿌리 하나만 짚고 가자. 오늘날 주류 압축기는 거의 모두 1977년에 발표된 LZ77의 후손이다. LZ77은 이미 지나온 데이터의 일정 구간(sliding window)을 사전처럼 삼아, 반복되는 패턴을 "몇 글자 뒤로 가서 몇 글자를 복사하라"는 짧은 참조로 바꾼다. 윈도우가 클수록 멀리 떨어진 반복까지 잡지만 연산과 메모리가 더 든다. ZIP과 GZIP이 쓰는 DEFLATE는 여기에 허프만 부호화(빈도가 높은 심볼에 짧은 비트를 주는 엔트로피 코딩)를 붙인 것이다. LZ77이 형제 격인 LZ78과 어떻게 갈렸는지는 <a href="/240701" target="_blank" rel="noopener noreferrer">LZ77과 LZ78의 차이</a>에 따로 정리했다.</p>
<p>압축 성능을 평가할 때는 두 가지 축을 고려해야 한다. <strong>압축률</strong>(얼마나 작아지는가)과 <strong>압축 속도</strong>(얼마나 빨리 끝나는가)이다. 이론적으로 더 높은 압축률을 추구하면 더 많은 연산이 필요하기 때문에 압축 시간이 길어진다. 이 둘 사이의 절충점을 찾는 것이 현실적인 압축 전략의 핵심이다.</p>
<p>그럼 차근차근 여러 가지 압축 형식들을 비교해 보자.</p>
<h2 id="zip"><a class="anchor" href="#zip">ZIP</a></h2>
<p>ZIP은 1989년 Phil Katz가 만든 파일 형식으로, 내부적으로 <strong>DEFLATE 알고리즘</strong>(LZ77 + Huffman coding의 조합)을 사용하여 압축한다. 여기서 중요한 점은, ZIP은 "압축 알고리즘"이 아니라 "파일 형식(format)"이라는 것이다. ZIP이라는 컨테이너 안에 DEFLATE라는 알고리즘이 들어가 있는 구조이다.</p>
<p>ZIP의 특징은 <strong>각 파일을 개별적으로 압축</strong>한다는 점이다. 이렇게 파일마다 따로 압축하는 구조를 비솔리드 아카이브(Non-solid Archive)라고 부른다. 모든 파일을 하나의 스트림으로 이어 붙여 한 번에 압축하는 반대쪽은 솔리드 아카이브다. 비솔리드 구조 덕분에 압축 파일 내의 특정 파일 하나만 꺼내는 것이 가능하다. 반면 파일 간의 중복 데이터를 활용하지 못하기 때문에, 뒤에서 설명할 tar.gz 방식에 비해 압축률이 낮을 수 있다.</p>
<p>Windows, macOS, Linux 등 대부분의 운영체제에서 별도의 소프트웨어 없이 기본 지원되므로, 크로스 플랫폼 호환성이 중요한 경우에 가장 무난한 선택이다.</p>
<h2 id="gzip-gnu-zip"><a class="anchor" href="#gzip-gnu-zip">GZIP (GNU Zip)</a></h2>
<p>GZIP은 ZIP과 마찬가지로 내부적으로 <strong>DEFLATE 알고리즘</strong>을 사용한다. 같은 알고리즘인데 왜 별도의 형식이 존재할까? ZIP이 여러 파일을 하나의 아카이브에 담는 "컨테이너" 역할까지 수행하는 반면, GZIP은 <strong>단일 파일(또는 단일 스트림)의 압축에 특화</strong>된 형식이다.</p>
<p>그래서 여러 파일이나 디렉토리를 GZIP으로 압축하려면, 먼저 TAR로 하나의 아카이브로 묶은 뒤 GZIP으로 압축하는 2단계를 거쳐야 한다. 이것이 바로 <code>.tar.gz</code>(또는 <code>.tgz</code>) 파일이 탄생하는 과정이다.</p>
<p>GZIP의 파일 구조는 RFC 1952에 명세되어 있으며, 상당히 단순하다. <strong>10바이트 고정 헤더</strong> + 선택적 확장 헤더(원본 파일명, 코멘트 등) + DEFLATE 압축 데이터 + <strong>8바이트 트레일러</strong>(CRC-32 체크섬과 원본 크기)로 이루어진다. 여기서 CRC-32는 압축 해제 후 데이터가 원본과 동일한지를 검증하는 무결성 체크 장치이다. 즉 GZIP은 DEFLATE 압축 데이터를 감싸는 가벼운 래퍼(wrapper)인 셈이다.</p>
<p>DEFLATE 내부에서 사용하는 sliding window의 크기는 <strong>최대 32KB</strong>이다. 이 크기가 GZIP 압축률의 상한을 결정하는 중요한 요소인데, 32KB 이상 떨어진 패턴은 참조할 수 없기 때문이다. 또한 GZIP은 압축 레벨을 1부터 9까지 지정할 수 있다. 레벨 1은 빠르지만 압축률이 낮고(약 60%), 레벨 9는 느리지만 최대 압축률(약 75%)을 달성한다. 기본값은 레벨 6으로, 속도와 압축률 사이의 균형점이다.</p>
<p>Unix/Linux 환경에서 소스 코드 배포, 로그 파일 압축, 소프트웨어 패키지 등에 표준처럼 사용되고 있다. 웹 서버의 HTTP 압축(<code>Content-Encoding: gzip</code>)에서도 여전히 기본값으로 널리 쓰이고 있지만, 이 영역에서는 점차 Brotli로 대체되는 추세이다.</p>
<h2 id="zstd-zstandard"><a class="anchor" href="#zstd-zstandard">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의 허프만 부호화 대신 <strong>FSE(Finite State Entropy)</strong> 라는 방식을 사용한다. FSE는 ANS(Asymmetric Numeral Systems) 이론에 기반한 엔트로피 코더다. ANS는 심볼을 하나 처리할 때마다 정수 하나로 된 상태값을 갱신하면서, 자주 나오는 심볼일수록 적은 비트를 내보낸다. 허프만 부호화에 가까운 속도로 산술 부호화(Arithmetic Coding)에 가까운 압축률을 내는 것이 목표다. 허프만 부호화가 심볼당 정수 비트만 할당할 수 있는 반면, FSE는 분수 비트까지 표현할 수 있어 이론적 최적값에 더 가까운 압축이 가능하다. (이름이 거창하지만, 핵심은 "같은 데이터를 더 적은 비트로 표현하는 더 똑똑한 방법"이라고 이해하면 된다.)</p>
<p>매치 파인더도 압축 레벨에 따라 전략이 달라진다. 낮은 레벨(1~4)에서는 단순 해시 테이블로 빠르게 검색하고, 중간 레벨(5~12)에서는 여러 후보를 비교한 뒤 가장 좋은 매치를 선택하는 Lazy 방식을, 높은 레벨(13~22)에서는 이진 트리와 동적 프로그래밍을 활용해 최적에 가까운 매치를 찾아낸다. 이렇게 레벨을 1부터 22까지 세밀하게 조절할 수 있어, 실시간 전송에는 낮은 레벨을, 아카이빙에는 높은 레벨을 사용하는 식으로 유연하게 대응할 수 있다.</p>
<p>Silesia Corpus 벤치마크 기준으로, ZSTD 기본 레벨(3)의 압축 속도는 약 300MB/s, 해제 속도는 약 1,200MB/s이다. 반면 GZIP 기본 레벨(6)은 압축 약 34MB/s, 해제 약 380MB/s에 그친다. <strong>압축은 약 8배, 해제는 약 3배 빠르면서도 압축률은 ZSTD(3.17) : GZIP(3.09)로 오히려 앞선다.</strong> 이 숫자가 ZSTD의 트레이드오프 개선을 가장 직관적으로 보여준다.</p>
<p>현재 생태계에서의 채택도 빠르게 확대되고 있다. Linux 커널의 모듈 압축과 파일시스템 투명 압축에 사용되고 있으며, Arch Linux, Fedora, Debian/Ubuntu 등 주요 배포판에서 패키지 압축 기본 형식으로 채택했다. 2025년 2월 릴리스된 v1.5.7부터는 <strong>멀티스레드 압축이 기본 활성화</strong>(최대 4스레드)되어, 싱글스레드만 지원하는 GZIP과의 실질적인 속도 격차는 더욱 벌어졌다. AWS는 내부 서비스에서 gzip을 zstd로 전환하여 S3 스토리지를 약 30% 절감했다고 밝힌 바 있다.</p>
<h2 id="bzip2"><a class="anchor" href="#bzip2">BZIP2</a></h2>
<p>BZIP2는 여러 단계의 변환을 거쳐 압축을 수행한다. 핵심 파이프라인은 다음과 같다.</p>
<ol>
<li><strong>RLE(Run-Length Encoding)</strong>: 초기 데이터의 연속 반복을 줄임</li>
<li><strong>BWT(Burrows-Wheeler Transform)</strong>: 데이터를 압축하기 쉬운 형태로 재배열</li>
<li><strong>MTF(Move-to-Front Transform)</strong>: BWT의 출력을 숫자 배열로 변환</li>
<li><strong>RLE</strong>: MTF 결과의 반복을 다시 줄임</li>
<li><strong>Huffman Coding</strong>: 최종적으로 빈도 기반 부호화로 압축</li>
</ol>
<p>GZIP보다 높은 압축률을 제공하지만, 압축 속도와 해제 속도 모두 느리다. 고압축률이 필요하면서 속도가 덜 중요한 아카이빙 용도로 사용되어 왔다.</p>
<p>다만 마지막 릴리스가 2019년(v1.0.8)이며, 활발한 개발은 이루어지지 않고 있다. 압축률과 속도 양면에서 ZSTD가 BZIP2를 능가하는 벤치마크 결과가 나오면서, 새로운 프로젝트에서는 ZSTD를 선택하는 경우가 늘고 있다.</p>
<h2 id="xz"><a class="anchor" href="#xz">XZ</a></h2>
<p>XZ는 <strong>LZMA2</strong>를 사용하는 압축 형식이다. LZMA(Lempel-Ziv-Markov chain Algorithm)는 Igor Pavlov가 개발한 알고리즘으로, LZ77 기반의 사전 압축에 범위 부호화(Range Encoding)를 결합한 것이다. LZMA2는 LZMA의 "개선된 버전"이라기보다는, LZMA 스트림을 감싸는 <strong>컨테이너 형식</strong>에 가깝다. 멀티스레드 압축/해제 지원과 비압축 데이터의 효율적인 처리가 추가된 것이 핵심이다.</p>
<p>이 글에서 다루는 형식 중 <strong>가장 높은 압축률</strong>을 자랑한다. 그 대신 압축 속도가 매우 느리고, 메모리 사용량도 크다. 저장 공간 절약이 최우선인 아카이빙에 적합하다.</p>
<p>다만 2024년 3월, XZ의 핵심 라이브러리인 xz-utils에서 <strong>백도어가 발견되는 심각한 보안 사건(CVE-2024-3094)이 있었다.</strong> 2년간의 소셜 엔지니어링 공격으로 메인테이너 권한이 탈취되어, CVSS 10.0(최고 심각도)을 받았다. 주요 배포판에서 즉시 안전한 버전으로 롤백했지만, 이 사건은 오픈소스 공급망 보안에 대한 경각심을 일깨워 주었다. (XZ 자체의 기술적 가치는 여전하지만, 도구 선택 시 이런 맥락도 알아두면 좋다.)</p>
<h2 id="tar"><a class="anchor" href="#tar">TAR</a></h2>
<p>TAR(Tape Archive)는 그 자체로 압축 알고리즘이 아니다. 여러 파일과 디렉토리를 <strong>하나의 아카이브 파일로 묶는</strong> 도구이다. 이름에서 알 수 있듯이 원래 자기 테이프(Tape) 백업을 위해 만들어진 포맷인데, 테이프라는 매체 특성상 데이터를 순차적으로 쭉 이어 붙이는 구조가 자연스러웠다.</p>
<p>TAR의 내부 구조는 의외로 단순하다. 모든 데이터는 <strong>512바이트 블록</strong> 단위로 처리된다. 각 파일 앞에 512바이트짜리 헤더 블록이 붙는데, 이 헤더에 파일명(최대 100바이트), 파일 모드, 소유자 UID/GID, 파일 크기, 수정 시간, 체크섬 등의 메타데이터가 들어간다. 파일 데이터는 헤더 뒤에 이어지며, 512바이트의 배수로 패딩된다. 아카이브의 끝은 512바이트짜리 제로 블록 두 개로 표시된다. 현대의 대부분의 TAR 구현체는 POSIX에서 정의한 <strong>UStar(Unix Standard TAR)</strong> 형식을 따르며, 이 형식은 더 긴 파일명(최대 256바이트)과 추가 메타데이터 필드를 지원한다.</p>
<p>핵심은 TAR가 파일의 권한, 소유권, 타임스탬프, 심볼릭 링크 같은 <strong>Unix 파일시스템 메타데이터를 그대로 보존</strong>한다는 점이다. ZIP은 이러한 Unix 고유 메타데이터를 완벽히 보존하지 못하는 경우가 있어, 서버 환경에서의 배포에는 TAR가 더 적합하다.</p>
<p>TAR 자체는 크기를 줄여주지 않는다. 오히려 헤더와 패딩 때문에 원본보다 약간 커진다. 실제 압축은 GZIP, BZIP2, XZ, ZSTD 등과 결합하여 수행한다. 이것이 <code>.tar.gz</code>, <code>.tar.bz2</code>, <code>.tar.xz</code>, <code>.tar.zst</code> 같은 확장자가 존재하는 이유이다. TAR가 "묶기"를, 압축 도구가 "줄이기"를 각각 담당하는 <strong>Unix 철학("한 가지 일을 잘 하라")</strong> 의 전형적인 사례이다.</p>
<p>Unix/Linux 환경에서 표준 아카이빙 도구로 사용되며, Windows에서는 별도의 소프트웨어(7-Zip 등)가 필요하다.</p>
<h2 id="잠깐-brotli도-알아두자"><a class="anchor" href="#잠깐-brotli도-알아두자">잠깐, Brotli도 알아두자</a></h2>
<p>프론트엔드 개발자라면 <strong>Brotli</strong>도 짚고 넘어갈 필요가 있다. Brotli는 Google이 개발한 압축 알고리즘으로, 2015년에 HTTP 스트림 압축 사양(<code>Content-Encoding: br</code>)으로 표준화되었다.</p>
<p>모든 주요 브라우저에서 HTTPS 환경에서 지원되며(지원률 96% 이상), GZIP 대비 <strong>약 15~25% 더 높은 압축률</strong>을 보인다. 특히 JavaScript, CSS, HTML 같은 텍스트 기반 정적 파일의 압축에 효과적이다. Cloudflare 등 주요 CDN에서 기본 압축 방식으로 채택하고 있으며, 현대 웹 최적화의 모범 사례는 "Brotli 우선, GZIP 폴백"으로 정리된다.</p>
<p>빌드 결과물을 S3에 올려 CDN으로 서빙하는 구조라면, 정적 파일을 사전에 Brotli로 압축해 두는 것이 네트워크 전송 최적화에 큰 도움이 된다. (지금 당장 고민하기에는 자료가 많지 않아 도입하기 어렵지만 추후 고민의 여지가 될 수 있기에 개발자라면 알아두면 좋을 것 같다.)</p>
<h2 id="targz가-zip보다-압축률이-높은-이유"><a class="anchor" href="#targz가-zip보다-압축률이-높은-이유">tar.gz가 zip보다 압축률이 높은 이유</a></h2>
<p>이것은 <strong>솔리드 아카이브(Solid Archive)</strong> 와 <strong>비솔리드 아카이브(Non-solid Archive)</strong> 의 차이에서 비롯된다.</p>
<p>tar.gz는 TAR로 모든 파일을 하나의 연속된 데이터 스트림으로 묶은 뒤, GZIP이 이 전체 스트림을 한 번에 압축한다. 이 과정에서 <strong>파일 간의 중복 데이터</strong>를 인식하고 활용할 수 있다. 이것이 솔리드 아카이브 방식이다. 예를 들어 빌드 폴더에 구조가 유사한 JavaScript 번들 파일이 수십 개 있다면, 파일 A에서 등장한 패턴이 파일 B에서도 반복될 때 이를 하나의 참조로 처리할 수 있다. 또한 파일마다 별도의 헤더, 체크섬, 목차(Table of Contents)를 기록할 필요가 없으므로 메타데이터 오버헤드도 줄어든다.</p>
<p>반면 ZIP은 각 파일을 독립적으로 압축하는 비솔리드 방식이므로, 파일 간 중복을 활용하지 못한다. 파일 A와 파일 B에 동일한 코드 블록이 있더라도, 각각의 DEFLATE 스트림은 상대방의 존재를 모른다. 일반적으로 tar.gz가 ZIP 대비 5~15% 더 높은 압축률을 보이는 이유가 바로 이것이다. 특히 유사한 구조의 파일이 대량으로 포함된 빌드 결과물일수록 이 격차는 더 벌어진다.</p>
<p>다만 솔리드 아카이브에도 뚜렷한 단점이 있다.</p>
<ul>
<li>압축 파일 내의 특정 파일 하나만 꺼내려면 <strong>그 파일 앞에 위치한 모든 데이터를 먼저 해제</strong>해야 한다. 모든 파일이 하나의 스트림으로 이어져 있기 때문에, 중간 지점으로 바로 건너뛸 수가 없는 것이다. ZIP은 개별 파일에 대한 랜덤 액세스가 가능하므로, 아카이브에서 특정 파일만 빈번하게 꺼내야 하는 상황이라면 ZIP이 더 적합할 수 있다.</li>
<li>아카이브의 일부가 손상되면 <strong>손상 지점 이후의 모든 데이터가 복구 불가능</strong>해질 수 있다. 비솔리드 방식에서는 손상된 파일 하나만 잃고 나머지는 살릴 수 있는 것과 대비된다.</li>
</ul>
<h2 id="2024년의-선택은-targz-지금은"><a class="anchor" href="#2024년의-선택은-targz-지금은">2024년의 선택은 tar.gz, 지금은?</a></h2>
<p>이 절은 2026년에 덧붙였다. 필자가 당시 tar.gz를 선택한 이유는 호환성과 안정성이었다. S3 업로드 이후 다양한 환경에서 해제해야 했기 때문에, 어디서든 지원되는 tar.gz가 안전한 선택이었다.</p>
<p>하지만 지금 같은 상황이 다시 온다면 <strong>tar.zst(TAR + ZSTD)</strong> 에 대해 고민해볼 것이다. 앞서 살펴본 벤치마크 수치를 다시 떠올려 보자.</p>
<p>GZIP 기본 레벨의 압축 속도가 34MB/s인데, ZSTD 기본 레벨은 300MB/s이다. 2GB 빌드 폴더 기준으로 단순 계산하면 GZIP은 약 60초, ZSTD는 약 7초 만에 압축이 끝난다. 여기에 ZSTD v1.5.7부터 기본 활성화된 멀티스레드(최대 4스레드)까지 고려하면, 실제 체감 시간은 더 짧아진다. CI/CD 파이프라인에서 이 차이는 배포 한 사이클마다 누적되는 것이니, 무시할 수 없는 수치이다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sh" data-theme="github-dark github-light"><code data-language="sh" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># tar.zst 생성 (멀티스레드 자동 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --zstd</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 또는 압축 레벨 지정 (-T0은 사용 가능한 모든 코어 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -I</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zstd -3 -T0'</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span></code></pre></figure>
<p>압축률 면에서도 ZSTD는 GZIP과 동등하거나 오히려 앞서기 때문에, "속도를 위해 압축률을 포기한다"는 트레이드오프가 사실상 존재하지 않는다. 속도도 빠르고, 결과물도 작다.</p>
<p>다만 수신 측 환경에서 zstd 해제가 가능한지는 반드시 확인해야 한다. 주요 Linux 배포판에는 이미 기본 설치되어 있고, macOS에서도 Homebrew(<code>brew install zstd</code>)로 간단히 설치할 수 있다. 하지만 레거시 시스템이나 최소 설치 환경에서는 별도 설치가 필요할 수 있으므로, 팀 내 모든 환경을 사전에 점검하는 것이 중요하다. 호환성이 최우선이라면 여전히 tar.gz가 가장 무난한 선택이다.</p>
<h2 id="한눈에-비교하기"><a class="anchor" href="#한눈에-비교하기">한눈에 비교하기</a></h2>
<table>
<thead>
<tr>
<th>형식</th>
<th>알고리즘</th>
<th>압축률</th>
<th>속도</th>
<th>특징</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ZIP</strong></td>
<td>DEFLATE</td>
<td>보통</td>
<td>빠름</td>
<td>크로스 플랫폼, 비솔리드</td>
</tr>
<tr>
<td><strong>GZIP</strong></td>
<td>DEFLATE</td>
<td>보통</td>
<td>빠름</td>
<td>단일 스트림, TAR와 결합</td>
</tr>
<tr>
<td><strong>ZSTD</strong></td>
<td>Zstandard</td>
<td>높음</td>
<td>매우 빠름</td>
<td>압축 레벨 조절, 최신 표준</td>
</tr>
<tr>
<td><strong>BZIP2</strong></td>
<td>BWT+MTF+Huffman</td>
<td>높음</td>
<td>느림</td>
<td>개발 정체</td>
</tr>
<tr>
<td><strong>XZ</strong></td>
<td>LZMA2</td>
<td>매우 높음</td>
<td>매우 느림</td>
<td>최고 압축률, 보안 사건 주의</td>
</tr>
<tr>
<td><strong>Brotli</strong></td>
<td>Brotli</td>
<td>높음</td>
<td>보통</td>
<td>웹 최적화 특화</td>
</tr>
</tbody>
</table>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>압축이라는 주제를 파보기 전까지는, 솔직히 "그냥 zip으로 묶으면 되는 거 아닌가?"라고 생각했다. 하지만 실제로 2GB가 넘는 빌드 폴더를 다루다 보니, 어떤 알고리즘을 선택하느냐에 따라 업로드 시간과 비용이 유의미하게 달라진다는 것을 체감할 수 있었다.</p>
<p>각 압축 형식은 저마다의 설계 철학과 트레이드오프를 가지고 있다. ZIP의 호환성, GZIP의 범용성, ZSTD의 속도, XZ의 압축률까지 어느 것이 "최고"라고 단정할 수 없고, 프로젝트의 맥락에 따라 적합한 선택이 달라진다.</p>
<p>평소에 무심코 사용하던 도구의 원리를 파보는 것은, 다음에 비슷한 문제를 만났을 때 더 나은 판단을 할 수 있게 해준다. 이 글이 압축 알고리즘을 선택해야 하는 누군가에게 작은 참고가 되었기를 바란다.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>소박한궁금증</category>
            <category>소프트웨어</category>
        </item>
        <item>
            <title><![CDATA[LZ77과 LZ78의 차이]]></title>
            <link>https://hooninedev.com/240701</link>
            <guid isPermaLink="false">https://hooninedev.com/240701</guid>
            <pubDate>Mon, 01 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[이번 포스팅에서는 LZ77과 LZ78이 어떻게 다른지에 대한 이야기를 해보려고 한다. zip, gzip, zstd 같은 압축 도구를 쓰면서 그 안의 사전 기반 압축이 어떻게 동작하는지 궁금했던 개발자를 위한 글이다. 끝까지 읽으면 두 알고리즘이 사전을 다루는 방식의 차이와, 오늘날 주류 압축기가 어느 쪽에서 이어졌는지 설명할 수 있다. 빌드 결과물을 어떤 ...]]></description>
            <content:encoded><![CDATA[<p>이번 포스팅에서는 <strong>LZ77과 LZ78이 어떻게 다른지</strong>에 대한 이야기를 해보려고 한다.</p>
<p>zip, gzip, zstd 같은 압축 도구를 쓰면서 그 안의 사전 기반 압축이 어떻게 동작하는지 궁금했던 개발자를 위한 글이다. 끝까지 읽으면 두 알고리즘이 사전을 다루는 방식의 차이와, 오늘날 주류 압축기가 어느 쪽에서 이어졌는지 설명할 수 있다.</p>
<p>빌드 결과물을 어떤 형식으로 압축할지 비교하다 보면 결국 이 두 알고리즘으로 거슬러 올라가게 된다.</p>
<h2 id="사전-기반-압축"><a class="anchor" href="#사전-기반-압축">사전 기반 압축</a></h2>
<p>무손실 압축(Lossless Compression)은 원본 데이터를 완벽하게 복원할 수 있는 압축 방식이다. 이미지나 오디오에서 사용하는 손실 압축(Lossy Compression)과 달리, 압축을 풀었을 때 원본과 1비트도 다르지 않다. 소스 코드나 빌드 결과물처럼 데이터 무결성이 중요한 경우에 반드시 무손실 압축을 사용해야 한다.</p>
<p>무손실 압축의 핵심 아이디어는 <strong>데이터에 존재하는 통계적 중복성을 활용하는 것</strong>이다. 반복되는 패턴을 짧은 표현으로 대체하면 전체 크기가 줄어드는 원리인 것이다.</p>
<p>이 중에서 <strong>사전 기반(Dictionary-Based) 방식</strong>은 무손실 압축에서 널리 쓰이는 알고리즘 계열이다. 여기서 사전은 앞에서 이미 본 데이터를 다시 가리킬 수 있게 해 두는 장치다. 그 장치가 지나온 데이터 자체인지, 따로 쌓은 목록인지가 LZ77과 LZ78을 가른다. Jacob Ziv와 Abraham Lempel이 1977년 IEEE Transactions on Information Theory에 발표한 <a href="https://doi.org/10.1109/TIT.1977.1055714" target="_blank" rel="noopener noreferrer">논문</a>에서 처음 제안한 <strong>LZ77</strong>, 그리고 두 사람이 이듬해인 1978년에 <a href="https://doi.org/10.1109/TIT.1978.1055934" target="_blank" rel="noopener noreferrer">이어서 발표한 논문</a>의 <strong>LZ78</strong>이 이 계열의 시조이다. 두 사람의 이름에서 알파벳 한 글자씩 따와 "LZ"가 된 것인데, 이 두 알고리즘은 이후 등장한 사전 기반 압축 알고리즘(DEFLATE, LZMA, LZ4, Zstd 등)의 뿌리가 되었다.</p>
<p>쉽게 설명하면 이런 것이다. "Linux"라는 단어가 100번 반복된다면, 두 번째부터는 원문 대신 "앞에서 본 그 조각"을 가리키는 짧은 참조를 쓴다. 참조가 원문보다 짧으면 전체 크기가 줄어든다.</p>
<p>그렇다면 LZ77과 LZ78은 구체적으로 어떻게 다를까?</p>
<h2 id="lz77-sliding-window-방식"><a class="anchor" href="#lz77-sliding-window-방식">LZ77: sliding window 방식</a></h2>
<p>LZ77은 <strong>명시적인 사전을 만들지 않고</strong>, 입력 스트림의 일정 구간을 그 자체로 사전처럼 사용한다. 이 구간을 <strong>sliding window</strong>라고 부르는데, 데이터를 처리한 만큼 윈도우가 앞으로 밀려간다는 뜻에서 붙은 이름이다.</p>
<p>윈도우는 두 영역으로 나뉜다.</p>
<ul>
<li><strong>search buffer</strong>: 이미 처리된, 직전까지의 데이터. 사전 역할을 한다.</li>
<li><strong>look-ahead buffer</strong>: 아직 처리되지 않은, 앞으로 압축할 데이터.</li>
</ul>
<p>알고리즘은 look-ahead buffer의 첫 부분이 search buffer 어딘가에 등장한 적이 있는지를 찾는다. 같은 패턴이 발견되면, 그 매치를 <strong>(거리, 길이, 다음 문자)</strong> 형태의 튜플로 인코딩한다. "거리"는 search buffer 내에서 매치가 시작되는 위치까지 몇 글자 뒤로 가야 하는지를, "길이"는 매치가 몇 글자나 이어지는지를 의미한다. 원 논문은 첫 값을 버퍼 안의 위치로 적었고, 지금의 구현은 같은 정보를 거리로 적는다.</p>
<p>예를 들어 <code>"banana_banana"</code> 라는 문자열을 이 글 뒤쪽에 실은 교육용 인코더로 압축하면 LZ77 토큰 다섯 개가 나온다. <code>(0,0,b)</code> <code>(0,0,a)</code> <code>(0,0,n)</code> <code>(2,3,_)</code> <code>(7,5,a)</code> 이다. 앞의 세 개는 처음 보는 글자라 매치 없이 다음 문자만 담는다. 두 번째 <code>"banana"</code> 는 마지막 토큰 <code>(7,5,a)</code> 하나로 처리된다. "7글자 뒤로 가서 5글자를 복사하고 <code>a</code> 를 붙여라"라는 뜻이다. 여섯 글자를 모두 복사하지 않는 것은 이 인코더가 입력의 마지막 글자를 항상 다음 문자 자리에 남기기 때문이다.</p>
<p><code>(2,3,_)</code> 에서는 길이 3이 거리 2보다 길다. 디코더는 <code>ban</code> 까지 쓴 상태에서 2글자 뒤의 <code>a</code> 부터 복사를 시작하고, 세 번째 글자를 복사할 때는 방금 쓴 <code>a</code> 를 다시 읽는다. 그래서 <code>ana</code> 가 나오고 <code>_</code> 가 붙는다. 한 글자씩 따라가면 아래와 같다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>읽은 글자</th>
<th>출력</th>
</tr>
</thead>
<tbody>
<tr>
<td>시작</td>
<td>없음</td>
<td><code>ban</code></td>
</tr>
<tr>
<td>복사 1</td>
<td>2번째 글자 <code>a</code></td>
<td><code>bana</code></td>
</tr>
<tr>
<td>복사 2</td>
<td>3번째 글자 <code>n</code></td>
<td><code>banan</code></td>
</tr>
<tr>
<td>복사 3</td>
<td>4번째 글자 <code>a</code> (복사 1에서 쓴 것)</td>
<td><code>banana</code></td>
</tr>
<tr>
<td>다음 문자</td>
<td>토큰의 <code>_</code></td>
<td><code>banana_</code></td>
</tr>
</tbody>
</table>
<p><a href="https://www.rfc-editor.org/rfc/rfc1951" target="_blank" rel="noopener noreferrer">RFC 1951</a>도 같은 동작을 명시한다. 마지막 두 바이트가 X, Y일 때 <code>&#x3C;length = 5, distance = 2></code> 는 X,Y,X,Y,X를 덧붙인다. 사전이 곧 방금 복원한 데이터라서 가능한 일이다.</p>
<p>이 방식에서는 <strong>사전을 별도로 저장하거나 전송하지 않는다.</strong> 디코더는 압축을 풀어 나가면서 search buffer를 스스로 재구성하기 때문에, 사전은 데이터 그 자체에 암묵적으로 내장되어 있다고 볼 수 있다. 참조가 앞쪽 데이터를 가리키므로 압축 해제는 기본적으로 앞에서부터 순서대로 진행한다. 다만 참조가 닿는 범위는 윈도우까지다. RFC 1951은 DEFLATE의 참조를 최대 32K 바이트 앞까지로 제한한다. zlib의 <code>Z_FULL_FLUSH</code> 는 압축 상태를 리셋해, 앞의 압축 데이터가 손상됐거나 random access가 필요할 때 그 지점부터 압축 해제를 다시 시작할 수 있게 한다. <a href="https://github.com/madler/zlib/blob/v1.3.1/zlib.h" target="_blank" rel="noopener noreferrer">zlib.h</a>는 너무 자주 쓰면 압축률이 크게 떨어진다고 경고한다.</p>
<p>윈도우 크기는 압축률과 직접적인 트레이드오프 관계에 있다. 윈도우가 크면 더 멀리 떨어진 패턴까지 참조할 수 있어 압축률이 높아지지만, 메모리 사용량이 늘고, 찾아볼 후보가 많아져 매치 검색도 대개 느려진다.</p>
<h2 id="lz78-명시적-사전-방식"><a class="anchor" href="#lz78-명시적-사전-방식">LZ78: 명시적 사전 방식</a></h2>
<p>LZ78은 LZ77과 달리, <strong>사전을 명시적으로 구축</strong>하면서 압축을 진행한다. Sliding window 같은 것은 없다. 대신 이전에 본 패턴들을 인덱스가 붙은 사전 항목으로 저장해 두고, 이후에 같은 패턴이 등장하면 인덱스로 대체한다.</p>
<p>LZ78이 출력하는 단위는 <strong>(사전 인덱스, 다음 문자)</strong> 형태의 태그이다. 인코더는 사전에서 가장 길게 일치하는 항목을 찾은 뒤, 그 항목의 인덱스와 매치를 깨뜨린 다음 문자를 묶어서 출력한다. 그리고 "방금 매치된 항목 + 새로운 문자"를 다시 사전에 새 항목으로 추가한다. 이렇게 사전이 점진적으로 자라나는 구조이다.</p>
<p>같은 <code>"banana_banana"</code> 를 LZ78로 압축하면 토큰 여덟 개가 나오고 사전에는 항목 일곱 개가 쌓인다. 토큰은 <code>(0,b)</code> <code>(0,a)</code> <code>(0,n)</code> <code>(2,n)</code> <code>(2,_)</code> <code>(1,a)</code> <code>(3,a)</code> <code>(7,)</code> 이고, 사전은 <code>1:b</code> <code>2:a</code> <code>3:n</code> <code>4:an</code> <code>5:a_</code> <code>6:ba</code> <code>7:na</code> 이다. 두 번째 <code>"banana"</code> 는 <code>ba</code>, <code>na</code>, <code>na</code> 를 만드는 토큰 세 개로 나뉜다. LZ77이 토큰 하나로 끝낸 부분이다. LZ78의 사전 항목은 한 번에 한 글자씩만 길어지므로, 같은 단어를 여러 번 봐야 긴 항목이 생긴다. 마지막 <code>(7,)</code> 은 입력이 끝나 다음 문자가 없는 경우다. 이 인코더는 토큰을 비트로 묶지 않으므로 토큰 수로 압축률을 비교할 수는 없다.</p>
<p>두 토큰열은 아래 코드로 재현할 수 있다. 2026-10-08에 macOS 26.6.2, Node v24.16.0에서 파일로 저장해 <code>node lz.mjs</code> 로 돌린 결과다.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ77: (거리, 길이, 다음 문자). 가장 긴 일치를 고르고, 겹친 복사를 허용한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">win</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 4096</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, l: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> win); j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i; j</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 입력의 마지막 글자는 매치에 넣지 않고 다음 문자로 남긴다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l]) l</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l) best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j, l }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([best.d, best.l, s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l]])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> out</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ78: (사전 인덱스, 다음 문자). 인덱스 0 은 빈 문자열이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> c</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">continue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 입력이 끝나 다음 문자가 없다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      break</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, c])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c, dict.size </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { out, dict: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()] }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 디코더 모두 토큰만 받는다. 사전은 전달받지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l; k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o[o.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> d] </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 방금 쓴 글자도 다시 읽는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> entry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict[i] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> entry</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(entry) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 인코더와 같은 순서로 사전을 다시 쌓는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'banana_banana'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ77'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, a.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">d</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">l</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(a) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ78'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">i</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.out) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'사전'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">k</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}:${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>LZ77 (0,0,b) (0,0,a) (0,0,n) (2,3,_) (7,5,a) true</span></span>
<span data-line=""><span>LZ78 (0,b) (0,a) (0,n) (2,n) (2,_) (1,a) (3,a) (7,) true</span></span>
<span data-line=""><span>사전 1:b 2:a 3:n 4:an 5:a_ 6:ba 7:na</span></span></code></pre></figure>
<h2 id="두-방식이-갈리는-곳"><a class="anchor" href="#두-방식이-갈리는-곳">두 방식이 갈리는 곳</a></h2>
<p>코드의 두 디코더는 토큰만 받는다. <strong>LZ78도 사전을 보내지 않는다</strong>는 뜻이다. 디코더가 토큰을 읽으며 인코더와 같은 순서로 항목을 추가하므로 같은 사전이 다시 쌓인다. 사전을 전송하지 않는다는 점은 두 알고리즘의 공통점이고, 둘이 갈리는 곳은 <strong>오래된 내용을 잊는 방식</strong>이다. LZ77의 윈도우는 앞으로 밀려가며 오래된 데이터를 저절로 잊는다. LZ78의 사전은 그대로 두면 계속 자란다. 원 논문은 정해진 길이의 블록이 끝날 때마다 사전을 통째로 비웠고, 실제 구현은 사전 크기에 상한을 두고 꽉 차면 얼리거나 비우거나 일부 항목을 재사용한다.</p>
<p>LZ78의 가장 유명한 변종이 바로 <strong>LZW</strong>(Lempel-Ziv-Welch)이다. 1984년 Terry Welch가 LZ78을 개선하여 발표한 것으로, GIF 이미지 포맷과 유닉스의 <code>compress</code> 유틸리티(<code>.Z</code> 확장자)에서 사용되었다. 두 구현 모두 사전에 상한을 두었다. <a href="https://www.w3.org/Graphics/GIF/spec-gif89a.txt" target="_blank" rel="noopener noreferrer">GIF89a 명세</a>는 코드를 최대 12비트(최댓값 4095)로 묶고, 사전을 초기 상태로 되돌리는 Clear code를 따로 둔다. <a href="https://github.com/vapier/ncompress/blob/v5.0/compress.1" target="_blank" rel="noopener noreferrer">ncompress man page</a>에 따르면 <code>compress</code> 는 코드 길이가 <code>-b</code> 상한(기본 16비트)에 닿은 뒤로 압축률을 지켜보다가, 압축률이 떨어지면 사전을 버리고 처음부터 다시 쌓는다.</p>
<p>두 논문은 각자의 모형에서 점근적 최적성을 보였다. 1977년 논문은 LZ77의 압축률이 원천을 미리 알고 설계한 부호의 하한에 균일하게 다가간다("uniformly approaches the lower bounds")고 보였고, 1978년 논문은 개별 수열(individual sequence)을 대상으로 LZ78의 incremental parsing이 점근적으로 최적임을 보였다. 모형이 서로 다르므로 이 결과만으로 둘을 비교할 수는 없다.</p>
<h2 id="lz77-계열의-후손"><a class="anchor" href="#lz77-계열의-후손">LZ77 계열의 후손</a></h2>
<p>그렇다면 현대 압축 알고리즘은 어느 쪽에서 이어졌을까? 오늘날 우리가 사용하는 주류 압축 알고리즘은 대부분 <strong>LZ77 계열의 후손</strong>이다.</p>
<p>1982년 Storer와 Szymanski의 논문에서 이어진 <strong>LZSS</strong>는 LZ77의 변종이다. 매치가 너무 짧아 포인터가 원본보다 길어질 때는 포인터 대신 리터럴(원본 문자)을 그대로 낸다.</p>
<p><strong>DEFLATE</strong>는 Phil Katz가 PKZIP 2를 위해 설계했고, 1996년 RFC 1951로 명세가 정리됐다. RFC 1951은 DEFLATE를 LZ77과 <strong>허프만 부호화</strong>(빈도가 높은 심볼에 짧은 비트를 할당하는 엔트로피 코딩)의 조합이라고 쓴다. 출력은 리터럴과 (길이, 거리) 쌍이 섞인 열이고, 리터럴과 매치 길이를 한 알파벳(0~285)에 합쳐 허프만 부호 하나로 구분한다. 대부분의 ZIP 프로그램이 기본으로 쓰는 압축 방식, GZIP, PNG가 모두 이 DEFLATE를 사용한다. 즉, 우리가 매일같이 만지는 <code>.zip</code>, <code>.gz</code>, <code>.png</code> 파일은 대부분 LZ77의 직계 후손인 셈이다.</p>
<p>LZ77 쪽이 주류가 된 이유를 하나로 꼽을 수는 없지만, 명세들이 직접 내세운 점 하나는 특허다. RFC 1951은 LZ77의 변형 가운데에도 특허가 걸린 것이 많다고 적으면서, DEFLATE 형식은 특허에 걸리지 않는 방식으로 쉽게 구현할 수 있다고 명시한다. <a href="https://www.w3.org/TR/png-3/" target="_blank" rel="noopener noreferrer">PNG 명세</a>는 첫머리에서 PNG를 특허 없는 GIF 대체 형식으로 소개한다. GIF는 앞에서 본 LZW를 쓰는 형식이다.</p>
<p>이후에 등장한 <strong>LZMA</strong>(7-Zip, XZ), <strong>LZ4</strong>, <strong>Zstd</strong> 도 LZ77의 sliding window 아이디어에서 출발했다. LZMA와 Zstd는 매치 검색과 엔트로피 코딩을 함께 발전시켰고, LZ4는 엔트로피 코딩을 아예 빼고 바이트 단위 형식으로 속도를 택했다. LZ78 계열은 LZW의 형태로 GIF 같은 형식 안에 남아 있다.</p>
<h2 id="마치며"><a class="anchor" href="#마치며">마치며</a></h2>
<p>LZ77과 LZ78은 반복되는 패턴을 짧은 참조로 바꾼다는 같은 아이디어에서 출발했다. 둘 다 사전을 보내지 않고 디코더가 다시 쌓지만, 참조가 가리키는 곳이 지나온 데이터의 위치냐 따로 쌓은 사전의 번호냐에서 갈렸고, 오래된 것을 잊는 방식도 달랐다. DEFLATE를 거쳐 LZMA, LZ4, Zstd로 이어진 주류 압축기는 LZ77 쪽 후손이고, LZ78 쪽은 LZW의 형태로 GIF 같은 형식에 남았다.</p>
<p>이 계보가 실제 형식 선택에서 어떤 차이로 이어지는지, 즉 ZIP, GZIP, ZSTD, XZ의 속도와 압축률을 비교하고 빌드 결과물에 무엇을 고를지는 <a href="/240706" target="_blank" rel="noopener noreferrer">압축 알고리즘에 대해 알아보자</a>에서 다룬다.</p>
<p>다음에 <code>.zip</code>이나 <code>.gz</code> 파일을 풀 때, 그 안에서 "몇 글자 뒤로 가서 몇 글자를 복사하라"는 지시가 오가고 있다는 것을 한 번쯤 떠올려 보시길 바란다.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1145/322344.322346" target="_blank" rel="noopener noreferrer">Storer, Szymanski, Data compression via textual substitution (1982)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1109/MC.1984.1659158" target="_blank" rel="noopener noreferrer">Welch, A Technique for High-Performance Data Compression (1984)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT" target="_blank" rel="noopener noreferrer">PKWARE, APPNOTE.TXT: .ZIP File Format Specification</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>소박한궁금증</category>
            <category>소프트웨어</category>
        </item>
    </channel>
</rss>