<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>sojin-zip.log</title>
        <link>https://velog.io/</link>
        <description>ISTJ 기록 저장소</description>
        <lastBuildDate>Wed, 30 Sep 2026 07:08:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>sojin-zip.log</title>
            <url>https://velog.velcdn.com/images/sojin-zip/profile/2fd4676c-346a-412b-854b-9f5da99044bd/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. sojin-zip.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/sojin-zip" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[신입 개발자를 위한 React Hook 정리]]></title>
            <link>https://velog.io/@sojin-zip/%EC%9E%85-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-React-Hook-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@sojin-zip/%EC%9E%85-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-React-Hook-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Wed, 30 Sep 2026 07:08:43 GMT</pubDate>
            <description><![CDATA[<h1 id="신입-개발자를-위한-react-hook-정리">신입 개발자를 위한 React Hook 정리</h1>
<blockquote>
<p>React 19 공식 문서 기준으로 정리한 내장 Hook 17개 가이드</p>
</blockquote>
<hr>
<h2 id="0-이-글의-구성">0. 이 글의 구성</h2>
<p>17개 Hook을 중요도에 따라 세 묶음으로 나눠서 다뤄요. 원하는 Hook으로 바로 이동할 수 있게 한눈에 보기 표를 먼저 둬요.</p>
<table>
<thead>
<tr>
<th>묶음</th>
<th>Hook</th>
</tr>
</thead>
<tbody><tr>
<td>매일 쓰는 Hook</td>
<td><code>useState</code>, <code>useEffect</code>, <code>useRef</code>, <code>useContext</code></td>
</tr>
<tr>
<td>구조·성능·비동기 UI</td>
<td><code>useReducer</code>, <code>useMemo</code>, <code>useCallback</code>, <code>useTransition</code>, <code>useDeferredValue</code>, <code>useActionState</code></td>
</tr>
<tr>
<td>알아만 두는 Hook</td>
<td><code>useLayoutEffect</code>, <code>useInsertionEffect</code>, <code>useImperativeHandle</code>, <code>useEffectEvent</code>, <code>useId</code>, <code>useSyncExternalStore</code>, <code>useDebugValue</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="1-들어가기-전에">1. 들어가기 전에</h2>
<p>Hook을 하나씩 살펴보기 전에, 이 글 전체에서 계속 나오는 개념 몇 가지를 먼저 잡고 가요. Hook 문서를 읽다가 막히는 부분은 대부분 Hook 자체보다 이 개념들에서 출발해요.</p>
<hr>
<h3 id="1-1-화면이-바뀌는-과정-트리거-렌더링-커밋">1-1. 화면이 바뀌는 과정: 트리거, 렌더링, 커밋</h3>
<p>React에서 화면을 이루는 조각을 <strong>컴포넌트</strong>라고 불러요. 컴포넌트는 &quot;화면에 무엇이 있어야 하는지&quot;를 반환하는 자바스크립트 함수예요.</p>
<pre><code class="language-jsx">function Greeting() {
  return &lt;h1&gt;안녕하세요&lt;/h1&gt;;
}</code></pre>
<p>React에서 화면이 바뀌는 과정은 항상 세 단계로 이뤄져요. 공식 문서는 이것을 식당에 비유해요. 컴포넌트는 주방의 요리사이고, React는 손님의 주문을 주방에 전달하고 완성된 요리를 테이블에 가져다주는 웨이터예요.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>무슨 일이 일어나나요</th>
<th>식당에 비유하면</th>
</tr>
</thead>
<tbody><tr>
<td>1. 트리거</td>
<td>렌더링이 시작돼요</td>
<td>손님의 주문을 주방에 전달</td>
</tr>
<tr>
<td>2. 렌더링</td>
<td>React가 컴포넌트 함수를 호출해서 화면에 무엇을 그릴지 계산해요</td>
<td>주방에서 요리</td>
</tr>
<tr>
<td>3. 커밋</td>
<td>계산한 결과를 실제 화면(DOM)에 반영해요</td>
<td>테이블에 서빙</td>
</tr>
</tbody></table>
<h4 id="1단계-트리거">1단계. 트리거</h4>
<p>렌더링이 시작되는 이유는 두 가지예요.</p>
<ol>
<li>앱이 처음 시작될 때 (최초 렌더링)</li>
<li>컴포넌트(또는 그 조상 컴포넌트)의 <strong>state가 업데이트</strong>될 때</li>
</ol>
<p>state를 바꾸는 <code>set</code> 함수를 호출하면 React가 렌더링을 예약해요.</p>
<h4 id="2단계-렌더링">2단계. 렌더링</h4>
<p><strong>&quot;렌더링&quot;은 React가 컴포넌트를 호출하는 것</strong>이에요. 컴포넌트가 다른 컴포넌트를 반환하면 React는 그 컴포넌트도 이어서 호출하고, 더 이상 호출할 컴포넌트가 없을 때까지 계속해요. 그래서 <strong>부모가 렌더링되면 기본적으로 그 안의 자식도 모두 다시 렌더링돼요.</strong> 이 사실은 3-2에서 성능을 이야기할 때 중요해져요.</p>
<p>다시 렌더링할 때 React는 이전과 달라진 부분이 있는지 계산해요. 여기까지는 계산일 뿐이고, 아직 화면은 바뀌지 않아요.</p>
<h4 id="3단계-커밋">3단계. 커밋</h4>
<p>계산이 끝나면 React가 실제 화면(DOM)을 바꿔요. 이때 <strong>이전 렌더링과 차이가 있는 부분만</strong> 바꿔요. 렌더링했다고 해서 화면이 항상 바뀌는 것은 아니에요.</p>
<pre><code class="language-jsx">function Clock({ time }) {
  return (
    &lt;&gt;
      &lt;h1&gt;{time}&lt;/h1&gt;
      &lt;input /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>부모가 매초 새로운 <code>time</code>을 내려줘서 <code>Clock</code>이 매초 다시 렌더링되어도, 입력창에 적어 둔 글자는 사라지지 않아요. 커밋 단계에서 React가 <code>&lt;h1&gt;</code>의 내용만 새 시간으로 바꾸고, <code>&lt;input&gt;</code>은 지난번과 같은 자리에 있으니 건드리지 않기 때문이에요.</p>
<blockquote>
<p><strong>페인트는 React의 일이 아니에요.</strong> React가 DOM을 바꾸고 나면 브라우저가 화면을 다시 그려요. 이것을 흔히 &quot;브라우저 렌더링&quot;이라고 부르는데, 공식 문서는 React의 렌더링과 헷갈리지 않도록 &quot;페인트&quot;라고 불러요. React에서 말하는 렌더링은 화면을 그리는 것이 아니라 컴포넌트를 호출해서 결과를 계산하는 것이에요.</p>
</blockquote>
<h4 id="카운터로-따라가-보기">카운터로 따라가 보기</h4>
<pre><code class="language-jsx">function Counter() {
  const [count, setCount] = useState(0);

  return &lt;button onClick={() =&gt; setCount(count + 1)}&gt;{count}&lt;/button&gt;;
}</code></pre>
<ol>
<li>앱이 시작되면 최초 렌더링이 일어나요. <code>Counter</code>가 호출되고 <code>count</code>는 <code>0</code>이에요. 커밋 후 버튼에 <code>0</code>이 보여요.</li>
<li>버튼을 누르면 <code>setCount(1)</code>이 호출돼요. &quot;다음에 그릴 때는 <code>count</code>를 <code>1</code>로 해줘&quot;라는 요청이고, 이것이 <strong>트리거</strong>예요.</li>
<li><strong>렌더링</strong>: React가 <code>Counter</code> 함수를 <strong>처음부터 다시 호출</strong>해요. 이번에는 <code>count</code>가 <code>1</code>이에요.</li>
<li><strong>커밋</strong>: 이전과 달라진 버튼의 글자만 <code>0</code>에서 <code>1</code>로 바뀌어요.</li>
</ol>
<p>여기서 중요한 사실이 하나 있어요. <strong>컴포넌트 함수는 렌더링할 때마다 처음부터 다시 실행돼요.</strong> 그래서 함수 안에서 만든 일반 변수는 매번 새로 만들어지고, 값을 계속 기억해 두려면 React가 대신 보관해 주는 장치가 필요해요. 그 장치가 바로 Hook이에요.</p>
<hr>
<h3 id="1-2-hook이란-그리고-호출-규칙">1-2. Hook이란, 그리고 호출 규칙</h3>
<p>이름이 <code>use</code>로 시작하는 함수를 <strong>Hook</strong>이라고 불러요. 컴포넌트 안에서 React의 기능(값 기억하기, 외부 시스템과 연결하기 등)을 꺼내 쓰게 해줘요. <code>useState</code>처럼 React가 기본으로 제공하는 것도 있고, 이 기본 Hook들을 조합해서 직접 만들 수도 있어요(커스텀 Hook).</p>
<p>Hook에는 지켜야 할 규칙이 두 가지 있어요.</p>
<h4 id="규칙-1-hook은-최상위에서만-호출해요">규칙 1. Hook은 최상위에서만 호출해요</h4>
<p>컴포넌트(또는 커스텀 Hook)의 <strong>최상위</strong>에서, <strong>early return보다 앞에서</strong> 호출해야 해요. 공식 문서는 아래와 같은 곳에서는 호출할 수 없다고 안내해요.</p>
<ul>
<li>조건문이나 반복문 안</li>
<li>조건부 <code>return</code> 뒤</li>
<li>이벤트 핸들러 안</li>
<li>그 밖의 중첩 함수 안</li>
<li><code>try</code> / <code>catch</code> / <code>finally</code> 블록 안</li>
<li><code>useMemo</code>, <code>useReducer</code>, <code>useEffect</code>에 넘긴 함수 안</li>
<li>클래스 컴포넌트 안</li>
</ul>
<pre><code class="language-jsx">function Bad({ cond }) {
  if (cond) {
    // 잘못된 방식: 조건문 안에서 Hook 호출
    const theme = useContext(ThemeContext);
  }
}

function Bad({ cond }) {
  if (cond) {
    return;
  }
  // 잘못된 방식: 조건부 return 뒤에서 Hook 호출
  const theme = useContext(ThemeContext);
}

function Bad() {
  function handleClick() {
    // 잘못된 방식: 이벤트 핸들러 안에서 Hook 호출
    const theme = useContext(ThemeContext);
  }
}</code></pre>
<pre><code class="language-jsx">function Counter() {
  // 올바른 방식: 컴포넌트 최상위에서 호출
  const [count, setCount] = useState(0);
  // ...
}

function useWindowWidth() {
  // 올바른 방식: 커스텀 Hook 최상위에서 호출
  const [width, setWidth] = useState(window.innerWidth);
  // ...
}</code></pre>
<p>고치는 방법은 간단해요. Hook 호출을 조건문이나 함수 <strong>바깥, 위쪽으로 옮기면</strong> 돼요. 조건에 따라 동작이 달라져야 한다면 <strong>조건을 Hook &quot;안&quot;에 넣어요.</strong> 조건에 따라 state 자체가 필요하다면, 그 부분을 새 컴포넌트로 분리하고 그 안의 최상위에서 호출하세요.</p>
<p><strong>왜 이런 규칙이 있을까요?</strong> 공식 문서는 이렇게 설명해요. 컴포넌트 안에 <code>useState</code>가 여러 개 있을 때, React는 &quot;어떤 state가 어떤 <code>useState</code> 호출의 것인지&quot;를 <strong>Hook이 호출된 순서</strong>로 알아내요.</p>
<pre><code class="language-jsx">function Form() {
  // 첫 번째 Hook 호출: name 보관
  const [name, setName] = useState(&#39;Mary&#39;);

  // 두 번째 Hook 호출: surname 보관
  const [surname, setSurname] = useState(&#39;Poppins&#39;);
}</code></pre>
<p>렌더링할 때마다 호출 순서가 똑같으면, React는 &quot;첫 번째 칸은 <code>name</code>, 두 번째 칸은 <code>surname</code>&quot;으로 값을 정확히 대응시킬 수 있어요. 그런데 Hook을 조건문 안에 두면 이런 일이 생겨요.</p>
<pre><code class="language-jsx">function Form({ showNickname }) {
  const [name, setName] = useState(&#39;Mary&#39;);

  if (showNickname) {
    // 잘못된 방식: 조건에 따라 호출 순서 변경
    const [nickname, setNickname] = useState(&#39;&#39;);
  }

  const [surname, setSurname] = useState(&#39;Poppins&#39;);
}</code></pre>
<p><code>showNickname</code>이 <code>true</code>일 때는 <code>name → nickname → surname</code> 순서로, <code>false</code>일 때는 <code>name → surname</code> 순서로 호출돼요. 그러면 &quot;두 번째 Hook 호출&quot;이 어떤 때는 <code>nickname</code>이고 어떤 때는 <code>surname</code>이에요. React는 어떤 값을 돌려줘야 할지 알 수 없고, 건너뛴 Hook 뒤의 모든 호출이 한 칸씩 밀려서 버그로 이어져요.</p>
<h4 id="규칙-2-hook은-react-함수에서만-호출해요">규칙 2. Hook은 React 함수에서만 호출해요</h4>
<p>Hook은 <strong>컴포넌트</strong>나 <strong>커스텀 Hook</strong> 안에서만 호출할 수 있어요. 일반 자바스크립트 함수 안에서는 호출할 수 없어요. 이 규칙 덕분에 컴포넌트의 state와 관련된 로직이 소스 코드에서 분명하게 보여요. 커스텀 Hook은 다른 Hook을 호출할 수 있고, 그게 커스텀 Hook의 존재 이유예요.</p>
<h4 id="린터가-대신-잡아줘요">린터가 대신 잡아줘요</h4>
<p>두 규칙은 <code>eslint-plugin-react-hooks</code>가 자동으로 검사해 줘요. 설정에서 <code>react-hooks/rules-of-hooks</code>는 Hook 규칙을, <code>react-hooks/exhaustive-deps</code>는 2-2에서 배울 Effect의 의존성을 검사해요. 린터를 켜 두면 규칙을 어길 때 바로 알려주니 꼭 켜 두세요.</p>
<hr>
<h3 id="1-3-렌더링은-순수한-계산이어야-해요">1-3. 렌더링은 &quot;순수한 계산&quot;이어야 해요</h3>
<p>React는 <strong>모든 컴포넌트가 순수한 함수라고 가정해요.</strong> 순수하다는 것은 두 가지를 뜻해요.</p>
<ol>
<li><strong>자기 할 일만 해요.</strong> 렌더링 전에 이미 있던 객체나 변수를 바꾸지 않아요.</li>
<li><strong>같은 입력이면 같은 출력을 내요.</strong> 같은 props, state, context가 들어오면 항상 같은 JSX를 돌려줘요.</li>
</ol>
<p>수학 공식과 같아요. <code>y = 2x</code>에서 <code>x</code>가 <code>3</code>이면 <code>y</code>는 언제나 <code>6</code>이에요. 시간대나 주식 시장에 따라 <code>9</code>나 <code>2.5</code>가 되는 일은 없어요. 컴포넌트를 레시피라고 생각해도 돼요. 레시피대로 만들고 조리 중에 새 재료를 끼워 넣지 않으면 매번 같은 요리가 나와요.</p>
<h4 id="순수하지-않은-컴포넌트">순수하지 않은 컴포넌트</h4>
<pre><code class="language-jsx">let guest = 0;

function Cup() {
  // 잘못된 방식: 이미 있던 변수 수정
  guest = guest + 1;
  return &lt;h2&gt;{guest}번 손님의 찻잔&lt;/h2&gt;;
}

export default function TeaSet() {
  return (
    &lt;&gt;
      &lt;Cup /&gt;
      &lt;Cup /&gt;
      &lt;Cup /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p><code>Cup</code>은 컴포넌트 밖에 있는 <code>guest</code> 변수를 읽고 써요. 그래서 <strong>같은 컴포넌트를 여러 번 호출하면 결과가 달라지고</strong>, 다른 컴포넌트가 <code>guest</code>를 읽는다면 렌더링된 순서에 따라 결과가 또 달라져요. 예측할 수 없는 상태예요.</p>
<p><code>guest</code>를 prop으로 받으면 순수해져요.</p>
<pre><code class="language-jsx">function Cup({ guest }) {
  return &lt;h2&gt;{guest}번 손님의 찻잔&lt;/h2&gt;;
}

export default function TeaSet() {
  return (
    &lt;&gt;
      &lt;Cup guest={1} /&gt;
      &lt;Cup guest={2} /&gt;
      &lt;Cup guest={3} /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>이제 <code>Cup</code>이 돌려주는 JSX는 <code>guest</code> prop에만 의존해요.</p>
<h4 id="렌더링-중에-읽을-수-있는-입력은-세-가지예요">렌더링 중에 읽을 수 있는 입력은 세 가지예요</h4>
<p>렌더링 중에 읽을 수 있는 입력은 <strong>props, state, context</strong>예요. 이 세 가지는 항상 <strong>읽기 전용</strong>으로 다뤄야 해요. 무언가를 바꾸고 싶다면 변수에 쓰지 말고 state를 바꾸세요.</p>
<h4 id="예외-방금-만든-것은-바꿔도-괜찮아요">예외: 방금 만든 것은 바꿔도 괜찮아요</h4>
<pre><code class="language-jsx">function TeaGathering() {
  // 렌더링 중 새로 만든 배열: 수정해도 안전
  const cups = [];
  for (let i = 1; i &lt;= 12; i++) {
    cups.push(&lt;Cup key={i} guest={i} /&gt;);
  }
  return cups;
}</code></pre>
<p>문제가 되는 것은 렌더링 <strong>전에</strong> 이미 있던 객체를 바꾸는 경우예요. 같은 렌더링 안에서 새로 만든 배열에 <code>push</code>하는 것은 괜찮아요. 이 컴포넌트 밖에서는 그런 일이 있었는지 아무도 모르기 때문이에요. 공식 문서는 이것을 &quot;지역 변경(local mutation)&quot;, 컴포넌트의 작은 비밀이라고 불러요.</p>
<h4 id="그럼-부수효과는-어디서-하나요">그럼 부수효과는 어디서 하나요?</h4>
<p>화면을 바꾸거나, 애니메이션을 시작하거나, 데이터를 바꾸는 일처럼 &quot;렌더링 계산 이외의 일&quot;을 <strong>부수효과</strong>라고 해요. 프로그램이 뭔가 의미 있는 일을 하려면 부수효과는 꼭 필요해요. 다만 렌더링 중에 하면 안 돼요.</p>
<ul>
<li><strong>이벤트 핸들러에서 해요.</strong> 클릭 같은 동작이 일어났을 때 React가 실행하는 함수예요. 컴포넌트 안에 정의되어 있어도 렌더링 중에는 실행되지 않기 때문에 순수할 필요가 없어요.</li>
<li>적당한 이벤트 핸들러가 없다면 <strong><code>useEffect</code>를 마지막 수단으로</strong> 써요. React가 렌더링이 끝난 뒤에 실행해 줘요. (2-2에서 자세히 다뤄요.)</li>
</ul>
<h4 id="왜-react는-순수함을-이렇게-중요하게-여길까요">왜 React는 순수함을 이렇게 중요하게 여길까요?</h4>
<p>공식 문서는 순수함이 가능하게 해주는 것들을 이렇게 설명해요.</p>
<ul>
<li>렌더링은 <strong>언제든 일어날 수 있어서</strong>, 컴포넌트끼리 렌더링 순서에 기대면 안 돼요. 각 컴포넌트는 자기 JSX를 스스로 계산해야 해요.</li>
<li>서버 같은 다른 환경에서도 컴포넌트를 실행할 수 있어요.</li>
<li>입력이 바뀌지 않은 컴포넌트는 렌더링을 건너뛰어도 안전해요 (3-2의 <code>memo</code>).</li>
<li>렌더링하는 도중에 데이터가 바뀌면, React는 오래된 렌더링을 끝까지 계산하지 않고 <strong>중간에 멈추고 처음부터 다시</strong> 시작할 수 있어요.</li>
</ul>
<h4 id="개발-중에는-함수가-두-번-실행돼요-strict-mode">개발 중에는 함수가 두 번 실행돼요 (Strict Mode)</h4>
<p>실수를 미리 잡아주는 장치도 있어요. <strong>Strict Mode</strong>는 개발 환경에서 React가 <strong>각 컴포넌트 함수를 두 번 호출</strong>해요. 순수하지 않은 컴포넌트는 두 번 호출되면 티가 나기 때문이에요.</p>
<p>위의 순수하지 않은 <code>Cup</code> 예제는 Strict Mode에서 <code>1, 2, 3</code>번 손님이 아니라 <strong><code>2, 4, 6</code>번 손님</strong>으로 표시돼요. <code>Cup</code> 하나가 두 번씩 호출되어 <code>guest</code>가 한 번에 2씩 늘었기 때문이에요. 반면 <code>guest</code>를 prop으로 받는 순수한 버전은 두 번 호출돼도 결과가 같아요.</p>
<ul>
<li><code>&lt;StrictMode&gt;</code>로 앱을 감싸야 켜져요. 일부 프레임워크는 기본으로 켜 둬요.</li>
<li>배포된 앱에는 영향이 없어요. 개발 중에만 동작해요.</li>
<li>컴포넌트 함수 말고도, 각 Hook 문서에 따라 <code>useState</code>의 초기값 함수와 업데이터 함수, <code>useMemo</code>의 계산 함수, <code>useReducer</code>의 reducer도 개발 중에 두 번 실행돼요. <code>useEffect</code>는 setup → cleanup → setup이 한 번 더 실행돼요.</li>
</ul>
<p>그래서 <strong>개발 중에 <code>console.log</code>가 두 번 찍혀도 놀라지 마세요.</strong> 버그가 아니에요. 순수한 코드라면 두 번 실행해도 문제가 없어야 해요.</p>
<hr>
<h3 id="1-4-이-글에서-계속-나오는-말-미리보기">1-4. 이 글에서 계속 나오는 말 미리보기</h3>
<table>
<thead>
<tr>
<th>말</th>
<th>한 줄 설명</th>
<th>자세한 설명</th>
</tr>
</thead>
<tbody><tr>
<td>트리거 / 렌더링 / 커밋</td>
<td>렌더링 시작 / 컴포넌트를 호출해 화면을 계산 / 계산 결과를 실제 화면에 반영</td>
<td>위에서 설명했어요</td>
</tr>
<tr>
<td>리렌더링</td>
<td>state가 바뀌어 컴포넌트를 다시 호출하는 것</td>
<td>위에서 설명했어요</td>
</tr>
<tr>
<td>순수한 컴포넌트</td>
<td>같은 입력이면 같은 JSX를 돌려주고, 렌더링 중에는 계산만 해요</td>
<td>위에서 설명했어요</td>
</tr>
<tr>
<td>스냅샷</td>
<td>한 번의 렌더링 안에서 state는 고정된 값이에요. <code>set</code>을 호출해도 그 렌더링 안에서는 값이 그대로예요</td>
<td>2-1 <code>useState</code></td>
</tr>
<tr>
<td>불변성</td>
<td>state에 든 객체나 배열은 직접 고치지 않고, 새로 만들어서 교체해요</td>
<td>2-1 <code>useState</code></td>
</tr>
<tr>
<td>의존성</td>
<td><code>useEffect</code>가 &quot;이 값이 바뀌면 다시 실행해&quot;라고 지켜보는 값</td>
<td>2-2 <code>useEffect</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="2-매일-쓰는-hook">2. 매일 쓰는 Hook</h2>
<p>Hook 중에서도 거의 모든 화면에서 만나게 되는 네 가지를 먼저 살펴볼게요. 예제에 나오는 <code>fetchBio</code>, <code>createConnection</code>, <code>post</code> 같은 함수는 설명을 위해 가정한 예시 함수예요.</p>
<table>
<thead>
<tr>
<th>Hook</th>
<th>한 줄로 말하면</th>
</tr>
</thead>
<tbody><tr>
<td><code>useState</code></td>
<td>화면에 반영되는 값을 기억해요</td>
</tr>
<tr>
<td><code>useEffect</code></td>
<td>React 밖의 것(타이머, 서버 연결 등)과 컴포넌트를 맞춰줘요</td>
</tr>
<tr>
<td><code>useRef</code></td>
<td>화면과 상관없는 값이나 DOM 요소를 기억해요</td>
</tr>
<tr>
<td><code>useContext</code></td>
<td>멀리 있는 부모의 값을 바로 읽어요</td>
</tr>
</tbody></table>
<hr>
<h3 id="2-1-usestate--컴포넌트가-값을-기억하게-하기">2-1. <code>useState</code> — 컴포넌트가 값을 기억하게 하기</h3>
<h4 id="이런-hook이에요">이런 Hook이에요</h4>
<p>컴포넌트가 &quot;기억&quot;해야 하는 값을 만들고, 그 값이 바뀌면 React가 화면을 다시 그리게 해요. 버튼을 누른 횟수, 입력창에 적은 글자, 메뉴가 열려 있는지 여부처럼 <strong>사용자의 조작에 따라 바뀌고, 바뀌면 화면에도 반영되어야 하는 값</strong>이 state예요.</p>
<h4 id="왜-그냥-변수를-쓰면-안-되나요">왜 그냥 변수를 쓰면 안 되나요?</h4>
<p><code>let count = 0</code> 같은 일반 변수는 두 가지 문제가 있어요.</p>
<ul>
<li>값을 바꿔도 React는 그 사실을 몰라서 화면이 그대로예요.</li>
<li>컴포넌트 함수는 화면을 다시 그릴 때마다 처음부터 다시 실행되기 때문에, 변수도 매번 <code>0</code>으로 돌아가요.</li>
</ul>
<p>state는 React가 대신 보관해 주고, 값이 바뀌면 화면도 다시 그려줘요.</p>
<h4 id="기본-사용법">기본 사용법</h4>
<pre><code class="language-jsx">import { useState } from &#39;react&#39;;

function Counter() {
  // 현재 값(count)과 값을 바꾸는 함수(setCount)
  const [count, setCount] = useState(0);

  function handleClick() {
    // 버튼 클릭 시 count 1 증가
    setCount(count + 1);
  }

  return &lt;button onClick={handleClick}&gt;{count}번 눌렀어요&lt;/button&gt;;
}</code></pre>
<p><code>const [count, setCount] = useState(0);</code> 한 줄에 여러 가지가 들어 있어요. 하나씩 풀어볼게요.</p>
<ul>
<li><code>useState(0)</code>: <code>0</code>은 <strong>초기값</strong>이에요. 처음 화면에 나타날 때 <code>count</code>는 <code>0</code>이에요.</li>
<li><code>useState</code>는 <strong>요소가 두 개인 배열</strong>을 돌려줘요. 첫 번째는 현재 값, 두 번째는 그 값을 바꾸는 함수예요.</li>
<li><code>const [count, setCount] = ...</code>는 배열에서 요소를 꺼내 이름을 붙이는 <strong>배열 구조 분해</strong> 문법이에요. 이름은 <code>[something, setSomething]</code> 형태로 짓는 게 관례예요.</li>
<li><code>setCount(count + 1)</code>을 호출하면 React가 새 값을 저장하고, 컴포넌트를 다시 실행(<strong>리렌더링</strong>)해서 화면을 갱신해요.</li>
</ul>
<h4 id="알아둘-점-1-set-함수를-호출해도-현재-값은-바로-바뀌지-않아요">알아둘 점 1. set 함수를 호출해도 현재 값은 바로 바뀌지 않아요</h4>
<pre><code class="language-jsx">function handleClick() {
  setCount(count + 1);
  console.log(count); // 여전히 이전 값
}</code></pre>
<p>state는 <strong>그 순간의 사진(스냅샷)</strong> 같아요. 이번 렌더링에서 <code>count</code>가 <code>0</code>이라면, 이 렌더링 안에서는 끝까지 <code>0</code>이에요. <code>setCount</code>는 &quot;다음 렌더링에서는 이 값으로 그려줘&quot;라는 요청일 뿐이에요.</p>
<p>새 값을 바로 써야 한다면 변수에 담아서 쓰면 돼요.</p>
<pre><code class="language-jsx">const nextCount = count + 1;
setCount(nextCount);
console.log(nextCount); // 새 값</code></pre>
<h4 id="알아둘-점-2-이전-값을-기준으로-바꿀-때는-함수를-넘겨요">알아둘 점 2. 이전 값을 기준으로 바꿀 때는 함수를 넘겨요</h4>
<p><code>age</code>가 <code>42</code>일 때, 버튼 한 번에 3씩 올리고 싶어서 이렇게 썼다고 해볼게요.</p>
<pre><code class="language-jsx">function handleClick() {
  setAge(age + 1); // age가 42라서 setAge(43)
  setAge(age + 1); // 여전히 age는 42라서 setAge(43)
  setAge(age + 1); // 마찬가지로 setAge(43)
}</code></pre>
<p>결과는 <code>45</code>가 아니라 <code>43</code>이에요. 위에서 본 스냅샷 때문에, 이 함수가 실행되는 동안 <code>age</code>는 계속 <code>42</code>이기 때문이에요.</p>
<p>이럴 때는 값 대신 <strong>&quot;이렇게 계산해 줘&quot;라는 함수</strong>를 넘겨요.</p>
<pre><code class="language-jsx">function handleClick() {
  setAge(a =&gt; a + 1); // 42 → 43
  setAge(a =&gt; a + 1); // 43 → 44
  setAge(a =&gt; a + 1); // 44 → 45
}</code></pre>
<p><code>a =&gt; a + 1</code>은 &quot;직전에 계산된 값(<code>a</code>)을 받아서 1을 더한 값을 돌려줘&quot;라는 뜻이에요. React는 이렇게 넘긴 함수들을 순서대로 쌓아 두었다가(큐), 다음 렌더링 때 차례로 실행해요. 이런 함수를 <strong>업데이터 함수</strong>라고 불러요. 인자 이름은 <code>age</code>의 첫 글자 <code>a</code>처럼 짓거나 <code>prevAge</code>처럼 지어도 돼요.</p>
<blockquote>
<p><strong>항상 이렇게 써야 하나요?</strong> 공식 문서에 따르면 대부분의 경우 둘은 차이가 없어요. 한 번의 이벤트 안에서 같은 state를 여러 번 바꿀 때 필요해요. 헷갈리면 항상 업데이터 함수로 써도 문제는 없어요.</p>
</blockquote>
<h4 id="알아둘-점-3-객체와-배열은-고쳐-쓰지-말고-새로-만들어서-교체해요">알아둘 점 3. 객체와 배열은 고쳐 쓰지 말고, 새로 만들어서 교체해요</h4>
<p>state에는 숫자와 문자뿐 아니라 객체나 배열도 넣을 수 있어요. 다만 안에 든 값을 직접 수정하면 안 돼요.</p>
<pre><code class="language-jsx">const [form, setForm] = useState({
  firstName: &#39;Barbara&#39;,
  lastName: &#39;Hepworth&#39;,
});

// 잘못된 방식: 기존 객체 직접 수정
form.firstName = &#39;Taylor&#39;;

// 올바른 방식: 새 객체를 만들어 교체
setForm({
  ...form,
  firstName: &#39;Taylor&#39;,
});</code></pre>
<p><strong>왜 그럴까요?</strong> React는 <code>set</code> 함수가 호출될 때 &quot;이전 값과 새 값이 같은 것인가?&quot;를 비교하고, 같다고 판단하면 화면을 다시 그리지 않아요. 기존 객체를 직접 고친 뒤 그 객체를 그대로 넘기면 React 눈에는 &quot;같은 객체&quot;라서 업데이트가 무시돼요. 새 객체를 만들어서 넘겨야 &quot;바뀌었다&quot;고 알아차려요. 그래서 state는 <strong>읽기 전용으로 다룬다</strong>고 말해요. 이런 성질을 불변성(immutability)이라고 해요.</p>
<p><strong><code>...form</code>은 뭔가요?</strong> 자바스크립트의 <strong>전개 문법(spread syntax)</strong> 이에요. 객체 안의 내용물을 풀어서 펼쳐 놓는다고 생각하면 돼요.</p>
<pre><code class="language-jsx">{ ...form, firstName: &#39;Taylor&#39; }</code></pre>
<p>이 코드는 &quot;<code>form</code> 안의 모든 값(<code>firstName</code>, <code>lastName</code>)을 새 객체에 복사하고, <code>firstName</code>만 <code>&#39;Taylor&#39;</code>로 덮어쓴 새 객체를 만들어 줘&quot;라는 뜻이에요. 같은 이름의 값이 있으면 <strong>뒤에 쓴 값이 앞의 값을 덮어써요.</strong></p>
<p>배열도 같은 원리예요. 전개 문법으로 기존 항목을 펼치고, 그 뒤에 새 항목을 붙인 새 배열을 만들어요.</p>
<pre><code class="language-jsx">const [todos, setTodos] = useState([]);

// 잘못된 방식: 기존 배열 직접 수정
todos.push(newTodo);

// 올바른 방식: 기존 항목을 펼치고 새 항목을 뒤에 붙인 새 배열
setTodos([...todos, newTodo]);</code></pre>
<h4 id="조금-더-알아두기-초기값을-계산하는-함수">조금 더 알아두기. 초기값을 계산하는 함수</h4>
<p>초기값을 만드는 데 시간이 걸리는 함수가 있다면, 괄호를 붙이느냐 마느냐가 중요해요.</p>
<pre><code class="language-jsx">// 렌더링마다 함수 실행 (결과는 첫 렌더링에서만 사용)
const [todos, setTodos] = useState(createInitialTodos());

// 함수 자체를 전달: 처음 한 번만 실행
const [todos, setTodos] = useState(createInitialTodos);</code></pre>
<p>초기값은 첫 렌더링에서만 쓰이는데, 괄호를 붙이면(<code>createInitialTodos()</code>) 화면을 다시 그릴 때마다 함수가 실행돼요. 결과는 버려지는데도요. 괄호 없이 <strong>함수 자체</strong>를 넘기면 React가 처음 한 번만 호출해요.</p>
<h4 id="흔한-실수">흔한 실수</h4>
<ul>
<li><strong><code>set</code>을 호출한 직후에 값을 읽음</strong>: 위의 알아둘 점 1처럼 이전 값이 나와요.</li>
<li><strong>객체나 배열을 직접 수정한 뒤 <code>set</code>을 호출</strong>: 같은 객체로 보여서 화면이 바뀌지 않아요.</li>
<li><strong><code>onClick={handleClick()}</code>처럼 괄호를 붙임</strong>: 클릭할 때가 아니라 렌더링 중에 함수가 실행돼요. 그 안의 <code>set</code>이 다시 렌더링을 일으키고, 이것이 무한 반복되어 <code>Too many re-renders</code> 에러가 나요. <code>onClick={handleClick}</code>으로 함수 자체를 넘겨야 해요.</li>
<li><strong>Hook을 조건문이나 반복문 안에서 호출</strong>: Hook은 컴포넌트의 최상위에서만 호출할 수 있어요.</li>
</ul>
<hr>
<h3 id="2-2-useeffect--react-밖의-것과-맞춰주기">2-2. <code>useEffect</code> — React 밖의 것과 맞춰주기</h3>
<h4 id="이런-hook이에요-1">이런 Hook이에요</h4>
<p>컴포넌트를 <strong>React 밖의 시스템</strong>과 맞춰주는(동기화하는) Hook이에요. 화면에 반영된 뒤 실행할 코드를 등록하고, 그 코드가 한 일을 <strong>정리하는 방법</strong>도 함께 등록해요.</p>
<p>&quot;React 밖의 시스템&quot;은 예를 들면 이런 것들이에요.</p>
<ul>
<li>타이머 (<code>setInterval</code>)</li>
<li>브라우저 이벤트 구독 (<code>window.addEventListener</code>)</li>
<li>서버나 채팅방과의 연결</li>
<li>React로 만들지 않은 외부 위젯</li>
</ul>
<p>이런 것들은 React가 알아서 켜고 꺼주지 않아서, 직접 &quot;켜고 끄는&quot; 코드를 써야 해요. 그 자리가 <code>useEffect</code>예요.</p>
<p><strong>핵심은 이거예요.</strong> <code>useEffect</code>는 외부 시스템과 연결할 때 쓰는 Hook이고, 그게 아니라면 필요 없을 수도 있어요.</p>
<h4 id="기본-사용법-1">기본 사용법</h4>
<pre><code class="language-jsx">import { useState, useEffect } from &#39;react&#39;;

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() =&gt; {
    // 타이머 시작: 1초마다 seconds 1 증가
    const id = setInterval(() =&gt; {
      setSeconds(s =&gt; s + 1);
    }, 1000);

    // 정리(cleanup): 타이머 중지
    return () =&gt; clearInterval(id);
  }, []);

  return &lt;h1&gt;{seconds}초&lt;/h1&gt;;
}</code></pre>
<p><code>useEffect</code>에는 두 가지를 넘겨요.</p>
<ol>
<li><strong>setup 함수</strong>: 외부 시스템을 켜는 코드예요. 이 함수가 함수를 <code>return</code>하면, 그 함수가 <strong>cleanup 함수</strong>(정리 함수)가 돼요. &quot;setup에서 켠 것을 끄는 코드&quot;를 넣어요.</li>
<li><strong>의존성 배열</strong>: 위 예제에서는 <code>[]</code>예요. 아래에서 자세히 설명할게요.</li>
</ol>
<p><code>setSeconds(s =&gt; s + 1)</code>은 앞에서 배운 업데이터 함수예요. 여기서 <code>setSeconds(seconds + 1)</code>로 쓰면 <code>seconds</code>를 의존성 배열에 넣어야 하고, 그러면 값이 바뀔 때마다 타이머를 껐다가 다시 켜게 돼요. 업데이터 함수를 쓰면 그럴 필요가 없어요.</p>
<h4 id="언제-실행되나요">언제 실행되나요?</h4>
<ol>
<li>컴포넌트가 화면에 처음 나타나면(<strong>마운트</strong>) setup이 실행돼요.</li>
<li>의존성 값이 바뀌면, 먼저 이전 값 기준으로 cleanup이 실행되고, 이어서 새 값 기준으로 setup이 다시 실행돼요.</li>
<li>컴포넌트가 화면에서 사라지면(<strong>언마운트</strong>) cleanup이 마지막으로 실행돼요.</li>
</ol>
<p>cleanup은 &quot;setup이 한 일을 되돌리는 코드&quot;예요. 켰으면 끄고, 연결했으면 끊고, 구독했으면 해제해요.</p>
<h4 id="의존성-배열-세-가지-모양">의존성 배열: 세 가지 모양</h4>
<table>
<thead>
<tr>
<th>모양</th>
<th>실행 시점</th>
</tr>
</thead>
<tbody><tr>
<td><code>[a, b]</code></td>
<td>처음 한 번, 그리고 <code>a</code> 또는 <code>b</code>가 바뀔 때마다</td>
</tr>
<tr>
<td><code>[]</code></td>
<td>처음 한 번만</td>
</tr>
<tr>
<td>생략</td>
<td>화면이 그려질 때마다 (대부분 원하지 않는 동작)</td>
</tr>
</tbody></table>
<p><strong>의존성은 내가 고르는 게 아니라, 코드가 정해줘요.</strong> Effect 안에서 쓰는 props, state, 그리고 컴포넌트 안에서 만든 변수와 함수는 전부 배열에 넣어야 해요.</p>
<pre><code class="language-jsx">function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState(&#39;https://localhost:1234&#39;);

  useEffect(() =&gt; {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () =&gt; connection.disconnect();
  }, [serverUrl, roomId]); // serverUrl, roomId 변경 시 재연결
}</code></pre>
<p>이 코드는 <code>serverUrl</code>과 <code>roomId</code>를 안에서 쓰기 때문에 둘 다 의존성이에요. 둘 중 하나가 바뀌면 이전 연결을 끊고(cleanup) 새 값으로 다시 연결해요(setup). 의존성을 빼먹으면 ESLint가 경고해 주는데, 이 경고를 <code>eslint-disable</code>로 끄면 React에게 거짓말을 하는 셈이라 버그로 이어지기 쉬워요.</p>
<h4 id="개발-중에는-effect가-두-번-실행돼요">개발 중에는 Effect가 두 번 실행돼요</h4>
<p>개발 환경의 Strict Mode에서는 React가 setup → cleanup → setup을 <strong>한 번 더</strong> 실행해요. cleanup이 setup을 제대로 되돌리는지 확인하는 테스트예요. 실제 배포 환경에서는 한 번만 실행돼요.</p>
<p>위의 타이머 예제는 두 번 실행돼도 문제가 없어요. cleanup에서 타이머를 끄기 때문에 타이머가 겹쳐서 돌지 않아요. 만약 두 번 실행했을 때 문제가 생긴다면, cleanup이 빠졌다는 신호예요.</p>
<h4 id="데이터-가져오기와-경쟁-상태">데이터 가져오기와 경쟁 상태</h4>
<p>Effect 안에서 서버 데이터를 가져오는 경우가 많아요. 이때 조심할 점이 있어요.</p>
<pre><code class="language-jsx">useEffect(() =&gt; {
  let ignore = false;

  fetchBio(person).then(result =&gt; {
    if (!ignore) {
      setBio(result);
    }
  });

  return () =&gt; {
    // 이전 요청의 응답 무시
    ignore = true;
  };
}, [person]);</code></pre>
<p><code>person</code>을 빠르게 여러 번 바꾸면 요청도 여러 개 나가요. 그런데 <strong>응답이 요청한 순서대로 도착한다는 보장은 없어요.</strong> 나중에 보낸 요청의 응답이 먼저 오고, 이전 요청의 응답이 나중에 오면 화면이 이전 사람의 정보로 덮여 버려요. 이런 문제를 <strong>경쟁 상태(race condition)</strong> 라고 해요.</p>
<p>cleanup에서 <code>ignore = true</code>로 바꿔 두면, 이미 지나간 요청의 응답은 무시하게 돼요.</p>
<p>다만 공식 문서는 Effect 안에서 직접 데이터를 가져오는 방식은 코드가 반복되고 캐싱 같은 최적화를 붙이기 어렵다고 말해요. 프레임워크를 쓴다면 프레임워크의 데이터 가져오기 기능을, 아니라면 TanStack Query 같은 라이브러리를 쓰는 걸 권해요.</p>
<h4 id="effect가-필요-없는-경우">Effect가 필요 없는 경우</h4>
<p>외부 시스템과 동기화하는 게 아니라면 Effect가 필요 없을 수 있어요. 신입 때 가장 많이 하는 실수가 이 부분이에요. 공식 문서는 Effect가 필요 없는 흔한 경우를 두 가지로 꼽아요.</p>
<p><strong>1) 화면에 보여줄 데이터를 만들 때는 Effect가 필요 없어요.</strong></p>
<pre><code class="language-jsx">function Form() {
  const [firstName, setFirstName] = useState(&#39;Taylor&#39;);
  const [lastName, setLastName] = useState(&#39;Swift&#39;);

  // 잘못된 방식: 중복 state와 불필요한 Effect
  const [fullName, setFullName] = useState(&#39;&#39;);
  useEffect(() =&gt; {
    setFullName(firstName + &#39; &#39; + lastName);
  }, [firstName, lastName]);

  // ...
}</code></pre>
<pre><code class="language-jsx">function Form() {
  const [firstName, setFirstName] = useState(&#39;Taylor&#39;);
  const [lastName, setLastName] = useState(&#39;Swift&#39;);

  // 올바른 방식: 렌더링 중 바로 계산
  const fullName = firstName + &#39; &#39; + lastName;

  // ...
}</code></pre>
<p>첫 번째 코드는 복잡할 뿐 아니라 비효율적이에요. <code>firstName</code>이 바뀌면 React는 먼저 컴포넌트를 렌더링하고(이때 <code>fullName</code>은 아직 <strong>이전 값</strong>이에요), 화면에 반영한 뒤에 Effect를 실행해요. 그런데 Effect가 곧바로 state를 또 바꾸기 때문에, 이 과정이 <strong>처음부터 다시</strong> 시작돼요. 오래된 값으로 한 번 그리고, 새 값으로 또 그리는 셈이에요.</p>
<p>원칙은 간단해요. <strong>기존 props나 state로 계산할 수 있는 값은 state에 넣지 말고, 렌더링 중에 계산하세요.</strong> 코드가 더 빠르고, 더 짧고, state끼리 어긋나는 버그도 생기지 않아요. 계산이 정말 느리다면 Effect가 아니라 3-2에서 배울 <code>useMemo</code>로 캐시해요.</p>
<p><strong>2) 사용자 이벤트를 처리할 때는 Effect가 필요 없어요.</strong></p>
<p>예를 들어 &quot;구매&quot; 버튼을 눌렀을 때 서버에 요청을 보내고 알림을 띄운다고 해볼게요. 버튼의 클릭 이벤트 핸들러 안에서는 <strong>무슨 일이 일어났는지</strong> 정확히 알 수 있어요. 하지만 Effect가 실행되는 시점에는 사용자가 무엇을 했는지(예를 들어 어떤 버튼을 눌렀는지) 알 수 없어요. 그래서 사용자 동작에 대한 반응은 대개 해당 이벤트 핸들러에 써요.</p>
<p><strong>Effect와 이벤트 핸들러 중 무엇을 써야 할지 헷갈린다면, 이렇게 자문해 보세요. &quot;이 코드는 왜 실행되어야 하지?&quot;</strong></p>
<ul>
<li>컴포넌트가 <strong>화면에 보여졌기 때문</strong>이라면 → Effect</li>
<li>사용자가 <strong>특정 동작을 했기 때문</strong>이라면 → 이벤트 핸들러</li>
</ul>
<pre><code class="language-jsx">function Form() {
  const [firstName, setFirstName] = useState(&#39;&#39;);
  const [lastName, setLastName] = useState(&#39;&#39;);

  // 올바른 방식: 폼이 화면에 보여졌기 때문에 실행하는 로직
  useEffect(() =&gt; {
    post(&#39;/analytics/event&#39;, { eventName: &#39;visit_form&#39; });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // 올바른 방식: 버튼을 눌렀기 때문에 실행하는 로직
    post(&#39;/api/register&#39;, { firstName, lastName });
  }

  // ...
}</code></pre>
<p>분석용 이벤트는 &quot;폼이 화면에 보여졌기 때문에&quot; 보내는 것이라 Effect가 맞아요. 반면 가입 요청은 &quot;버튼을 눌렀기 때문에&quot; 보내는 것이라 이벤트 핸들러에 써야 해요.</p>
<h4 id="effect-대신-쓸-수-있는-것들">Effect 대신 쓸 수 있는 것들</h4>
<table>
<thead>
<tr>
<th>하고 싶은 일</th>
<th>Effect 대신</th>
</tr>
</thead>
<tbody><tr>
<td>다른 state나 props로 계산할 수 있는 값 만들기</td>
<td>렌더링 중 바로 계산</td>
</tr>
<tr>
<td>비싼 계산 결과 캐시하기</td>
<td><code>useMemo</code></td>
</tr>
<tr>
<td>prop이 바뀔 때 컴포넌트의 모든 state 초기화하기</td>
<td>컴포넌트에 다른 <code>key</code> 전달</td>
</tr>
<tr>
<td>사용자 동작에 반응하기</td>
<td>이벤트 핸들러</td>
</tr>
<tr>
<td>외부 스토어 구독하기</td>
<td><code>useSyncExternalStore</code> (4-4)</td>
</tr>
</tbody></table>
<p>더 자세한 내용은 공식 문서의 <a href="https://ko.react.dev/learn/you-might-not-need-an-effect">Effect가 필요하지 않을 수도 있습니다</a>를 참고하세요.</p>
<h4 id="흔한-실수-1">흔한 실수</h4>
<ul>
<li><strong>의존성 배열을 빼먹음</strong>: 화면이 그려질 때마다 Effect가 실행돼요.</li>
<li><strong>린터 경고를 무시하고 의존성을 지움</strong>: 오래된 값을 쓰는 버그가 생겨요.</li>
<li><strong>렌더링마다 새로 만들어지는 객체나 함수를 의존성에 넣음</strong>: 매번 다른 값으로 보여서 Effect가 계속 다시 실행돼요. 그 객체나 함수를 Effect 안으로 옮기면 해결되는 경우가 많아요.</li>
<li><strong>Effect 안에서 state를 바꾸는데, 그 state가 의존성에도 들어 있음</strong>: 무한 루프에 빠져요.</li>
<li><strong>cleanup 없이 타이머나 구독을 만듦</strong>: 컴포넌트가 사라져도 계속 남아서 쌓여요.</li>
</ul>
<hr>
<h3 id="2-3-useref--화면과-상관없는-값-기억하기">2-3. <code>useRef</code> — 화면과 상관없는 값 기억하기</h3>
<h4 id="이런-hook이에요-2">이런 Hook이에요</h4>
<p><strong>렌더링에 쓰지 않는 값</strong>을 기억해 두는 상자예요. <code>{ current: 값 }</code> 모양의 객체를 돌려주고, 화면이 다시 그려져도 같은 객체가 유지돼요. <code>current</code>를 바꿔도 <strong>화면은 다시 그려지지 않아요.</strong></p>
<h4 id="state와-뭐가-다른가요">state와 뭐가 다른가요?</h4>
<table>
<thead>
<tr>
<th></th>
<th><code>useState</code></th>
<th><code>useRef</code></th>
</tr>
</thead>
<tbody><tr>
<td>값이 바뀌면</td>
<td>화면을 다시 그려요</td>
<td>아무 일도 일어나지 않아요</td>
</tr>
<tr>
<td>값을 바꾸는 방법</td>
<td><code>set</code> 함수 호출</td>
<td><code>ref.current = 값</code>으로 직접 수정</td>
</tr>
<tr>
<td>렌더링 중에 읽기</td>
<td>가능해요</td>
<td>하지 않아야 해요</td>
</tr>
<tr>
<td>주로 쓰는 곳</td>
<td>화면에 보여줄 값</td>
<td>화면과 무관한 값, DOM 요소</td>
</tr>
</tbody></table>
<p>기준은 간단해요. <strong>화면에 보여줄 값이면 state, 그렇지 않으면 ref</strong>예요.</p>
<h4 id="용도-1-화면과-상관없는-값-보관하기">용도 1. 화면과 상관없는 값 보관하기</h4>
<pre><code class="language-jsx">function Stopwatch() {
  const intervalRef = useRef(0);

  function handleStart() {
    // 타이머 ID를 ref에 보관
    intervalRef.current = setInterval(() =&gt; {
      // ...
    }, 1000);
  }

  function handleStop() {
    // 보관해 둔 ID로 타이머 중지
    clearInterval(intervalRef.current);
  }

  // ...
}</code></pre>
<p>타이머 ID는 화면에 보여줄 값이 아니에요. 그런데 화면이 다시 그려져도 사라지면 안 돼요. 일반 변수는 매번 초기화되고, state는 바꿀 때마다 불필요하게 화면을 다시 그려요. 이런 값이 ref에 딱 맞아요.</p>
<h4 id="용도-2-dom-요소-직접-다루기">용도 2. DOM 요소 직접 다루기</h4>
<p>DOM은 브라우저가 화면에 그리는 HTML 요소 객체예요. 입력창에 포커스를 주거나 스크롤을 옮기는 것처럼, React 방식으로는 표현하기 어려운 동작에 ref를 써요.</p>
<pre><code class="language-jsx">import { useRef } from &#39;react&#39;;

function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    // 입력창에 포커스
    inputRef.current.focus();
  }

  return (
    &lt;&gt;
      &lt;input ref={inputRef} /&gt;
      &lt;button onClick={handleClick}&gt;입력창으로 이동&lt;/button&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>순서는 이래요.</p>
<ol>
<li><code>useRef(null)</code>로 빈 ref를 만들어요.</li>
<li>원하는 요소에 <code>ref={inputRef}</code>를 붙여요.</li>
<li>React가 화면에 요소를 그린 뒤, 그 DOM 요소를 <code>inputRef.current</code>에 넣어줘요.</li>
<li><code>inputRef.current.focus()</code>처럼 DOM 요소의 기능을 직접 호출해요.</li>
</ol>
<p>요소가 화면에서 사라지면 React가 <code>current</code>를 다시 <code>null</code>로 돌려놔요.</p>
<h4 id="렌더링-중에는-refcurrent를-읽거나-쓰지-마세요">렌더링 중에는 <code>ref.current</code>를 읽거나 쓰지 마세요</h4>
<pre><code class="language-jsx">function MyComponent() {
  // 잘못된 방식: 렌더링 중 ref 쓰기
  myRef.current = 123;

  // 잘못된 방식: 렌더링 중 ref 읽기
  return &lt;h1&gt;{myOtherRef.current}&lt;/h1&gt;;
}</code></pre>
<p>컴포넌트는 같은 입력(props, state)이면 같은 화면을 만들어야 해요. 그런데 ref는 React가 변화를 추적하지 않아서, 렌더링 중에 읽으면 결과를 예측할 수 없게 돼요. ref는 <strong>이벤트 핸들러나 <code>useEffect</code> 안에서</strong> 읽고 쓰세요. 렌더링 중에 꼭 필요한 값이라면 state가 맞아요.</p>
<h4 id="내가-만든-컴포넌트에-ref-넘기기">내가 만든 컴포넌트에 ref 넘기기</h4>
<pre><code class="language-jsx">// React 19: ref를 일반 prop처럼 받음
function MyInput({ ref }) {
  return &lt;input ref={ref} /&gt;;
}

function Form() {
  const inputRef = useRef(null);

  return (
    &lt;&gt;
      &lt;MyInput ref={inputRef} /&gt;
      &lt;button onClick={() =&gt; inputRef.current.focus()}&gt;포커스&lt;/button&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>기본적으로 내가 만든 컴포넌트는 안쪽 DOM 요소를 바깥에 공개하지 않아요. 그래서 <code>ref</code>를 prop으로 받아서 안쪽 요소에 직접 넘겨줘야 해요. React 19부터는 <code>ref</code>를 다른 prop처럼 받을 수 있어요. React 18 이하에서는 <code>forwardRef</code>라는 함수로 컴포넌트를 감싸야 했어요. 예전 자료에서 <code>forwardRef</code>가 보여도 당황하지 마세요.</p>
<h4 id="흔한-실수-2">흔한 실수</h4>
<ul>
<li><strong>화면에 보여줄 값을 ref에 저장</strong>: <code>{ref.current}</code>를 JSX에 써도 값이 바뀌었을 때 화면이 갱신되지 않아요.</li>
<li><strong>렌더링 중에 <code>ref.current</code>를 읽거나 씀</strong>: 위에서 설명한 이유로 피해야 해요.</li>
<li><strong>내가 만든 컴포넌트에 ref를 넘겼는데 안쪽 요소에 연결하지 않음</strong>: <code>Cannot read properties of null</code> 에러가 나요.</li>
</ul>
<hr>
<h3 id="2-4-usecontext--멀리-있는-값을-바로-읽기">2-4. <code>useContext</code> — 멀리 있는 값을 바로 읽기</h3>
<h4 id="이런-hook이에요-3">이런 Hook이에요</h4>
<p>멀리 있는 부모 컴포넌트가 내려준 값을, 중간 컴포넌트를 거치지 않고 <strong>바로 읽는</strong> Hook이에요.</p>
<h4 id="왜-필요한가요">왜 필요한가요?</h4>
<p>앱 맨 위에 있는 &quot;다크 모드 여부&quot;를 저 깊은 곳의 버튼이 알아야 한다고 해볼게요. props로만 전달하면 이렇게 돼요.</p>
<p><code>App → Form → Panel → Button</code></p>
<p><code>Form</code>과 <code>Panel</code>은 테마를 쓰지도 않는데, 아래로 전달하기 위해 props를 받아서 그대로 넘겨야 해요. 이렇게 props를 여러 단계에 걸쳐 전달하는 것을 <strong>props drilling</strong>이라고 부르고, 컴포넌트가 깊어질수록 번거로워져요. Context를 쓰면 이 중간 단계를 건너뛸 수 있어요.</p>
<h4 id="사용법-세-단계">사용법: 세 단계</h4>
<pre><code class="language-jsx">import { createContext, useContext } from &#39;react&#39;;

// 1단계: Context 생성 (기본값 &#39;light&#39;)
const ThemeContext = createContext(&#39;light&#39;);

// 2단계: 위쪽 부모에서 값 제공 (이 안쪽 전체가 &#39;dark&#39;를 읽을 수 있음)
function MyApp() {
  return (
    &lt;ThemeContext value=&quot;dark&quot;&gt;
      &lt;Form /&gt;
    &lt;/ThemeContext&gt;
  );
}

// 중간 컴포넌트: theme을 props로 받지 않음
function Form() {
  return (
    &lt;Panel&gt;
      &lt;Button&gt;가입하기&lt;/Button&gt;
    &lt;/Panel&gt;
  );
}

function Panel({ children }) {
  return &lt;section&gt;{children}&lt;/section&gt;;
}

function Button({ children }) {
  // 3단계: 값 읽기 (가장 가까운 위쪽 제공자의 값)
  const theme = useContext(ThemeContext);
  return &lt;button className={&#39;button-&#39; + theme}&gt;{children}&lt;/button&gt;;
}</code></pre>
<ul>
<li><code>useContext(ThemeContext)</code>는 트리에서 <strong>가장 가까운 위쪽</strong>의 <code>&lt;ThemeContext value=...&gt;</code>를 찾아서 그 값을 돌려줘요.</li>
<li>위쪽에 아무것도 없을 때만 <code>createContext(&#39;light&#39;)</code>에 넣은 <strong>기본값</strong>이 쓰여요.</li>
<li>React 19에서는 <code>&lt;ThemeContext value=&quot;dark&quot;&gt;</code>로 쓰고, React 18 이하에서는 <code>&lt;ThemeContext.Provider value=&quot;dark&quot;&gt;</code>로 썼어요. 예전 자료를 볼 때 참고하세요.</li>
</ul>
<h4 id="값을-바꾸고-싶다면-state와-함께-쓰기">값을 바꾸고 싶다면: state와 함께 쓰기</h4>
<p>Context는 값을 <strong>전달하는 통로</strong>예요. 값을 바꾸는 건 state가 해요.</p>
<pre><code class="language-jsx">function MyApp() {
  const [theme, setTheme] = useState(&#39;light&#39;);

  return (
    &lt;ThemeContext value={theme}&gt;
      &lt;Form /&gt;
      &lt;button onClick={() =&gt; setTheme(&#39;dark&#39;)}&gt;다크 모드로 변경&lt;/button&gt;
    &lt;/ThemeContext&gt;
  );
}</code></pre>
<p><code>theme</code>이 바뀌면 이 Context를 읽는 모든 컴포넌트가 새 값으로 다시 그려져요.</p>
<h4 id="흔한-실수-3">흔한 실수</h4>
<ul>
<li><strong>값을 제공하는 쪽이 <code>useContext</code>를 호출하는 컴포넌트와 같은 곳이거나 그 아래에 있음</strong>: <code>&lt;ThemeContext value=...&gt;</code>는 반드시 <code>useContext</code>를 호출하는 컴포넌트보다 <strong>위에</strong> 있어야 해요. 같은 컴포넌트 안에서 감싼 것은 그 컴포넌트의 <code>useContext</code>에 영향을 주지 않아요.</li>
<li><strong><code>value</code>를 빼먹거나 이름을 잘못 씀</strong>: <code>&lt;ThemeContext theme={theme}&gt;</code>처럼 쓰면 <code>value={undefined}</code>를 준 것과 같아서, 읽는 쪽에서 <code>undefined</code>를 받아요. 이름은 꼭 <code>value</code>여야 해요.</li>
</ul>
<hr>
<h2 id="3-구조·성능·비동기-ui-hook">3. 구조·성능·비동기 UI Hook</h2>
<p>앞의 네 가지 Hook만으로도 대부분의 화면을 만들 수 있어요. 그런데 앱이 커지면 이런 고민이 생겨요.</p>
<ul>
<li>state를 바꾸는 코드가 여기저기 흩어져서 관리하기 힘들어요.</li>
<li>화면이 느려져서 불필요한 계산과 다시 그리기를 줄이고 싶어요.</li>
<li>서버 응답을 기다리는 동안 화면이 굳거나, 타이핑이 버벅여요.</li>
</ul>
<p>이 섹션의 Hook들은 바로 이런 고민을 풀어주는 도구예요. 예제에 나오는 <code>filterTodos</code>, <code>post</code>, <code>addToCart</code>, <code>updateCartAction</code>과 <code>/api/...</code> 같은 API 경로는 설명을 위해 가정한 예시예요.</p>
<table>
<thead>
<tr>
<th>Hook</th>
<th>이런 문제를 풀어요</th>
</tr>
</thead>
<tbody><tr>
<td><code>useReducer</code></td>
<td>state를 바꾸는 방법이 많고 복잡할 때</td>
</tr>
<tr>
<td><code>useMemo</code>, <code>useCallback</code></td>
<td>불필요한 계산과 리렌더링을 건너뛰고 싶을 때</td>
</tr>
<tr>
<td><code>useTransition</code></td>
<td>급하지 않은 업데이트가 급한 동작을 방해할 때, 비동기 작업의 진행 상태를 보여줄 때</td>
</tr>
<tr>
<td><code>useDeferredValue</code></td>
<td>느린 화면 영역 때문에 입력이 버벅일 때</td>
</tr>
<tr>
<td><code>useActionState</code></td>
<td>서버 요청 같은 비동기 작업의 결과를 state로 관리할 때</td>
</tr>
</tbody></table>
<hr>
<h3 id="3-1-usereducer--state-바꾸는-방법을-한곳에-모으기">3-1. <code>useReducer</code> — state 바꾸는 방법을 한곳에 모으기</h3>
<h4 id="이런-hook이에요-4">이런 Hook이에요</h4>
<p><code>useState</code>처럼 state를 만들어 주지만, <strong>state를 바꾸는 방법을 컴포넌트 밖의 함수 하나(reducer)에 모아 두는</strong> Hook이에요.</p>
<p><code>useState</code>에서는 이벤트 핸들러마다 <code>setXxx(...)</code>로 직접 값을 바꿔요. state가 여러 값으로 이뤄져 있고 바꾸는 경우도 여러 가지라면, 바꾸는 코드가 컴포넌트 곳곳에 흩어져요. <code>useReducer</code>를 쓰면 &quot;무슨 일이 일어났는지&quot;만 알려주고, &quot;그래서 state가 어떻게 바뀌는지&quot;는 reducer 한 곳에서 정해요.</p>
<h4 id="기본-사용법-2">기본 사용법</h4>
<pre><code class="language-jsx">import { useReducer } from &#39;react&#39;;

// reducer: 현재 state와 action을 받아 다음 state 반환
function reducer(state, action) {
  switch (action.type) {
    case &#39;incremented_age&#39;: {
      return { ...state, age: state.age + 1 };
    }
    case &#39;changed_name&#39;: {
      return { ...state, name: action.nextName };
    }
  }
  throw Error(&#39;알 수 없는 action: &#39; + action.type);
}

function Form() {
  const [state, dispatch] = useReducer(reducer, { name: &#39;Taylor&#39;, age: 42 });

  function handleButtonClick() {
    // &quot;나이가 1 증가했다&quot;는 사실 전달
    dispatch({ type: &#39;incremented_age&#39; });
  }

  function handleInputChange(e) {
    // &quot;이름이 바뀌었다&quot;는 사실과 새 이름 전달
    dispatch({ type: &#39;changed_name&#39;, nextName: e.target.value });
  }

  return (
    &lt;&gt;
      &lt;input value={state.name} onChange={handleInputChange} /&gt;
      &lt;button onClick={handleButtonClick}&gt;나이 +1&lt;/button&gt;
      &lt;p&gt;{state.name}님은 {state.age}살이에요.&lt;/p&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>용어를 하나씩 풀어볼게요.</p>
<ul>
<li><code>useReducer(reducer, 초기값)</code>은 <code>[state, dispatch]</code>를 돌려줘요. <code>state</code>는 현재 값, <code>dispatch</code>는 &quot;무슨 일이 일어났는지&quot;를 React에 알리는 함수예요.</li>
<li><strong>action</strong>은 <code>dispatch</code>에 넘기는 객체예요. 일어난 일을 담고 있어요. 관례상 <code>type</code>으로 종류를 구분하고, 필요하면 <code>nextName</code>처럼 추가 정보를 같이 넣어요.</li>
<li><strong>reducer</strong>는 &quot;지금 state와 action을 받아서 다음 state를 돌려주는 함수&quot;예요.</li>
</ul>
<p><code>dispatch</code>를 호출하면 React가 <code>reducer(현재 state, action)</code>을 호출하고, 그 반환값을 다음 state로 저장한 뒤 화면을 다시 그려요.</p>
<p>reducer도 <code>useState</code>와 같은 규칙을 지켜야 해요. state를 직접 고치지 않고, 새 객체를 만들어서 반환해요. 위 코드의 <code>{ ...state, age: state.age + 1 }</code>은 2-1에서 배운 전개 문법으로 &quot;기존 값을 전부 복사하고 <code>age</code>만 바꾼 새 객체&quot;를 만든 거예요. reducer는 렌더링 중에 실행되기 때문에 1-3에서 본 것처럼 계산만 해야 해요.</p>
<h4 id="usestate와-뭐가-다른가요"><code>useState</code>와 뭐가 다른가요?</h4>
<table>
<thead>
<tr>
<th></th>
<th><code>useState</code></th>
<th><code>useReducer</code></th>
</tr>
</thead>
<tbody><tr>
<td>값을 바꾸는 방식</td>
<td><code>setAge(43)</code>처럼 새 값을 직접 전달</td>
<td><code>dispatch({ type: ... })</code>로 일어난 일만 전달</td>
</tr>
<tr>
<td>바꾸는 로직의 위치</td>
<td>이벤트 핸들러마다</td>
<td>reducer 한 곳</td>
</tr>
<tr>
<td>잘 맞는 경우</td>
<td>값 하나를 간단히 바꿀 때</td>
<td>여러 값이 함께 바뀌고, 바뀌는 경우가 여러 가지일 때</td>
</tr>
</tbody></table>
<p>값 하나를 간단히 바꾸는 정도라면 <code>useState</code>로 충분해요.</p>
<h4 id="흔한-실수-4">흔한 실수</h4>
<ul>
<li><strong>reducer 안에서 state를 직접 수정</strong>: <code>state.age++; return state;</code>처럼 쓰면 같은 객체를 돌려준 것이라 React가 &quot;바뀐 게 없다&quot;고 판단해서 화면이 안 바뀌어요.</li>
<li><strong><code>...state</code>를 빼먹음</strong>: 바꾼 값 하나만 들어 있는 객체를 반환하면 나머지 값이 <code>undefined</code>가 돼요.</li>
<li><strong><code>return</code>을 빼먹거나 <code>type</code>을 오타 냄</strong>: state 전체가 <code>undefined</code>가 될 수 있어요. 위 예제처럼 <code>switch</code> 밖에서 <code>throw Error(...)</code>를 해두면 원인을 빨리 찾을 수 있어요.</li>
<li><strong><code>dispatch</code> 직후에 <code>state</code>를 읽음</strong>: <code>useState</code>와 똑같이 이전 값이 나와요(스냅샷).</li>
</ul>
<hr>
<h3 id="3-2-usememo와-usecallback--계산과-함수를-기억해-두기">3-2. <code>useMemo</code>와 <code>useCallback</code> — 계산과 함수를 기억해 두기</h3>
<h4 id="이런-hook이에요-5">이런 Hook이에요</h4>
<p>렌더링과 렌더링 사이에 무언가를 <strong>캐시</strong>(저장해 두고 재사용)하는 Hook이에요.</p>
<ul>
<li><code>useMemo</code>: 계산의 <strong>결과</strong>를 캐시해요.</li>
<li><code>useCallback</code>: <strong>함수 자체</strong>를 캐시해요.</li>
</ul>
<blockquote>
<p>React Compiler를 사용하면 값과 함수, 그리고 컴포넌트(<code>memo</code>에 해당하는 최적화)까지 컴파일러가 자동으로 캐시해 줘요. 그래서 직접 쓸 일이 줄어들어요. 그래도 기존 코드에서 자주 만나니 개념은 알아두세요.</p>
</blockquote>
<h4 id="먼저-알아둘-점-이-hook들은-성능-최적화용이에요">먼저 알아둘 점: 이 Hook들은 성능 최적화용이에요</h4>
<p>이 Hook들이 없어도 앱은 똑같이 동작해요. 공식 문서도 <strong>성능 최적화로만 의존하라</strong>고 강조해요. 이 Hook을 지웠을 때 코드가 동작하지 않는다면, 그 근본 원인을 먼저 고쳐야 한다는 뜻이에요. React가 특별한 이유로 캐시를 버릴 수도 있기 때문이에요. 그리고 공식 문서는 <strong>성능 문제가 생기기 전에 미리 최적화하지 말라</strong>고도 당부해요.</p>
<h4 id="왜-같은-값이-중요한가요">왜 &quot;같은 값&quot;이 중요한가요?</h4>
<p>두 가지 사실을 알아야 이 Hook의 쓰임새가 이해돼요.</p>
<p><strong>1) 부모가 다시 렌더링되면, 자식도 기본적으로 전부 다시 렌더링돼요.</strong> (1-1에서 본 내용이에요.) 이게 느려지는 원인이 될 수 있어요. 이때 자식 컴포넌트를 <code>memo</code>로 감싸면, <strong>props가 이전과 같을 때는 다시 렌더링하지 않도록</strong> 할 수 있어요. <code>memo</code>는 Hook이 아니라 컴포넌트를 감싸는 함수예요.</p>
<pre><code class="language-jsx">import { memo } from &#39;react&#39;;

const Greeting = memo(function Greeting({ name }) {
  return &lt;h1&gt;안녕하세요, {name}!&lt;/h1&gt;;
});</code></pre>
<p>이 <code>Greeting</code>은 부모가 <code>name</code>이 아닌 다른 state(예를 들어 <code>address</code>)를 바꿔서 다시 렌더링돼도, <code>name</code> prop이 그대로라면 다시 렌더링되지 않아요. 다만 <code>memo</code>는 <strong>부모가 넘겨주는 props에만</strong> 관련된 최적화예요. 컴포넌트 자신의 state가 바뀌거나, 컴포넌트가 사용하는 context가 바뀌면 <code>memo</code>로 감싸도 다시 렌더링돼요.</p>
<p><strong>2) 그런데 객체, 배열, 함수는 렌더링마다 새로 만들어져요.</strong> React는 <code>memo</code>로 감싼 컴포넌트의 props를 <strong>각 prop마다 <code>Object.is</code>로 비교</strong>해요. 내용이 똑같아도 새로 만들어진 객체는 &quot;다른 값&quot;이에요.</p>
<pre><code class="language-jsx">console.log(Object.is(3, 3));   // true
console.log(Object.is({}, {})); // false: 모양이 같아도 서로 다른 객체</code></pre>
<p>그래서 부모가 렌더링 중에 만든 객체나 일반 함수를 자식에게 넘기면, props가 매번 달라져서 <strong><code>memo</code>가 아무 효과도 없어요.</strong> 공식 문서도 이 경우 <code>memo</code>는 완전히 쓸모없다고 말해요. 이때 <code>useMemo</code>와 <code>useCallback</code>으로 <strong>내용이 안 바뀌었으면 같은 값을 유지</strong>하게 만들어요.</p>
<p><code>memo</code>가 값어치 있는 경우는 <strong>같은 props로 자주 다시 렌더링되고, 그 렌더링 비용이 클 때</strong>예요. 다시 렌더링해도 체감되는 지연이 없다면 <code>memo</code>는 필요 없어요.</p>
<h4 id="usememo-사용법"><code>useMemo</code> 사용법</h4>
<pre><code class="language-jsx">import { useMemo } from &#39;react&#39;;

function TodoList({ todos, tab, theme }) {
  // todos나 tab이 바뀔 때만 다시 계산
  const visibleTodos = useMemo(
    () =&gt; filterTodos(todos, tab),
    [todos, tab]
  );

  // ...
}</code></pre>
<ul>
<li>첫 번째 인자: 값을 계산하는 함수예요. 인자는 없고, 계산 결과를 반환해요. 렌더링 중에 실행되므로 <strong>순수한 계산</strong>이어야 해요.</li>
<li>두 번째 인자: 의존성 배열이에요. <code>useEffect</code>와 같은 규칙이에요. 계산 안에서 쓰는 값을 전부 넣어요.</li>
</ul>
<p>React는 렌더링마다 의존성을 이전 값과 비교해요. 모두 같으면 계산을 건너뛰고 <strong>이전 결과를 그대로 돌려주고</strong>, 하나라도 다르면 다시 계산해요. 위 코드에서는 <code>theme</code>만 바뀌었을 때 <code>filterTodos</code>가 다시 실행되지 않아요. <code>visibleTodos</code>가 같은 배열로 유지되니 <code>memo</code>로 감싼 자식에게 넘겨도 효과가 있어요.</p>
<p><strong>언제 써야 하나요?</strong> 공식 문서가 꼽는 경우는 세 가지예요.</p>
<ol>
<li>계산이 눈에 띄게 느리고, 의존성이 자주 바뀌지 않을 때</li>
<li><code>memo</code>로 감싼 자식 컴포넌트에 prop으로 넘기는 값일 때</li>
<li>그 값을 다른 Hook의 의존성으로 쓸 때</li>
</ol>
<p>&quot;얼마나 느려야 느린 계산인가요?&quot;라는 질문에 문서는 이렇게 답해요. 수천 개 이상의 항목을 만들거나 순회하는 게 아니라면 대개 비싼 계산이 아니에요. 궁금하면 <code>console.time</code>으로 직접 재보고, 합계가 대략 1ms 이상일 때 고려해 보세요. 개발 모드의 측정값은 정확하지 않으니 실제 배포용 빌드로 확인하는 것이 좋아요.</p>
<pre><code class="language-jsx">console.time(&#39;filter array&#39;);
const visibleTodos = filterTodos(todos, tab);
console.timeEnd(&#39;filter array&#39;); // 걸린 시간 출력</code></pre>
<h4 id="usecallback-사용법"><code>useCallback</code> 사용법</h4>
<pre><code class="language-jsx">import { memo, useCallback } from &#39;react&#39;;

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  // ...
});

function ProductPage({ productId, referrer, theme }) {
  // productId나 referrer가 바뀔 때만 새 함수 사용
  const handleSubmit = useCallback((orderDetails) =&gt; {
    post(&#39;/product/&#39; + productId + &#39;/buy&#39;, {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

  return (
    &lt;div className={theme}&gt;
      &lt;ShippingForm onSubmit={handleSubmit} /&gt;
    &lt;/div&gt;
  );
}</code></pre>
<p><code>theme</code>이 바뀌어서 <code>ProductPage</code>가 다시 렌더링돼도 <code>handleSubmit</code>은 <strong>같은 함수</strong>로 유지돼요. 그래서 <code>ShippingForm</code>은 &quot;props가 그대로네&quot; 하고 다시 렌더링하지 않아요. <code>useCallback</code> 없이 그냥 함수를 선언하면 렌더링마다 새 함수가 만들어져서 <code>memo</code>가 아무 효과 없어요.</p>
<h4 id="usememo와-usecallback의-차이"><code>useMemo</code>와 <code>useCallback</code>의 차이</h4>
<table>
<thead>
<tr>
<th></th>
<th><code>useMemo</code></th>
<th><code>useCallback</code></th>
</tr>
</thead>
<tbody><tr>
<td>캐시하는 것</td>
<td>함수를 <strong>호출한 결과</strong></td>
<td><strong>함수 자체</strong></td>
</tr>
<tr>
<td>함수 호출</td>
<td>React가 렌더링 중 호출</td>
<td>호출하지 않고 돌려줌</td>
</tr>
</tbody></table>
<p>공식 문서는 <code>useCallback(fn, deps)</code>를 <code>useMemo(() =&gt; fn, deps)</code>와 같다고 설명해요. 함수를 캐시할 때 <code>useMemo</code>로 쓰면 함수를 한 번 더 감싸야 해서 번거로우니 <code>useCallback</code>이 따로 있는 거예요.</p>
<h4 id="memo가-잘-동작하게-props-줄이기"><code>memo</code>가 잘 동작하게 props 줄이기</h4>
<p><code>memo</code>의 효과를 제대로 보려면 <strong>props가 바뀌는 횟수를 최소화</strong>해야 해요. 공식 문서가 권하는 방법은 이래요.</p>
<ul>
<li>객체를 통째로 넘기기보다, 자식이 <strong>꼭 필요한 개별 값</strong>만 props로 받게 해요.</li>
<li>자식에게 함수를 넘겨야 한다면, 컴포넌트 <strong>밖에 선언</strong>해서 아예 바뀌지 않게 하거나 <code>useCallback</code>으로 캐시해요.</li>
</ul>
<pre><code class="language-jsx">function Page() {
  const [name, setName] = useState(&#39;Taylor&#39;);
  const [age, setAge] = useState(42);

  // 객체를 만들어 넘기는 대신, 필요한 개별 값 전달
  return &lt;Profile name={name} age={age} /&gt;;
}

const Profile = memo(function Profile({ name, age }) {
  // ...
});</code></pre>
<h4 id="메모이제이션-전에-먼저-해볼-것들">메모이제이션 전에 먼저 해볼 것들</h4>
<p>공식 문서는 메모이제이션을 붙이기 전에 이런 원칙들을 먼저 지켜보라고 해요. 이것만으로 해결되는 경우가 많고, 코드도 이해하기 쉬워져요.</p>
<ul>
<li>다른 컴포넌트를 감싸는 컴포넌트는 <code>children</code>으로 JSX를 받게 만들어요. 그러면 감싸는 컴포넌트가 자기 state를 바꿔도 <code>children</code>은 다시 렌더링하지 않아도 된다는 걸 React가 알아요.</li>
<li>state는 필요한 곳보다 더 위로 올리지 않고 가까이에 둬요. 폼 입력이나 마우스가 올라갔는지 같은 일시적인 state를 앱 최상단이나 전역 상태에 두지 마세요.</li>
<li>렌더링 로직을 순수하게 유지해요. 다시 렌더링했을 때 문제가 생긴다면 그건 메모이제이션이 아니라 컴포넌트의 버그를 고쳐야 한다는 신호예요.</li>
<li>state를 바꾸는 불필요한 Effect를 없애요. React 앱의 성능 문제는 대부분 Effect가 state를 바꾸고, 그것이 다시 렌더링을 일으키는 연쇄에서 생겨요.</li>
<li>Effect의 불필요한 의존성을 없애요. 객체나 함수를 Effect 안으로 옮기면 메모이제이션 없이 해결되는 경우가 많아요.</li>
</ul>
<p>그래도 특정 동작이 느리다면, React 개발자 도구의 프로파일러로 어떤 컴포넌트가 가장 이득을 볼지 확인한 뒤 필요한 곳에만 메모이제이션을 추가하세요.</p>
<h4 id="흔한-실수-5">흔한 실수</h4>
<ul>
<li><strong>습관적으로 모든 곳에 붙임</strong>: 코드가 읽기 어려워지고, 값 하나가 계속 새로 만들어지면 전체 메모이제이션이 깨져서 효과도 없어요.</li>
<li><strong><code>memo</code>를 붙였는데 props가 매번 새로 만들어짐</strong>: 렌더링 중에 만든 객체나 일반 함수를 넘기면 <code>memo</code>가 소용없어요.</li>
<li><strong>자식이 <code>memo</code>가 아닌데 <code>useCallback</code>만 씀</strong>: 자식은 어차피 다시 렌더링돼서 효과가 없어요.</li>
<li><strong>의존성 배열을 빼먹음</strong>: 매 렌더링마다 다시 계산하고, 매번 새 함수를 돌려줘요.</li>
<li><strong><code>useMemo</code>에서 객체를 반환하는데 <code>undefined</code>가 됨</strong>: 화살표 함수의 <code>{</code>는 객체가 아니라 함수 본문의 시작으로 해석돼요.</li>
</ul>
<pre><code class="language-jsx">// 잘못된 방식: 중괄호가 객체가 아니라 함수 본문으로 해석
const options = useMemo(() =&gt; { matchMode: &#39;whole-word&#39; }, []);

// 올바른 방식: return 명시
const options = useMemo(() =&gt; {
  return { matchMode: &#39;whole-word&#39; };
}, []);</code></pre>
<hr>
<h3 id="3-3-본격적으로-들어가기-전에-비동기-transition-action">3-3. 본격적으로 들어가기 전에: 비동기, Transition, Action</h3>
<p>다음 세 Hook(<code>useTransition</code>, <code>useDeferredValue</code>, <code>useActionState</code>)은 모두 세 가지 개념 위에 있어요. 이 개념을 먼저 잡고 가요.</p>
<h4 id="비동기-작업이란">비동기 작업이란</h4>
<p><strong>결과가 나올 때까지 시간이 걸리는 작업을, 기다리는 동안 다른 일을 멈추지 않고 처리하는 방식</strong>이에요.</p>
<pre><code class="language-js">console.log(&#39;1&#39;);
setTimeout(() =&gt; console.log(&#39;2&#39;), 1000); // 1초 뒤 실행 예약
console.log(&#39;3&#39;);
// 출력 순서: 1 → 3 → 2</code></pre>
<p><code>2</code>가 나중에 나오는 건, 1초를 기다리는 동안 자바스크립트가 멈추지 않고 <code>3</code>을 먼저 실행했기 때문이에요. 자바스크립트는 기본적으로 한 번에 하나만 실행하기 때문에, 서버 응답을 3초 기다리는 동안 코드가 멈춘다면 화면도 3초간 얼어붙을 거예요. 그래서 시간이 걸리는 일(서버 요청, 타이머, 파일 읽기, DB 조회 등)은 비동기로 처리해요.</p>
<p>가장 읽기 쉬운 방법은 <code>async/await</code>예요.</p>
<pre><code class="language-js">async function loadData() {
  // 응답이 올 때까지 이 함수 안에서만 대기
  const res = await fetch(&#39;/api/data&#39;);
  const json = await res.json();
  return json;
}</code></pre>
<p><code>async</code> 함수는 항상 Promise를 반환해요. <code>await</code>는 &quot;이 작업이 끝날 때까지 <strong>이 함수 안에서는</strong> 기다려라&quot;라는 뜻이고, 함수 밖의 다른 코드는 멈추지 않아요.</p>
<h4 id="transition이란">Transition이란</h4>
<p><strong>&quot;이 state 업데이트는 급하지 않으니, 더 급한 일(타이핑, 클릭 등)을 먼저 처리해도 돼요&quot;라고 React에게 알려주는 표시</strong>예요.</p>
<p>업데이트를 두 종류로 나눌 수 있어요.</p>
<ul>
<li><strong>긴급한 업데이트</strong>: 타이핑, 클릭처럼 즉시 반응해야 하는 것</li>
<li><strong>Transition 업데이트</strong>: 화면 전환, 무거운 목록 갱신처럼 조금 늦어져도 되는 것</li>
</ul>
<p>Transition으로 표시된 업데이트는 <strong>중단될 수 있어요.</strong> 오래 걸리는 렌더링을 하는 도중에 사용자가 다시 입력하면, React는 하던 일을 멈추고 입력부터 처리한 뒤 다시 시작해요.</p>
<h4 id="action이란">Action이란</h4>
<p><strong>Transition 안에서 실행되는 함수</strong>를 Action이라고 불러요. 비동기 함수여도 돼요. React는 Action이 실행되는 동안을 추적해서 &quot;진행 중인지&quot;(<code>isPending</code>)를 알려줘요.</p>
<p>이제 이 세 가지를 알았으니, 각 Hook이 무엇을 해주는지 쉽게 이해할 수 있어요.</p>
<hr>
<h3 id="3-4-usetransition--급하지-않은-업데이트-표시하기">3-4. <code>useTransition</code> — 급하지 않은 업데이트 표시하기</h3>
<h4 id="이런-hook이에요-6">이런 Hook이에요</h4>
<p>일부 state 업데이트를 <strong>Transition(급하지 않은 업데이트)으로 표시</strong>하고, 그 업데이트가 진행 중인지(<code>isPending</code>) 알려주는 Hook이에요.</p>
<h4 id="기본-사용법-3">기본 사용법</h4>
<pre><code class="language-jsx">import { useState, useTransition } from &#39;react&#39;;

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState(&#39;about&#39;);

  function selectTab(nextTab) {
    // 이 안에서 실행한 state 업데이트는 Transition으로 처리
    startTransition(() =&gt; {
      setTab(nextTab);
    });
  }

  // ...
}</code></pre>
<ul>
<li><code>useTransition()</code>은 <code>[isPending, startTransition]</code>을 돌려줘요.</li>
<li><code>startTransition(action)</code>에 넘긴 함수(Action)는 <strong>바로 실행</strong>되고, 그 안에서 일어난 state 업데이트가 Transition으로 표시돼요.</li>
<li><code>isPending</code>은 Transition이 진행 중일 때 <code>true</code>예요. 로딩 표시를 할 때 써요.</li>
</ul>
<p>Transition은 사용자 경험을 이렇게 바꿔줘요. 탭을 눌렀는데 그 탭 화면이 무거워서 오래 걸리는 상황을 떠올려 보세요. Transition이 없으면 그 동안 화면이 굳어요. Transition으로 표시하면 <strong>탭을 누른 뒤 마음이 바뀌어 다른 탭을 눌러도 두 번째 클릭이 바로 처리돼요.</strong> 첫 번째 업데이트가 끝나기를 기다리지 않아요.</p>
<pre><code class="language-jsx">function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();

  if (isActive) {
    return &lt;b&gt;{children}&lt;/b&gt;;
  }

  // 대기 중 표시
  if (isPending) {
    return &lt;b className=&quot;pending&quot;&gt;{children}&lt;/b&gt;;
  }

  return (
    &lt;button onClick={() =&gt; {
      startTransition(async () =&gt; {
        await action();
      });
    }}&gt;
      {children}
    &lt;/button&gt;
  );
}</code></pre>
<h4 id="불필요한-로딩-화면-막기">불필요한 로딩 화면 막기</h4>
<p><code>&lt;Suspense&gt;</code>는 데이터를 기다리는 동안 대체 화면(fallback)을 보여주는 React 기능이에요. 탭을 눌렀을 때 그 탭이 데이터를 기다리느라 멈추면(suspend), 탭 전체가 스피너로 바뀌어 버릴 수 있어요. 이때 탭 전환을 Transition으로 표시하면 <strong>이미 보이던 내용을 스피너로 바꾸지 않고</strong>, 새 내용이 준비될 때까지 기다려요. 그 사이에 <code>isPending</code>으로 버튼에 진행 중 표시를 할 수 있어요.</p>
<h4 id="비동기-action">비동기 Action</h4>
<p>Action 안에서 <code>await</code>을 쓸 수 있어요. 이때 주의할 점이 있어요.</p>
<pre><code class="language-jsx">startTransition(async () =&gt; {
  const savedQuantity = await updateQuantity(newQuantity);

  // await 이후의 state 업데이트는 startTransition으로 한 번 더 감싸기
  startTransition(() =&gt; {
    setQuantity(savedQuantity);
  });
});</code></pre>
<p><strong><code>await</code> 이후에 일어나는 state 업데이트는 Transition으로 인식되지 않아요.</strong> 그래서 <code>startTransition</code>으로 한 번 더 감싸야 해요. 공식 문서는 이걸 자바스크립트의 한계 때문에 생긴 현재의 제약이라고 설명해요.</p>
<h4 id="알아둘-점">알아둘 점</h4>
<ul>
<li><p><strong>입력창(controlled input)의 state에는 쓸 수 없어요.</strong> 입력은 즉시 반영돼야 하는데 Transition은 &quot;급하지 않은&quot; 업데이트라서 맞지 않아요.</p>
<pre><code class="language-jsx">function handleChange(e) {
  // 잘못된 방식: 입력창 state를 Transition으로 처리
  startTransition(() =&gt; {
    setText(e.target.value);
  });
}</code></pre>
<p>타이핑에 맞춰 무거운 화면을 갱신하고 싶다면, 입력용 state와 화면용 state를 따로 두거나 다음에 나올 <code>useDeferredValue</code>를 써요.</p>
</li>
<li><p><strong><code>startTransition</code>에 넘긴 함수는 미뤄지지 않고 바로 실행돼요.</strong> 그 함수가 실행되는 동안 일어난 state 업데이트만 Transition이 돼요. <code>setTimeout</code> 안에서 호출한 <code>set</code>은 Transition이 아니에요. 필요하면 <code>setTimeout</code> 안쪽에서 <code>startTransition</code>을 호출하세요.</p>
</li>
<li><p><strong>여러 Action의 순서는 보장되지 않아요.</strong> 빠르게 여러 번 요청하면 나중 요청의 응답이 먼저 도착해 화면이 엉킬 수 있어요. 이런 경우를 다루는 <code>useActionState</code>가 뒤에 나와요.</p>
</li>
<li><p><strong>컴포넌트 밖에서 써야 한다면</strong> <code>react</code>에서 가져오는 <code>startTransition</code> 함수를 직접 쓰세요. 다만 <code>isPending</code>은 없어요.</p>
</li>
<li><p><strong><code>set</code> 함수에 접근할 수 있을 때만 쓸 수 있어요.</strong> props로 받은 값 때문에 업데이트를 미루고 싶다면 <code>useDeferredValue</code>를 써요.</p>
</li>
</ul>
<h4 id="흔한-실수-6">흔한 실수</h4>
<ul>
<li>입력창 state를 Transition으로 감쌈</li>
<li><code>await</code> 이후의 업데이트를 다시 감싸지 않음</li>
<li><code>setTimeout</code> 안의 <code>set</code>이 Transition이라고 생각함</li>
</ul>
<hr>
<h3 id="3-5-usedeferredvalue--값이-천천히-따라오게-하기">3-5. <code>useDeferredValue</code> — 값이 천천히 따라오게 하기</h3>
<h4 id="이런-hook이에요-7">이런 Hook이에요</h4>
<p>어떤 값의 <strong>&quot;뒤따라오는 버전&quot;</strong>을 만들어서, 화면의 일부를 나중에 갱신하게 하는 Hook이에요.</p>
<h4 id="기본-사용법-4">기본 사용법</h4>
<p>입력창에 글자를 칠 때마다 아주 긴 목록을 다시 그리는 화면이 있다고 해볼게요. 목록이 느려서 타이핑이 버벅여요.</p>
<pre><code class="language-jsx">import { memo, useState, useDeferredValue } from &#39;react&#39;;

const SlowList = memo(function SlowList({ text }) {
  // ... 렌더링이 느린 긴 목록
});

function App() {
  const [text, setText] = useState(&#39;&#39;);
  // text를 뒤따라오는 값
  const deferredText = useDeferredValue(text);

  return (
    &lt;&gt;
      &lt;input value={text} onChange={e =&gt; setText(e.target.value)} /&gt;
      &lt;SlowList text={deferredText} /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>동작은 이래요.</p>
<ol>
<li>글자를 치면 <code>text</code>가 <strong>즉시</strong> 바뀌어서 입력창은 바로 갱신돼요.</li>
<li>이때 <code>deferredText</code>는 아직 <strong>이전 값</strong>이라서, <code>SlowList</code>는 props가 그대로라 다시 그려지지 않아요(<code>memo</code> 덕분이에요).</li>
<li>그 뒤에 React가 백그라운드에서 <code>deferredText</code>를 새 값으로 바꿔 <code>SlowList</code>를 다시 그려요.</li>
<li>그 사이에 글자를 또 치면, React는 하던 백그라운드 렌더링을 버리고 새 값으로 다시 시작해요.</li>
</ol>
<p>목록이 입력을 뒤따라오다가 따라잡는 모습이에요. <strong>목록 자체가 빨라지는 건 아니에요.</strong> &quot;이 부분은 덜 급하니까 입력을 먼저 처리해&quot;라고 우선순위를 낮춰 주는 거예요.</p>
<blockquote>
<p><strong><code>memo</code>가 꼭 필요해요.</strong> 타이핑할 때 <code>SlowList</code>가 <code>memo</code>가 아니면, <code>deferredText</code>가 이전 값이어도 부모가 다시 그려지면서 <code>SlowList</code>도 같이 다시 그려져서 효과가 없어요.</p>
</blockquote>
<h4 id="이전-결과를-보여주면서-새-결과-기다리기">이전 결과를 보여주면서 새 결과 기다리기</h4>
<p>검색 결과를 서버에서 받아오는 화면에서도 유용해요. 검색어를 바꿀 때 결과 영역이 로딩 화면으로 바뀌는 대신, 새 결과가 준비될 때까지 <strong>이전 결과를 그대로 보여줄</strong> 수 있어요.</p>
<pre><code class="language-jsx">const deferredQuery = useDeferredValue(query);

// 최신 검색어와 결과용 검색어가 다르면 이전 결과를 보여주는 중
const isStale = query !== deferredQuery;

return (
  &lt;div style={{ opacity: isStale ? 0.5 : 1 }}&gt;
    &lt;SearchResults query={deferredQuery} /&gt;
  &lt;/div&gt;
);</code></pre>
<p>새 결과를 기다리는 동안 이전 결과를 살짝 흐리게 해서, 지금 보이는 게 최신 결과가 아니라는 것도 알려줄 수 있어요. 주의할 점은 <code>useDeferredValue</code>가 <strong>네트워크 요청을 줄여주지는 않는다</strong>는 거예요. 미루는 것은 결과를 <strong>화면에 보여주는 것</strong>이에요.</p>
<h4 id="usetransition과-뭐가-다른가요"><code>useTransition</code>과 뭐가 다른가요?</h4>
<table>
<thead>
<tr>
<th></th>
<th><code>useTransition</code></th>
<th><code>useDeferredValue</code></th>
</tr>
</thead>
<tbody><tr>
<td>미루는 대상</td>
<td>state를 바꾸는 코드(<code>set</code> 호출)</td>
<td>값(props나 state) 자체</td>
</tr>
<tr>
<td>쓸 수 있는 조건</td>
<td><code>set</code> 함수에 접근할 수 있을 때</td>
<td>값만 있으면 돼요. props나 커스텀 Hook의 값도 가능해요</td>
</tr>
</tbody></table>
<p>입력창 state는 Transition으로 감쌀 수 없으니, 입력 중 무거운 화면을 미뤄야 할 때는 <code>useDeferredValue</code>가 잘 맞아요.</p>
<h4 id="debounce와는-뭐가-다른가요">debounce와는 뭐가 다른가요?</h4>
<p>debounce는 &quot;사용자가 입력을 멈추고 일정 시간이 지나면 갱신&quot;하는 방식이에요. <code>useDeferredValue</code>는 이와 달라요.</p>
<ul>
<li>고정된 지연 시간이 없어요. 기기가 빠르면 거의 즉시 따라오고, 느리면 그만큼 뒤처져요.</li>
<li>백그라운드 렌더링이 <strong>중단 가능</strong>해요. debounce는 갱신 시점을 미룰 뿐이라, 갱신이 시작되면 그동안 타이핑을 막아요.</li>
</ul>
<p>다만 렌더링이 아닌 작업, 예를 들어 서버 요청 횟수를 줄이는 데는 debounce가 여전히 유용해요.</p>
<h4 id="알아둘-점-1">알아둘 점</h4>
<ul>
<li>넘기는 값은 문자열이나 숫자 같은 원시 값이거나, <strong>렌더링 밖에서 만든 객체</strong>여야 해요. 렌더링 중에 새 객체를 만들어서 넘기면 매번 다른 값이라 불필요한 백그라운드 렌더링이 계속 일어나요.</li>
<li>Transition 안에서 일어난 업데이트는 이미 미뤄진 것이라, 새 값을 바로 돌려줘요.</li>
</ul>
<hr>
<h3 id="3-6-useactionstate--비동기-작업의-결과를-state로-관리하기">3-6. <code>useActionState</code> — 비동기 작업의 결과를 state로 관리하기</h3>
<h4 id="이런-hook이에요-8">이런 Hook이에요</h4>
<p><strong>Action(서버 요청처럼 비동기이고 부수효과가 있는 작업)의 결과를 state로 관리</strong>하는 Hook이에요. &quot;이전 state를 받아서 다음 state를 돌려주는 함수&quot;를 등록해 두면, React가 호출 순서와 진행 상태를 관리해 줘요.</p>
<p>3-1의 <code>useReducer</code>와 닮았는데, 공식 문서는 이렇게 구분해요.</p>
<ul>
<li><code>useReducer</code>: <strong>UI의 state</strong>를 관리해요. reducer는 순수해야 해요.</li>
<li><code>useActionState</code>: <strong>Action의 state</strong>를 관리해요. reducer에서 서버 요청 같은 부수효과를 해도 돼요.</li>
</ul>
<p>즉 &quot;부수효과를 써도 되는 <code>useReducer</code>&quot;라고 생각하면 이해하기 쉬워요.</p>
<h4 id="기본-사용법-5">기본 사용법</h4>
<pre><code class="language-jsx">const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);</code></pre>
<table>
<thead>
<tr>
<th>이름</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>reducerAction</code></td>
<td>이전 state와 전달받은 값을 받아서 다음 state를 돌려주는 함수예요. 비동기 함수도 돼요</td>
</tr>
<tr>
<td><code>initialState</code></td>
<td>state의 초기값이에요</td>
</tr>
<tr>
<td><code>state</code></td>
<td>현재 state예요. 처음에는 <code>initialState</code>이고, 이후에는 <code>reducerAction</code>이 마지막으로 반환한 값이에요</td>
</tr>
<tr>
<td><code>dispatchAction</code></td>
<td><code>reducerAction</code>을 실행시키는 함수예요</td>
</tr>
<tr>
<td><code>isPending</code></td>
<td>실행 중인 Action이 있는지 여부예요</td>
</tr>
</tbody></table>
<pre><code class="language-jsx">import { useActionState, startTransition } from &#39;react&#39;;

function Checkout() {
  const [count, dispatchAction, isPending] = useActionState(
    // reducerAction: 이전 수량을 받아 서버에 추가 요청 후 새 수량 반환
    async (prevCount) =&gt; {
      return await addToCart(prevCount);
    },
    0 // 초기 수량
  );

  function handleClick() {
    startTransition(() =&gt; {
      dispatchAction();
    });
  }

  return (
    &lt;button onClick={handleClick}&gt;
      티켓 추가 (현재 {count}장){isPending ? &#39; 처리 중...&#39; : &#39;&#39;}
    &lt;/button&gt;
  );
}</code></pre>
<p>흐름은 이래요.</p>
<ol>
<li>버튼을 누르면 <code>dispatchAction()</code>이 호출돼요.</li>
<li>React가 <code>reducerAction(이전 state, 넘긴 값)</code>을 호출해요. 실행되는 동안 <code>isPending</code>은 <code>true</code>예요.</li>
<li><code>reducerAction</code>이 반환한 값이 새 <code>state</code>가 되고 화면이 갱신돼요.</li>
</ol>
<blockquote>
<p>공식 문서에서는 <code>reducerAction</code>이 부수효과를 하도록 설계되어서, 개발 환경의 Strict Mode에서도 두 번 실행되지 않는다고 해요.</p>
</blockquote>
<h4 id="여러-번-호출하면-순서대로-실행돼요">여러 번 호출하면 순서대로 실행돼요</h4>
<p><code>dispatchAction</code>을 빠르게 여러 번 호출하면 React가 <strong>호출을 줄 세워서 하나씩 순서대로</strong> 실행해요. 앞 호출의 결과가 다음 호출의 <code>prevState</code>로 들어가야 하기 때문이에요. 그래서 요청에 1초가 걸리면, 네 번 누르면 4초가 걸려요. 공식 문서는 이게 의도된 설계라고 말해요.</p>
<p>앞의 <code>useTransition</code>에서 나온 &quot;응답 순서가 뒤바뀌는 문제&quot;가 <code>useActionState</code>에서는 일어나지 않는 이유가 이거예요.</p>
<p>다만 요청이 오래 걸리면 화면이 느리게 반응하는 것처럼 보일 수 있어요. 이럴 때 <code>useOptimistic</code>을 함께 쓰면 돼요. <code>useOptimistic</code>은 <strong>Action이 진행되는 동안 임시로 보여줄 값</strong>을 만들어 줘요. 서버 응답을 기다리지 않고 화면에 결과를 먼저 보여주는 거예요.</p>
<pre><code class="language-jsx">import { useActionState, useOptimistic, startTransition } from &#39;react&#39;;

function Checkout() {
  // updateCartAction: 서버에 요청하는 reducerAction
  const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0);
  const [optimisticCount, setOptimisticCount] = useOptimistic(count);

  function handleAdd() {
    startTransition(() =&gt; {
      // 화면에는 즉시 +1 반영
      setOptimisticCount(c =&gt; c + 1);
      // 실제 요청은 순서대로 처리
      dispatchAction({ type: &#39;ADD&#39; });
    });
  }

  // ...
}</code></pre>
<ul>
<li><code>useOptimistic(count)</code>는 <code>[optimisticCount, setOptimisticCount]</code>를 돌려줘요. Action이 진행 중이 아닐 때 <code>optimisticCount</code>는 <code>count</code>와 같아요.</li>
<li><code>setOptimisticCount</code>는 <strong>Action 안에서</strong> 호출해야 해요. 위 코드는 <code>startTransition</code> 안에서 호출하고 있어요.</li>
<li>임시 값은 <strong>Action이 진행되는 동안에만</strong> 보여요. Action이 끝나면 실제 값(<code>count</code>)이 화면에 반영돼요.</li>
<li>Action이 <strong>실패하면</strong> 실제 값이 바뀌지 않으니, 화면은 낙관적 업데이트 이전 모습으로 자동으로 돌아가요. 에러는 <code>catch</code>해서 사용자에게 메시지를 보여줄 수 있어요.</li>
</ul>
<p>순서대로 실행되는 것 자체를 피하고 싶다면, 대기 중인 Action을 <code>AbortController</code>로 취소하는 방법도 있어요. 다만 서버에 이미 반영된 변경(예를 들어 DB에 쓰기)은 요청을 취소해도 되돌릴 수 없어서, 취소해도 안전한 경우에만 쓸 수 있어요. 더 복잡한 경우에는 <code>useActionState</code>를 쓰지 않는 방법도 함께 고려하라고 공식 문서는 안내해요.</p>
<h4 id="form과-함께-쓰기">form과 함께 쓰기</h4>
<p><code>dispatchAction</code>을 <code>&lt;form&gt;</code>의 <code>action</code>에 넘기면, React가 <strong>자동으로 Transition으로 감싸 주기 때문에</strong> <code>startTransition</code>을 직접 쓸 필요가 없어요. 이때 <code>reducerAction</code>은 이전 state와 제출된 <code>FormData</code>를 받아요.</p>
<pre><code class="language-jsx">function Signup() {
  const [state, dispatchAction, isPending] = useActionState(signupAction, { error: null });

  return (
    &lt;form action={dispatchAction}&gt;
      &lt;input name=&quot;email&quot; /&gt;
      &lt;button disabled={isPending}&gt;가입&lt;/button&gt;
      {state.error &amp;&amp; &lt;p&gt;{state.error}&lt;/p&gt;}
    &lt;/form&gt;
  );
}

// 첫 번째 인자: 이전 state, 두 번째 인자: 제출된 FormData
async function signupAction(prevState, formData) {
  const res = await fetch(&#39;/api/signup&#39;, { method: &#39;POST&#39;, body: formData });
  if (!res.ok) {
    return { error: &#39;가입에 실패했어요&#39; };
  }
  return { error: null };
}</code></pre>
<p><code>useActionState</code>를 쓰지 않을 때의 form action은 <code>function action(formData)</code>처럼 인자가 <code>formData</code> 하나인데, <code>useActionState</code>를 쓰면 <strong>첫 번째 인자가 이전 state로 밀려서</strong> <code>formData</code>는 두 번째 인자가 돼요. 헷갈리기 쉬운 부분이에요.</p>
<h4 id="에러-처리">에러 처리</h4>
<p>에러는 두 가지로 나눠서 다뤄요.</p>
<ul>
<li><strong>예상 가능한 에러</strong>(예: &quot;수량이 부족해요&quot; 같은 서버의 검사 결과): <code>reducerAction</code>이 <strong>반환하는 state에 담아서</strong> 화면에 보여줘요.</li>
<li><strong>예상하지 못한 에러</strong>(예: 코드 버그): <code>throw</code>하면 React가 대기 중인 Action을 전부 취소하고, 가장 가까운 Error Boundary를 보여줘요.</li>
</ul>
<h4 id="흔한-실수-7">흔한 실수</h4>
<ul>
<li><strong><code>startTransition</code> 없이 <code>dispatchAction</code>을 직접 호출</strong>: <code>An async function with useActionState was called outside of a transition</code> 에러가 나고, <code>isPending</code>도 제대로 갱신되지 않아요. <code>&lt;form action&gt;</code>이나 Action을 받는 prop에 넘길 때는 자동으로 감싸지니 괜찮아요.</li>
<li><strong><code>reducerAction</code>의 첫 번째 인자를 <code>formData</code>로 착각</strong>: 첫 번째는 이전 state예요.</li>
<li><strong>렌더링 중에 <code>dispatchAction</code> 호출</strong>: <code>Cannot update action state while rendering</code> 에러가 나고, 무한 루프가 돼요. 클릭이나 폼 제출 같은 사용자 이벤트에서만 호출하세요.</li>
<li><strong><code>reducerAction</code>에서 에러를 <code>throw</code>함</strong>: 그 뒤에 줄 서 있던 호출이 전부 건너뛰어져요. 예상 가능한 에러는 <code>try/catch</code>로 잡아서 에러 state를 반환하세요.</li>
</ul>
<hr>
<h3 id="3번-요약-상황별-선택">3번 요약: 상황별 선택</h3>
<table>
<thead>
<tr>
<th>이런 상황이라면</th>
<th>이 Hook을 고려해요</th>
</tr>
</thead>
<tbody><tr>
<td>state를 바꾸는 경우가 많아서 코드가 흩어져요</td>
<td><code>useReducer</code></td>
</tr>
<tr>
<td>느린 계산 결과를 재사용하고 싶어요</td>
<td><code>useMemo</code></td>
</tr>
<tr>
<td><code>memo</code>로 감싼 자식에게 넘기는 함수를 유지하고 싶어요</td>
<td><code>useCallback</code></td>
</tr>
<tr>
<td>급하지 않은 state 업데이트를 미루고, 진행 상태를 보여주고 싶어요</td>
<td><code>useTransition</code></td>
</tr>
<tr>
<td>입력이 느린 화면 영역 때문에 버벅여요</td>
<td><code>useDeferredValue</code></td>
</tr>
<tr>
<td>서버 요청 결과를 state로 관리하고, 요청 순서를 보장하고 싶어요</td>
<td><code>useActionState</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="4-알아만-두는-hook">4. 알아만 두는 Hook</h2>
<p>지금까지 배운 Hook만으로도 대부분의 화면을 만들 수 있어요. 이 섹션의 Hook 일곱 개는 <strong>특정 상황에서만</strong> 필요해요. 공식 문서도 일부는 라이브러리를 만드는 사람을 위한 Hook이라고 안내해요. 그러니 지금 다 외울 필요는 없고, &quot;이런 상황에 쓰는 Hook이 있구나&quot; 정도만 기억해 두세요. 나중에 그 상황을 만났을 때 다시 찾아보면 돼요.</p>
<table>
<thead>
<tr>
<th>Hook</th>
<th>이럴 때 떠올려요</th>
</tr>
</thead>
<tbody><tr>
<td><code>useId</code></td>
<td>접근성 속성 등에 쓸 고유한 ID가 필요할 때</td>
</tr>
<tr>
<td><code>useEffectEvent</code></td>
<td>Effect 안에서 최신 값을 읽고 싶지만, 그 값이 바뀔 때 Effect가 다시 실행되는 건 싫을 때</td>
</tr>
<tr>
<td><code>useImperativeHandle</code></td>
<td>부모에게 &quot;포커스 주기&quot; 같은 <strong>일부 동작만</strong> 공개하고 싶을 때</td>
</tr>
<tr>
<td><code>useSyncExternalStore</code></td>
<td>React 밖에서 바뀌는 데이터(브라우저 API, 외부 상태 라이브러리)를 구독할 때</td>
</tr>
<tr>
<td><code>useLayoutEffect</code></td>
<td>화면이 그려지기 <strong>전에</strong> 요소의 크기나 위치를 측정해서 다시 렌더링해야 할 때</td>
</tr>
<tr>
<td><code>useInsertionEffect</code></td>
<td>CSS-in-JS 라이브러리를 만들면서 <code>&lt;style&gt;</code> 태그를 넣어야 할 때</td>
</tr>
<tr>
<td><code>useDebugValue</code></td>
<td>커스텀 Hook에 개발자 도구용 라벨을 달고 싶을 때</td>
</tr>
</tbody></table>
<hr>
<h3 id="4-1-useid--고유한-id-만들기">4-1. <code>useId</code> — 고유한 ID 만들기</h3>
<h4 id="이런-hook이에요-9">이런 Hook이에요</h4>
<p>접근성 속성에 쓸 <strong>고유한 ID 문자열</strong>을 만들어 주는 Hook이에요.</p>
<p>접근성 속성 중 <code>aria-describedby</code>는 &quot;이 요소는 저 요소로 설명된다&quot;는 관계를 스크린 리더 같은 보조 기술에 알려줘요. 예를 들어 비밀번호 입력창과 그 아래의 안내 문구를 연결할 때 써요. 이때 두 요소를 이어 주는 것이 <code>id</code>예요.</p>
<pre><code class="language-jsx">&lt;label&gt;
  비밀번호:
  &lt;input type=&quot;password&quot; aria-describedby=&quot;password-hint&quot; /&gt;
&lt;/label&gt;
&lt;p id=&quot;password-hint&quot;&gt;비밀번호는 18자 이상이어야 해요&lt;/p&gt;</code></pre>
<h4 id="왜-id를-직접-적으면-안-되나요">왜 ID를 직접 적으면 안 되나요?</h4>
<p>React에서는 하나의 컴포넌트가 화면에 <strong>여러 번</strong> 나타날 수 있어요. 그런데 <code>id</code>는 페이지 안에서 유일해야 해요. <code>&quot;password-hint&quot;</code>처럼 직접 적어 두면, 이 컴포넌트를 두 번 쓰는 순간 같은 <code>id</code>가 두 개 생겨요.</p>
<pre><code class="language-jsx">import { useId } from &#39;react&#39;;

function PasswordField() {
  // 컴포넌트마다 고유한 ID 생성
  const passwordHintId = useId();

  return (
    &lt;&gt;
      &lt;label&gt;
        비밀번호:
        &lt;input type=&quot;password&quot; aria-describedby={passwordHintId} /&gt;
      &lt;/label&gt;
      &lt;p id={passwordHintId}&gt;
        비밀번호는 18자 이상이어야 해요
      &lt;/p&gt;
    &lt;/&gt;
  );
}

export default function App() {
  return (
    &lt;&gt;
      &lt;h2&gt;비밀번호 입력&lt;/h2&gt;
      &lt;PasswordField /&gt;
      &lt;h2&gt;비밀번호 확인&lt;/h2&gt;
      &lt;PasswordField /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p><code>PasswordField</code>가 두 번 나타나도 각각 다른 ID가 만들어져서 충돌하지 않아요.</p>
<p><code>nextId++</code> 같은 직접 만든 카운터 대신 <code>useId</code>를 쓰는 이유는 <strong>서버 렌더링</strong>과 함께 쓸 수 있기 때문이에요. 서버에서 만든 HTML의 ID와 브라우저에서 만든 ID가 같아야 하는데, 직접 만든 카운터로는 이걸 보장하기 어려워요.</p>
<h4 id="관련된-요소가-여러-개일-때">관련된 요소가 여러 개일 때</h4>
<p>요소마다 <code>useId</code>를 호출하지 않고, 하나를 만들어 뒤에 이름을 붙여 쓸 수 있어요.</p>
<pre><code class="language-jsx">function Form() {
  const id = useId();

  return (
    &lt;form&gt;
      &lt;label htmlFor={id + &#39;-firstName&#39;}&gt;이름:&lt;/label&gt;
      &lt;input id={id + &#39;-firstName&#39;} type=&quot;text&quot; /&gt;
      &lt;hr /&gt;
      &lt;label htmlFor={id + &#39;-lastName&#39;}&gt;성:&lt;/label&gt;
      &lt;input id={id + &#39;-lastName&#39;} type=&quot;text&quot; /&gt;
    &lt;/form&gt;
  );
}</code></pre>
<h4 id="알아둘-점-2">알아둘 점</h4>
<ul>
<li><strong>리스트의 <code>key</code>로 쓰면 안 돼요.</strong> <code>key</code>는 데이터에서 만들어야 해요.</li>
<li>서버 렌더링을 쓴다면, 서버와 브라우저에서 렌더링하는 컴포넌트 트리가 똑같아야 ID가 일치해요.</li>
</ul>
<hr>
<h3 id="4-2-useeffectevent--effect-안에서-최신-값만-읽기">4-2. <code>useEffectEvent</code> — Effect 안에서 최신 값만 읽기</h3>
<h4 id="이런-hook이에요-10">이런 Hook이에요</h4>
<p>Effect 안에서 <strong>최신 props나 state를 읽으면서도</strong>, 그 값이 바뀔 때 Effect가 다시 실행되지 않게 해주는 Hook이에요.</p>
<p>2-2에서 배운 것처럼, Effect 안에서 쓰는 props와 state는 전부 의존성 배열에 넣어야 해요. 그런데 &quot;읽기만 하고 싶을 뿐, 그 값이 바뀐다고 해서 Effect를 다시 실행하고 싶지는 않은&quot; 값이 있을 때 문제가 생겨요.</p>
<h4 id="문제-상황">문제 상황</h4>
<p>채팅방에 연결되면 알림을 띄우는 컴포넌트를 생각해 볼게요. 사용자가 &quot;알림 끄기&quot;(<code>muted</code>)를 켜 두면 알림을 띄우지 않아요.</p>
<pre><code class="language-jsx">function ChatRoom({ roomId, muted }) {
  useEffect(() =&gt; {
    const connection = createConnection(roomId);
    connection.on(&#39;connected&#39;, () =&gt; {
      if (!muted) {
        showNotification(&#39;채팅방에 연결되었어요&#39;);
      }
    });
    connection.connect();
    return () =&gt; connection.disconnect();
  }, [roomId, muted]); // muted 변경 시에도 채팅방 재연결
}</code></pre>
<p><code>muted</code>를 Effect 안에서 쓰니까 의존성에 넣어야 하고, 그러면 <strong>알림 설정을 바꿀 때마다 채팅방 연결이 끊겼다가 다시 연결돼요.</strong> 원하는 동작이 아니에요.</p>
<h4 id="useeffectevent로-해결"><code>useEffectEvent</code>로 해결</h4>
<pre><code class="language-jsx">import { useEffect, useEffectEvent } from &#39;react&#39;;

function ChatRoom({ roomId, muted }) {
  // Effect Event: 호출 시점의 최신 muted 값 읽기
  const onConnected = useEffectEvent(() =&gt; {
    if (!muted) {
      showNotification(&#39;채팅방에 연결되었어요&#39;);
    }
  });

  useEffect(() =&gt; {
    const connection = createConnection(roomId);
    connection.on(&#39;connected&#39;, () =&gt; {
      onConnected();
    });
    connection.connect();
    return () =&gt; connection.disconnect();
  }, [roomId]); // onConnected와 muted는 의존성에서 제외
}</code></pre>
<p><code>useEffectEvent</code>로 만든 함수(<strong>Effect Event</strong>)는 Effect의 일부이지만 이벤트 핸들러처럼 동작해요. 호출할 때마다 <strong>그 시점의 최신 값</strong>을 읽고, 의존성 배열에는 넣지 않아요. 이제 알림 설정을 바꿔도 채팅방은 다시 연결되지 않고, <code>roomId</code>가 바뀔 때만 다시 연결돼요.</p>
<h4 id="알아둘-점-3">알아둘 점</h4>
<ul>
<li><strong>Effect 안에서만 호출해요.</strong> 렌더링 중에 호출하거나 다른 컴포넌트에 props로 넘기면 안 돼요. <code>eslint-plugin-react-hooks</code>가 이 규칙을 검사해 줘요.</li>
<li><strong>의존성 배열에 넣지 않아요.</strong> 넣으면 린터가 경고해요. 일부러 렌더링마다 새 함수가 되도록 만든 것이에요.</li>
<li><strong>의존성을 숨기려고 쓰면 안 돼요.</strong> 공식 문서가 특히 강조하는 부분이에요.</li>
</ul>
<pre><code class="language-jsx">// 잘못된 방식: 의존성을 숨기기 위한 사용
const logVisit = useEffectEvent(() =&gt; {
  log(pageUrl);
});

useEffect(() =&gt; {
  logVisit();
}, []); // pageUrl이 바뀌어도 로그 누락</code></pre>
<p>값이 바뀔 때 Effect가 다시 실행되어야 한다면 그 값은 의존성으로 남겨 두세요. <code>useEffectEvent</code>는 <strong>정말로 &quot;Effect 안에서 발생하는 이벤트&quot;인 로직</strong>에만 써요.</p>
<hr>
<h3 id="4-3-useimperativehandle--부모에게-공개할-동작-고르기">4-3. <code>useImperativeHandle</code> — 부모에게 공개할 동작 고르기</h3>
<h4 id="이런-hook이에요-11">이런 Hook이에요</h4>
<p>부모가 <code>ref</code>로 접근했을 때 <strong>무엇을 받을지 직접 정하는</strong> Hook이에요.</p>
<p>2-3에서 <code>ref</code>를 prop으로 받아 안쪽 <code>&lt;input&gt;</code>에 그대로 넘기면, 부모는 그 DOM 요소 전체에 접근할 수 있다고 배웠어요. 그런데 DOM 요소 전체를 열어 주는 대신 <code>focus</code>와 <code>scrollIntoView</code> 같은 <strong>일부 동작만</strong> 공개하고 싶을 때가 있어요.</p>
<h4 id="사용법">사용법</h4>
<pre><code class="language-jsx">import { useRef, useImperativeHandle } from &#39;react&#39;;

function MyInput({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () =&gt; {
    // 부모에게 공개할 동작만 담은 객체
    return {
      focus() {
        inputRef.current.focus();
      },
      scrollIntoView() {
        inputRef.current.scrollIntoView();
      },
    };
  }, []);

  // 실제 DOM 요소는 내부 ref에만 연결
  return &lt;input ref={inputRef} /&gt;;
}</code></pre>
<pre><code class="language-jsx">function Form() {
  const ref = useRef(null);

  function handleClick() {
    ref.current.focus();
    // 동작하지 않는 코드: DOM 요소 전체가 공개되지 않음
    // ref.current.style.opacity = 0.5;
  }

  return (
    &lt;form&gt;
      &lt;MyInput placeholder=&quot;이름을 입력하세요&quot; ref={ref} /&gt;
      &lt;button type=&quot;button&quot; onClick={handleClick}&gt;
        수정
      &lt;/button&gt;
    &lt;/form&gt;
  );
}</code></pre>
<p><code>useImperativeHandle(ref, createHandle, dependencies?)</code>의 두 번째 인자인 <code>createHandle</code>이 돌려주는 객체가 부모의 <code>ref.current</code>가 돼요. 부모는 <code>focus()</code>와 <code>scrollIntoView()</code>는 호출할 수 있지만, <code>&lt;input&gt;</code> DOM 요소에는 직접 접근할 수 없어요.</p>
<h4 id="알아둘-점-ref를-남용하지-마세요">알아둘 점: ref를 남용하지 마세요</h4>
<p>공식 문서는 <code>ref</code>를 <strong>props로는 표현할 수 없는 명령형 동작</strong>에만 쓰라고 해요. 예를 들면 스크롤하기, 포커스 주기, 애니메이션 시작하기, 텍스트 선택하기 같은 것들이에요.</p>
<p><strong>props로 표현할 수 있다면 ref를 쓰지 않는 게 좋아요.</strong> 예를 들어 <code>Modal</code> 컴포넌트에서 <code>{ open, close }</code> 메서드를 공개하는 것보다, <code>isOpen</code>을 prop으로 받는 <code>&lt;Modal isOpen={isOpen} /&gt;</code>가 더 좋아요.</p>
<hr>
<h3 id="4-4-usesyncexternalstore--react-밖의-데이터-구독하기">4-4. <code>useSyncExternalStore</code> — React 밖의 데이터 구독하기</h3>
<h4 id="이런-hook이에요-12">이런 Hook이에요</h4>
<p><strong>React 밖에서 바뀌는 데이터</strong>를 구독해서, 그 데이터가 바뀌면 컴포넌트를 다시 렌더링하는 Hook이에요.</p>
<p>대부분의 컴포넌트는 props, state, context만 읽어요. 그런데 가끔 React와 상관없이 값이 바뀌는 곳에서 데이터를 읽어야 할 때가 있어요.</p>
<ul>
<li>React 밖에 상태를 두는 서드파티 상태 관리 라이브러리</li>
<li>값과 변경 이벤트를 제공하는 브라우저 API (예: 네트워크 연결 상태)</li>
</ul>
<p>이런 값은 React가 변화를 알 수 없기 때문에, 직접 &quot;이 값이 바뀌면 알려줘&quot;라고 구독해야 해요.</p>
<h4 id="사용법-네트워크-연결-상태-읽기">사용법: 네트워크 연결 상태 읽기</h4>
<p>브라우저의 <code>navigator.onLine</code>은 현재 네트워크에 연결되어 있는지를 알려줘요. 이 값은 React와 상관없이 바뀌어요.</p>
<pre><code class="language-jsx">import { useSyncExternalStore } from &#39;react&#39;;

// 변경 시 callback을 호출하고, 구독 해제 함수를 반환
function subscribe(callback) {
  window.addEventListener(&#39;online&#39;, callback);
  window.addEventListener(&#39;offline&#39;, callback);
  return () =&gt; {
    window.removeEventListener(&#39;online&#39;, callback);
    window.removeEventListener(&#39;offline&#39;, callback);
  };
}

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe, // 구독하는 방법
    () =&gt; navigator.onLine, // 클라이언트에서 값을 읽는 방법
    () =&gt; true // 서버에서 값을 읽는 방법
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  return &lt;h1&gt;{isOnline ? &#39;온라인&#39; : &#39;연결 끊김&#39;}&lt;/h1&gt;;
}</code></pre>
<p><code>useSyncExternalStore</code>에는 함수 세 개를 넘겨요.</p>
<ol>
<li><strong><code>subscribe</code></strong>: 스토어를 구독하는 함수예요. 스토어가 바뀌면 받은 <code>callback</code>을 호출하고, 구독을 해제하는 함수를 반환해요.</li>
<li><strong><code>getSnapshot</code></strong>: 스토어의 현재 값을 읽는 함수예요. 스토어가 바뀌지 않았다면 여러 번 호출해도 <strong>같은 값</strong>을 돌려줘야 해요.</li>
<li><strong><code>getServerSnapshot</code></strong> (선택): 서버에서 HTML을 만들 때, 그리고 그 HTML에 React가 이벤트를 연결하는 동안 쓸 초기값이에요. 서버에는 <code>navigator</code> 같은 브라우저 API가 없어서 필요해요.</li>
</ol>
<p>보통은 위처럼 <code>useOnlineStatus</code> 같은 <strong>커스텀 Hook으로 감싸서</strong> 써요. 그러면 여러 컴포넌트가 같은 코드를 반복하지 않고 같은 스토어를 쓸 수 있어요.</p>
<h4 id="알아둘-점-4">알아둘 점</h4>
<ul>
<li><strong>가능하면 <code>useState</code>와 <code>useReducer</code>를 쓰세요.</strong> 이 Hook은 주로 React가 아닌 기존 코드와 연결할 때 유용해요.</li>
<li><strong>Effect로 직접 구독하는 것보다 이 Hook이 좋아요.</strong> 공식 문서도 <code>useEffect</code>와 <code>useState</code>로 구독을 직접 구현하는 방식보다 이 Hook을 권해요. 덜 실수하기 쉬워요.</li>
</ul>
<h4 id="흔한-실수-8">흔한 실수</h4>
<ul>
<li><strong><code>getSnapshot</code>이 호출할 때마다 새 객체를 돌려줌</strong>: React가 &quot;값이 바뀌었다&quot;고 판단해서 무한 반복에 빠져요. <code>The result of getSnapshot should be cached</code> 에러가 나요. 스토어에 실제로 바뀐 것이 있을 때만 새 값을 돌려주세요.</li>
<li><strong><code>subscribe</code> 함수를 컴포넌트 안에서 정의함</strong>: 렌더링마다 다른 함수가 되어서 React가 매번 다시 구독해요. <code>subscribe</code>를 컴포넌트 밖으로 옮기세요.</li>
</ul>
<hr>
<h3 id="4-5-uselayouteffect--화면이-그려지기-전에-실행하기">4-5. <code>useLayoutEffect</code> — 화면이 그려지기 전에 실행하기</h3>
<h4 id="이런-hook이에요-13">이런 Hook이에요</h4>
<p><code>useEffect</code>와 똑같이 쓰지만, <strong>브라우저가 화면을 다시 그리기 전에</strong> 실행되는 Hook이에요.</p>
<blockquote>
<p><strong>주의</strong>: <code>useLayoutEffect</code>는 성능을 해칠 수 있어요. 가능하면 <code>useEffect</code>를 쓰세요. 공식 문서도 대부분의 Effect에는 필요 없다고 안내해요.</p>
</blockquote>
<h4 id="언제-필요한가요-툴팁-위치-정하기">언제 필요한가요? 툴팁 위치 정하기</h4>
<p>마우스를 올리면 나타나는 툴팁을 생각해 볼게요. 요소 위쪽에 공간이 충분하면 위에, 아니면 아래에 보여주고 싶어요. 그러려면 <strong>툴팁의 높이를 알아야</strong> 해요. 그런데 높이는 일단 화면에 넣어 봐야 알 수 있어요.</p>
<ol>
<li>일단 아무 위치에나 툴팁을 렌더링해요 (높이는 모르니 <code>0</code>으로 가정).</li>
<li>DOM에 들어간 툴팁의 높이를 측정해요.</li>
<li>측정한 높이로 올바른 위치에 <strong>다시 렌더링</strong>해요.</li>
</ol>
<p>이 모든 일이 <strong>브라우저가 화면을 그리기 전에</strong> 끝나야 해요. 그렇지 않으면 사용자가 툴팁이 잘못된 위치에 나타났다가 옮겨지는 모습을 보게 돼요.</p>
<pre><code class="language-jsx">import { useState, useRef, useLayoutEffect } from &#39;react&#39;;

function Tooltip() {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0); // 실제 높이를 아직 모름

  useLayoutEffect(() =&gt; {
    // 화면이 그려지기 전 툴팁 높이 측정
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // 실제 높이를 알았으니 즉시 다시 렌더링
  }, []);

  // ...tooltipHeight를 사용해 위치 결정...
}</code></pre>
<p><code>useLayoutEffect</code> 안의 코드와 그 안에서 예약한 state 업데이트는 <strong>브라우저가 화면을 그리기 전에</strong> 모두 처리돼요. 그래서 사용자는 첫 번째(잘못된 위치) 렌더링을 보지 못하고 최종 결과만 봐요. <code>useEffect</code>를 쓰면 React가 화면을 먼저 그리게 둘 수 있어서, 잘못된 위치가 잠깐 보였다가 바뀌는 깜빡임이 생길 수 있어요.</p>
<h4 id="알아둘-점-5">알아둘 점</h4>
<ul>
<li><strong>화면 그리기를 막아요.</strong> <code>useLayoutEffect</code>의 코드와 그 안의 state 업데이트는 브라우저가 화면을 그리는 것을 막아요. 많이 쓰면 앱이 느려져요.</li>
<li><strong>두 번 렌더링하는 방식 자체가 비용이에요.</strong> 피할 수 있다면 피하는 것이 좋아요.</li>
<li><strong>서버 렌더링에서는 동작하지 않아요.</strong> 서버에는 레이아웃이 없어서 측정할 수 없기 때문이에요. 서버 렌더링을 쓰는 앱에서 <code>useLayoutEffect does nothing on the server</code> 경고가 보이면, <code>useEffect</code>로 바꾸는 것이 가장 간단한 해결책이에요.</li>
</ul>
<hr>
<h3 id="4-6-useinsertioneffect--css-in-js-라이브러리용">4-6. <code>useInsertionEffect</code> — CSS-in-JS 라이브러리용</h3>
<h4 id="이런-hook이에요-14">이런 Hook이에요</h4>
<p><strong>다른 Effect들이 실행되기 전에</strong> DOM에 요소를 넣을 수 있게 해주는 Hook이에요.</p>
<blockquote>
<p><strong>공식 문서의 안내</strong>: <code>useInsertionEffect</code>는 CSS-in-JS 라이브러리를 만드는 사람을 위한 Hook이에요. 그런 라이브러리를 만들면서 스타일을 넣을 자리가 필요한 경우가 아니라면, <code>useEffect</code>나 <code>useLayoutEffect</code>를 쓰세요.</p>
</blockquote>
<h4 id="어떤-상황인가요">어떤 상황인가요?</h4>
<p>CSS-in-JS는 CSS 파일 대신 자바스크립트 코드 안에서 스타일을 작성하는 방식이에요. 일부 라이브러리는 실행 중에 <code>&lt;style&gt;</code> 태그를 만들어 페이지에 넣어요. 이때 스타일이 <strong>너무 늦게</strong> 들어가면 문제가 생겨요. 다른 Effect에서 요소의 크기를 측정하는데, 그 시점에 스타일이 아직 없으면 측정값이 틀리기 때문이에요.</p>
<p><code>useInsertionEffect</code>는 다른 Effect들보다 먼저 실행되므로, 그 전에 스타일을 넣어 둘 수 있어요.</p>
<pre><code class="language-jsx">import { useInsertionEffect } from &#39;react&#39;;

// CSS-in-JS 라이브러리 내부
function useCSS(rule) {
  useInsertionEffect(() =&gt; {
    // &lt;style&gt; 태그 삽입
  });
  return rule;
}</code></pre>
<h4 id="알아둘-점-6">알아둘 점</h4>
<ul>
<li>이 Hook 안에서는 <strong>state를 업데이트할 수 없어요.</strong></li>
<li>이 Hook이 실행되는 시점에는 <strong><code>ref</code>가 아직 연결되지 않았어요.</strong></li>
<li>서버 렌더링 중에는 실행되지 않아요. (<code>useEffect</code>와 같아요.)</li>
</ul>
<p>애플리케이션을 만드는 입장에서는 쓸 일이 거의 없어요. 이름만 알아 두세요.</p>
<hr>
<h3 id="4-7-usedebugvalue--개발자-도구에-라벨-달기">4-7. <code>useDebugValue</code> — 개발자 도구에 라벨 달기</h3>
<h4 id="이런-hook이에요-15">이런 Hook이에요</h4>
<p><strong>커스텀 Hook</strong>에 React 개발자 도구(React DevTools)에서 보이는 <strong>라벨</strong>을 달아주는 Hook이에요.</p>
<pre><code class="language-jsx">import { useDebugValue } from &#39;react&#39;;

function useOnlineStatus() {
  // ...
  useDebugValue(isOnline ? &#39;Online&#39; : &#39;Offline&#39;);
  // ...
}</code></pre>
<p><code>useOnlineStatus</code>를 쓰는 컴포넌트를 개발자 도구로 검사하면 <code>OnlineStatus: &quot;Online&quot;</code>이라는 라벨이 보여요. <code>useDebugValue</code>가 없으면 안쪽의 값인 <code>true</code>만 보여서, 그게 무슨 뜻인지 알아보기 어려워요.</p>
<h4 id="포맷-함수">포맷 함수</h4>
<p>두 번째 인자로 포맷 함수를 넘기면, 디버그 값을 <strong>검사할 때만</strong> 포맷 작업을 실행해요.</p>
<pre><code class="language-jsx">// 개발자 도구에서 검사할 때만 toDateString 실행
useDebugValue(date, date =&gt; date.toDateString());</code></pre>
<p>렌더링마다 <code>toDateString()</code>을 실행하지 않아도 돼서, 비용이 드는 포맷 작업을 피할 수 있어요.</p>
<h4 id="알아둘-점-7">알아둘 점</h4>
<p>모든 커스텀 Hook에 붙일 필요는 없어요. 공식 문서는 <strong>공유 라이브러리에 들어가는, 내부 데이터 구조가 복잡해서 들여다보기 어려운 커스텀 Hook</strong>에 가장 유용하다고 해요.</p>
<hr>
<h2 id="5-마무리">5. 마무리</h2>
<h3 id="5-1-상황별-hook-선택-표">5-1. 상황별 Hook 선택 표</h3>
<table>
<thead>
<tr>
<th>이런 상황이라면</th>
<th>이 Hook</th>
</tr>
</thead>
<tbody><tr>
<td>화면에 보여줄 값을 기억하고 싶어요</td>
<td><code>useState</code></td>
</tr>
<tr>
<td>state를 바꾸는 경우가 많아서 코드가 흩어져요</td>
<td><code>useReducer</code></td>
</tr>
<tr>
<td>타이머나 서버 연결 같은 React 밖의 것과 맞춰야 해요</td>
<td><code>useEffect</code></td>
</tr>
<tr>
<td>화면과 상관없는 값(타이머 ID 등)을 기억하거나 DOM 요소를 다뤄야 해요</td>
<td><code>useRef</code></td>
</tr>
<tr>
<td>깊이 있는 컴포넌트에 props 없이 값을 전달하고 싶어요</td>
<td><code>useContext</code></td>
</tr>
<tr>
<td>느린 계산 결과를 재사용하고 싶어요</td>
<td><code>useMemo</code></td>
</tr>
<tr>
<td><code>memo</code>로 감싼 자식에게 넘기는 함수를 유지하고 싶어요</td>
<td><code>useCallback</code></td>
</tr>
<tr>
<td>급하지 않은 업데이트를 미루고 진행 상태를 보여주고 싶어요</td>
<td><code>useTransition</code></td>
</tr>
<tr>
<td>느린 화면 영역 때문에 입력이 버벅여요</td>
<td><code>useDeferredValue</code></td>
</tr>
<tr>
<td>서버 요청 결과를 state로 관리하고 요청 순서를 보장하고 싶어요</td>
<td><code>useActionState</code></td>
</tr>
<tr>
<td>접근성 속성에 쓸 고유한 ID가 필요해요</td>
<td><code>useId</code></td>
</tr>
<tr>
<td>Effect 안에서 최신 값을 읽되 Effect를 다시 실행시키고 싶지 않아요</td>
<td><code>useEffectEvent</code></td>
</tr>
<tr>
<td>부모에게 일부 동작만 공개하고 싶어요</td>
<td><code>useImperativeHandle</code></td>
</tr>
<tr>
<td>React 밖에서 바뀌는 데이터를 구독해야 해요</td>
<td><code>useSyncExternalStore</code></td>
</tr>
<tr>
<td>화면이 그려지기 전에 크기나 위치를 측정해야 해요</td>
<td><code>useLayoutEffect</code></td>
</tr>
<tr>
<td>CSS-in-JS 라이브러리를 만들어요</td>
<td><code>useInsertionEffect</code></td>
</tr>
<tr>
<td>커스텀 Hook에 개발자 도구용 라벨을 달고 싶어요</td>
<td><code>useDebugValue</code></td>
</tr>
</tbody></table>
<h3 id="5-2-기억해-둘-세-가지">5-2. 기억해 둘 세 가지</h3>
<p>Hook을 많이 알수록 좋은 게 아니라, <strong>언제 쓰지 않아도 되는지</strong> 아는 것이 더 중요해요. 이 글 전체를 세 문장으로 줄이면 이래요.</p>
<ol>
<li><strong>렌더링은 순수한 계산이고, state는 고치지 않고 교체해요.</strong> (1-3, 2-1)</li>
<li><strong>Effect는 React 밖의 것과 동기화할 때만 써요.</strong> 화면에 보여줄 값은 렌더링 중에 계산하고, 사용자 동작은 이벤트 핸들러에서 처리하세요. (2-2)</li>
<li><strong>최적화는 문제가 생긴 뒤에 해요.</strong> <code>useMemo</code>, <code>useCallback</code>, <code>memo</code>는 느려졌을 때 측정하고 나서 붙이는 도구예요. (3-2)</li>
</ol>
<h3 id="5-3-더-읽어볼-자료">5-3. 더 읽어볼 자료</h3>
<ul>
<li><a href="https://ko.react.dev/reference/react/hooks">내장 React Hook 전체 목록</a>: 이 글에서 다룬 Hook의 공식 문서로 이동할 수 있어요.</li>
<li><a href="https://ko.react.dev/learn/you-might-not-need-an-effect">Effect가 필요하지 않을 수도 있습니다</a>: 2-2의 내용을 더 많은 예제로 설명해요.</li>
<li><a href="https://ko.react.dev/learn/synchronizing-with-effects">Effect와 동기화하기</a>: Effect가 왜 필요한지, cleanup이 왜 필요한지를 설명해요.</li>
<li><a href="https://ko.react.dev/learn/reusing-logic-with-custom-hooks">커스텀 Hook으로 로직 재사용하기</a>: 이 글에서 자주 등장한 커스텀 Hook을 직접 만드는 방법이에요.</li>
<li><a href="https://ko.react.dev/learn/extracting-state-logic-into-a-reducer">reducer로 state 로직 정리하기</a>: 3-1의 <code>useReducer</code>를 더 자세히 다뤄요.</li>
</ul>
<p>이 글에서는 3-6에서 잠깐만 다룬 <code>useOptimistic</code>도 <a href="https://react.dev/reference/react/useOptimistic">공식 문서</a>에서 더 살펴보세요. <code>useActionState</code>, <code>useTransition</code>과 함께 쓰는 경우가 많아요.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[같은 단어, 다른 뜻: Nuxt + 헤드리스 CMS 프로젝트 용어 정리]]></title>
            <link>https://velog.io/@sojin-zip/%EA%B0%99%EC%9D%80-%EB%8B%A8%EC%96%B4-%EB%8B%A4%EB%A5%B8-%EB%9C%BB-Nuxt-%ED%97%A4%EB%93%9C%EB%A6%AC%EC%8A%A4-CMS-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9A%A9%EC%96%B4-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@sojin-zip/%EA%B0%99%EC%9D%80-%EB%8B%A8%EC%96%B4-%EB%8B%A4%EB%A5%B8-%EB%9C%BB-Nuxt-%ED%97%A4%EB%93%9C%EB%A6%AC%EC%8A%A4-CMS-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%9A%A9%EC%96%B4-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 11 Sep 2026 04:08:22 GMT</pubDate>
            <description><![CDATA[<h1 id="같은-단어-다른-뜻-nuxt--헤드리스-cms-프로젝트-용어-정리">같은 단어, 다른 뜻: Nuxt + 헤드리스 CMS 프로젝트 용어 정리</h1>
<p>주니어 개발자에게 진행 중인 프로젝트를 인수인계하면서 문서를 썼다.
그런데 다 쓰고 보니 문서 자체가 용어로 가득했다. &quot;swr 라우트는 Redis에 캐시되므로
배포 후 revalidate가 필요하다&quot; 같은 문장이 한 줄 걸러 나왔다.</p>
<p>그래서 용어집을 따로 만들었다. 하나하나 언제 다 구글링 해~
여기서 주목할 점은,
<strong>진짜 헷갈리는 건 모르는 단어가 아니라, 아는 단어가 다른 뜻으로 쓰일 때다.</strong></p>
<p>공식 문서에 정의가 잘 나와 있는 것들은 여기서 다루지 않는다.
검색해도 잘 안 나오거나, 검색해서 읽어도 현장에서 또 틀리는 것들만 모았다.</p>
<hr>
<h2 id="1-컴포넌트-같은-이름-완전히-다른-것">1. 컴포넌트: 같은 이름, 완전히 다른 것</h2>
<p>프런트엔드를 하다 헤드리스 CMS를 만나면 처음 부딪히는 지점이다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Vue 컴포넌트</th>
<th>Strapi 컴포넌트</th>
</tr>
</thead>
<tbody><tr>
<td>정체</td>
<td><strong>화면 조각</strong></td>
<td><strong>필드 묶음</strong></td>
</tr>
<tr>
<td>예시</td>
<td>버튼, 카드, 히어로 섹션</td>
<td>metaTitle + metaDescription + ogImage</td>
</tr>
<tr>
<td>쓰는 곳</td>
<td>템플릿에서 <code>&lt;ProductCard /&gt;</code></td>
<td>콘텐츠 타입 정의에서 재사용</td>
</tr>
</tbody></table>
<p>Strapi 컴포넌트는 화면과 아무 상관이 없다. 여러 콘텐츠 타입에서 <strong>반복되는 필드 조합</strong>을
하나로 묶어둔 것이다. SEO 필드 세 개를 20개 페이지마다 따로 정의하기 싫으니까
<code>shared.seo</code>로 묶어두고 가져다 쓰는 식이다.</p>
<p>그래서 이런 대화가 성립한다.</p>
<blockquote>
<p>&quot;SEO 컴포넌트에 필드 하나 추가해주세요&quot;
→ Vue 파일을 열면 안 된다. CMS 스키마를 고쳐야 한다.</p>
</blockquote>
<h2 id="2-prerender--ssr--isr--swr-언제-html을-만드냐의-문제">2. prerender / SSR / ISR / SWR: 언제 HTML을 만드냐의 문제</h2>
<p>Nuxt를 처음 만지면 이 넷이 제일 어렵다. 이름이 다 비슷하고, 공식 문서를 읽어도
&quot;그래서 뭘 골라야 하지&quot;가 남는다.</p>
<p>그런데 질문 하나로 정리된다. <strong>HTML을 누가, 언제 만드는가.</strong></p>
<h3 id="출발점-브라우저가-만들면-어떻게-되나-csr">출발점: 브라우저가 만들면 어떻게 되나 (CSR)</h3>
<p>React나 Vue만 쓰면 서버는 빈 껍데기 HTML을 준다.</p>
<pre><code class="language-html">&lt;div id=&quot;app&quot;&gt;&lt;/div&gt;
&lt;script src=&quot;/app.js&quot;&gt;&lt;/script&gt;</code></pre>
<p>브라우저가 JS를 받아 실행해야 비로소 화면이 그려진다. 이걸 CSR(Client Side Rendering)이라
한다. 문제는 둘이다. 첫 화면이 늦고, <strong>검색엔진 크롤러가 빈 div만 보고 갈 수 있다.</strong></p>
<p>그래서 서버가 미리 HTML을 만들어 주자는 게 나머지 넷의 공통 출발점이다.
차이는 <strong>그 &quot;미리&quot;가 언제냐</strong>다.</p>
<h3 id="prerender-빌드할-때-만든다">prerender: 빌드할 때 만든다</h3>
<p>배포용 파일을 만드는 시점에 페이지를 실제로 렌더해서 HTML 파일로 저장해 둔다.
요청이 오면 그 파일을 그냥 내보낸다.</p>
<pre><code>빌드 시점:  [페이지 렌더] → about.html 저장
요청 1:     about.html 전송   (0ms 계산)
요청 2:     about.html 전송
요청 N:     about.html 전송</code></pre><p>가장 빠르다. 서버가 계산을 아예 안 한다. 대신 <strong>내용이 빌드 시점에 박제된다.</strong>
CMS에서 글을 고쳐도 다시 빌드하기 전까지 안 바뀐다.</p>
<blockquote>
<p><strong>함정</strong>: prerender는 빌드 컨테이너 안에서 <strong>진짜로 페이지를 렌더한다.</strong>
그 페이지가 CMS API를 호출하면 빌드할 때 CMS가 떠 있어야 하고,
캐시 저장소를 쓰면 빌드할 때 그것도 떠 있어야 한다.
빌드 환경에 런타임 인프라를 끌고 들어오는 셈이다.</p>
</blockquote>
<h3 id="ssr-요청이-올-때마다-만든다">SSR: 요청이 올 때마다 만든다</h3>
<p>요청이 들어오면 그때 서버가 데이터를 가져와 HTML을 만들어 보낸다.</p>
<pre><code>요청 1:  [DB 조회 → 렌더] → HTML 전송
요청 2:  [DB 조회 → 렌더] → HTML 전송
요청 3:  [DB 조회 → 렌더] → HTML 전송</code></pre><p>항상 최신이다. 대신 <strong>모든 요청마다 일한다.</strong> 트래픽이 많으면 서버가 버티기 힘들고,
사용자는 렌더가 끝날 때까지 기다린다.</p>
<p>로그인 상태처럼 사람마다 다른 화면, 폼 제출처럼 매번 결과가 달라야 하는 곳에 쓴다.</p>
<h3 id="캐시를-끼우면-isr과-swr">캐시를 끼우면: ISR과 SWR</h3>
<p>SSR은 최신이지만 느리고, prerender는 빠르지만 낡는다.
그 사이를 메우는 게 <strong>캐시 + 유효기간(TTL)</strong> 조합이다.</p>
<p>둘 다 &quot;한 번 만든 HTML을 N초 동안 재사용한다&quot;는 점은 같다.
<strong>차이는 유효기간이 지난 뒤에 나타난다.</strong></p>
<h4 id="isr-만료되면-새로-만들-때까지-기다린다">ISR: 만료되면 새로 만들 때까지 기다린다</h4>
<pre><code>TTL 60초 기준

t=0    첫 요청  → [렌더 800ms] → HTML 전송 + 캐시 저장
t=30   요청     → 캐시 전송 (빠름)
t=59   요청     → 캐시 전송 (빠름)
t=61   요청     → 만료됨 → [렌더 800ms 대기] → 새 HTML 전송 + 캐시 갱신
                  ↑ 이 사람은 800ms 기다린다
t=62   요청     → 새 캐시 전송 (빠름)</code></pre><p>만료 직후에 들어온 <strong>한 명이 렌더 비용을 부담한다.</strong> 대신 낡은 내용을 보는 사람은 없다.</p>
<h4 id="swr-만료돼도-일단-옛-것을-준다">SWR: 만료돼도 일단 옛 것을 준다</h4>
<p>이름이 그대로 설명이다. <code>stale-while-revalidate</code>, <strong>&quot;낡은 걸 주는 동안 갱신한다.&quot;</strong></p>
<pre><code>TTL 60초 기준

t=0    첫 요청  → [렌더 800ms] → HTML 전송 + 캐시 저장
t=30   요청     → 캐시 전송 (빠름)
t=61   요청     → 만료됐지만 → 옛 캐시 즉시 전송 (빠름)
                  동시에 뒤에서 [렌더] 돌려서 캐시 갱신
t=62   요청     → 갱신된 캐시 전송</code></pre><p><strong>아무도 기다리지 않는다.</strong> 대신 t=61의 사람은 61초 전 내용을 본다.</p>
<p>이게 SWR 관련 질문의 90%를 설명한다.</p>
<blockquote>
<p>&quot;분명 수정했는데 왜 옛날 게 보이죠?&quot;
→ 버그가 아니라 설계다. 한 번 더 새로고침하면 바뀐다.</p>
</blockquote>
<h3 id="정리">정리</h3>
<table>
<thead>
<tr>
<th></th>
<th>HTML 생성 시점</th>
<th>만료 후 첫 요청</th>
<th>최신성</th>
<th>서버 부하</th>
</tr>
</thead>
<tbody><tr>
<td><code>prerender</code></td>
<td>빌드할 때</td>
<td>해당 없음</td>
<td>낮음 (재빌드까지 고정)</td>
<td>없음</td>
</tr>
<tr>
<td><code>ssr</code></td>
<td>요청마다</td>
<td>해당 없음</td>
<td>항상 최신</td>
<td>높음</td>
</tr>
<tr>
<td><code>isr</code></td>
<td>첫 요청 + 만료 시</td>
<td><strong>기다림</strong></td>
<td>최대 TTL만큼 낡음</td>
<td>낮음</td>
</tr>
<tr>
<td><code>swr</code></td>
<td>첫 요청 + 만료 시</td>
<td><strong>안 기다림</strong> (낡은 것 제공)</td>
<td>최대 TTL + α</td>
<td>낮음</td>
</tr>
</tbody></table>
<p>Nuxt에서는 경로별로 지정한다.</p>
<pre><code class="language-ts">// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    &#39;/&#39;:          { swr: 3600 },   // 1시간 캐시, 만료돼도 즉시 응답
    &#39;/blog/**&#39;:   { swr: 3600 },
    &#39;/careers/**&#39;:{ isr: 600 },    // 10분. 낡은 내용을 보여주면 곤란한 경우
    &#39;/contact&#39;:   { ssr: true },   // 폼 제출, 캐시 금지
    &#39;/about&#39;:     { prerender: true },
  },
})</code></pre>
<h3 id="뭘-골라야-하나">뭘 골라야 하나</h3>
<ul>
<li>사람마다 다른 화면이거나 제출 결과가 보여야 한다 → <strong><code>ssr</code></strong></li>
<li>내용이 거의 안 바뀌고 빌드 때 데이터를 가져올 수 있다 → <strong><code>prerender</code></strong></li>
<li>자주 안 바뀌고, 몇 분 낡아도 괜찮다 → <strong><code>swr</code></strong></li>
<li>낡은 내용을 보여주면 곤란하다 (공고 마감, 가격) → <strong><code>isr</code></strong></li>
</ul>
<h3 id="캐시는-어디에-저장되나">캐시는 어디에 저장되나</h3>
<p>여기서 한 번 더 막힌다. ISR과 SWR의 캐시는 <strong>메모리나 Redis 같은 별도 저장소</strong>에 있다.
애플리케이션 컨테이너 바깥이다.</p>
<p>그래서 이런 일이 생긴다.</p>
<blockquote>
<p><strong>새 코드를 배포했는데 화면이 그대로다.</strong>
컨테이너는 새것으로 바뀌었지만 Redis에 남아 있던 옛 HTML이 계속 나가기 때문이다.</p>
</blockquote>
<p>배포 후에 캐시를 비워주는 절차가 필요하다. Nuxt에서는 보통 캐시를 지우는 엔드포인트를
직접 만들고, CMS 발행 웹훅이 그걸 호출하게 해둔다.</p>
<p>개발 환경에서 &quot;코드를 고쳤는데 옛 화면이 나온다&quot;거나 hydration mismatch 경고가 뜨는 것도
대개 같은 원인이다. 그래서 캐시는 운영에서만 켜는 편이 낫다.</p>
<pre><code class="language-ts">const prod = process.env.NODE_ENV === &#39;production&#39;
const swr3600 = prod ? { swr: 3600 } : {}   // dev 에서는 캐시 없음</code></pre>
<h2 id="3-빌드-타임-vs-런타임-어디서-읽느냐가-기준">3. 빌드 타임 vs 런타임: 어디서 읽느냐가 기준</h2>
<p>Nuxt를 쓰면 <code>NUXT_PUBLIC_*</code> 환경변수는 런타임에 바꿀 수 있다고 배운다. 맞는 말인데,
전부 그런 건 아니다.</p>
<p>기준은 간단하다. <strong><code>nuxt.config.ts</code>에서 읽는 값은 빌드 타임이다.</strong></p>
<pre><code class="language-ts">// nuxt.config.ts 는 &quot;빌드할 때 실행되는 파일&quot;이다
export default defineNuxtConfig({
  site: { url: process.env.NUXT_PUBLIC_SITE_URL },  // ← 여기서 읽으면 굳는다
  runtimeConfig: {
    public: {
      gtmId: &#39;&#39;,   // ← 이건 런타임에 NUXT_PUBLIC_GTM_ID 로 덮인다
    },
  },
})</code></pre>
<p><code>site.url</code> 같은 값은 빌드할 때 평가되어 설정에 박히고, 그 설정으로 canonical·hreflang·
sitemap·og:url이 만들어진다. 컨테이너 환경변수를 아무리 고쳐도 안 바뀐다.</p>
<p>Docker를 쓴다면 이 둘도 같은 축에 있다.</p>
<ul>
<li><code>--build-arg</code>: 이미지를 <strong>만들 때</strong> 쓰는 값. 이미지에 굳음</li>
<li>컨테이너 환경변수: 컨테이너를 <strong>띄울 때</strong> 주는 값. 재시작으로 바뀜</li>
</ul>
<h2 id="4-populate-요청하지-않으면-안-준다">4. populate: 요청하지 않으면 안 준다</h2>
<p>Strapi를 처음 만지면 거의 반드시 겪는다. API를 호출했는데 이미지가 <code>null</code>로 온다.</p>
<p>Strapi는 <strong>연결된 데이터를 기본적으로 주지 않는다.</strong> 명시해야 준다.</p>
<pre><code>/api/news-page                          → heroImage 없음
/api/news-page?populate=heroImage       → 나온다</code></pre><p>여기까지는 문서에 있는데, 실무에서 한 번 더 막히는 지점이 있다.
<strong>컴포넌트 안에 들어 있는 필드는 한 단계 populate로 안 나온다.</strong></p>
<pre><code>/api/page?populate=*                        → hero 는 나오지만 hero 안의 image 는 없음
/api/page?populate[hero][populate]=*        → 이제 나온다</code></pre><p>나도 이걸로 한 번 틀렸다. 스키마 최상위만 훑어보고 &quot;이 페이지는 이미지 필드가 없다&quot;고
판단했는데, 실제로는 컴포넌트 안에 8개가 들어 있었다.</p>
<h2 id="5-멱등idempotent-여러-번-해도-같은-결과">5. 멱등(idempotent): 여러 번 해도 같은 결과</h2>
<p>수학 용어처럼 생겼지만 실무 개념이다. <strong>같은 작업을 몇 번 실행해도 결과가 같은</strong> 성질이다.</p>
<p>CMS 초기 데이터를 넣는 코드가 서버 시작마다 실행된다고 하자.
멱등하지 않으면 재시작할 때마다 데이터가 중복으로 쌓인다.</p>
<pre><code class="language-ts">// 멱등하지 않음 — 뜰 때마다 늘어난다
await db.create({ title: &#39;회사 소개&#39; })

// 멱등함 — 이미 있으면 건너뛴다
const exists = await db.findFirst({ title: &#39;회사 소개&#39; })
if (!exists) await db.create({ title: &#39;회사 소개&#39; })</code></pre>
<p>이 성질을 알아야 이해되는 현상이 있다. <strong>&quot;관리자 화면에서 지운 데이터가 재시작하니 되살아난다.&quot;</strong>
멱등 시드가 &quot;없으면 만든다&quot;로 동작하기 때문이다. 버그가 아니라 설계다.</p>
<h2 id="6-폴백fallback-cms-값이-없으면-코드-값">6. 폴백(fallback): CMS 값이 없으면 코드 값</h2>
<p>CMS로 관리하는 이미지나 문구가 있을 때, CMS가 비어 있거나 죽으면 화면이 깨진다.
그래서 코드에 기본값을 남겨둔다.</p>
<pre><code class="language-ts">const bgUrl = computed(() =&gt; getStrapiImageUrl(props.bg) || &#39;/images/hero.jpg&#39;)
//                           CMS 값 우선              없으면 코드 경로</code></pre>
<p>덕분에 CMS가 죽어도 사이트는 뜬다. 대신 이런 상황이 생긴다.</p>
<blockquote>
<p>&quot;관리자에서 이미지를 바꿨는데 화면이 그대로예요&quot;
→ 폴백을 보고 있을 가능성이 높다. CMS 값이 실제로 저장·<strong>발행</strong>됐는지 확인해야 한다.</p>
</blockquote>
<p>Strapi는 저장(Save)과 발행(Publish)이 별개다. 저장만 하면 API로 안 나간다.</p>
<h2 id="7-이미지--컨테이너--태그">7. 이미지 / 컨테이너 / 태그</h2>
<p>Docker 입문에서 제일 먼저 나오는데도 오래 헷갈린다.</p>
<ul>
<li><strong>이미지</strong>: 설계도. 변하지 않는다</li>
<li><strong>컨테이너</strong>: 그 설계도로 실제 실행한 것</li>
<li><strong>태그</strong>: 이미지에 붙이는 이름표. <code>myapp:latest</code>의 <code>latest</code></li>
</ul>
<p>빵틀과 빵으로 생각하면 된다. 그리고 여기서 실무 함정이 나온다.</p>
<pre><code class="language-bash">docker load -i app.tar        # 이미지(설계도)만 교체됨
docker compose restart web    # 기존 컨테이너 재시작 → 옛 이미지 그대로
docker compose up -d web      # 컨테이너 재생성 → 새 이미지 반영</code></pre>
<p><code>restart</code>는 명령이 성공하고 컨테이너도 새로 뜬 것처럼 보이는데 이미지는 안 바뀐다.</p>
<p>그리고 <code>:latest</code>로 배포한다면, <code>docker load</code>가 같은 태그를 덮어쓰므로
<strong>로드 전에 현재 이미지에 백업 태그를 달아두지 않으면 되돌릴 대상이 사라진다.</strong></p>
<h2 id="8-i18n-왜-i18n인가">8. i18n: 왜 i18n인가</h2>
<p>&quot;internationalization&quot;의 i와 n 사이에 글자가 18개라서 i18n이다.
같은 방식으로 a11y는 accessibility(접근성), l10n은 localization(현지화)이다.</p>
<p>다국어 URL 전략에도 이름이 있다. Nuxt i18n의 <code>prefix_except_default</code>는
<strong>기본 언어만 접두사를 안 붙이는</strong> 방식이다.</p>
<pre><code>/about        ← 한국어(기본)
/en/about     ← 영어
/ja/about     ← 일본어</code></pre><hr>
<h2 id="빠른-참조">빠른 참조</h2>
<table>
<thead>
<tr>
<th>용어</th>
<th>한 줄 정의</th>
</tr>
</thead>
<tbody><tr>
<td>헤드리스 CMS</td>
<td>콘텐츠만 관리하고 화면은 안 그리는 CMS</td>
</tr>
<tr>
<td>컬렉션 타입</td>
<td>여러 개 만드는 콘텐츠 (뉴스 기사)</td>
</tr>
<tr>
<td>단일 타입</td>
<td>하나만 있는 콘텐츠 (홈 설정)</td>
</tr>
<tr>
<td>documentId</td>
<td>Strapi 5의 콘텐츠 식별자. 다국어판·초안·발행본이 공유</td>
</tr>
<tr>
<td>웹훅</td>
<td>사건이 생기면 상대 서버 주소를 자동 호출해 알림</td>
</tr>
<tr>
<td>revalidate</td>
<td>캐시를 무효화해 다음 요청 때 새로 만들게 함</td>
</tr>
<tr>
<td>SSE</td>
<td>서버가 브라우저로 일방향 메시지를 계속 보내는 기술</td>
</tr>
<tr>
<td>하이드레이션</td>
<td>서버가 만든 HTML에 JS를 붙여 동작하게 하는 과정</td>
</tr>
<tr>
<td>canonical</td>
<td>&quot;이게 대표 주소다&quot;라고 알리는 표시</td>
</tr>
<tr>
<td>hreflang</td>
<td>&quot;다른 언어 버전은 여기 있다&quot;고 알리는 표시</td>
</tr>
<tr>
<td>JSON-LD</td>
<td>페이지 내용을 기계가 읽을 형식으로 적어둔 것</td>
</tr>
<tr>
<td>amd64 / arm64</td>
<td>CPU 아키텍처. 서로 호환 안 됨</td>
</tr>
<tr>
<td>리버스 프록시</td>
<td>외부 요청을 받아 내부 서버로 넘기는 중계 (nginx)</td>
</tr>
<tr>
<td>CSP</td>
<td>어떤 출처의 자원을 불러올 수 있는지 정하는 보안 설정</td>
</tr>
</tbody></table>
<hr>
<h2 id="정리하며">정리하며</h2>
<p>용어집을 쓰면서 느낀 건, 초보자가 막히는 지점이 <strong>&quot;이 단어를 모른다&quot;</strong>보다
<strong>&quot;안다고 생각했는데 여기서는 다른 뜻이다&quot;</strong>인 경우가 많다는 것이다.</p>
<p>컴포넌트가 그랬고, 환경변수가 그랬고, 캐시가 그랬다.
그래서 인수인계 문서를 쓸 때는 용어 정의보다 <strong>&quot;이 프로젝트에서 이 단어는 이런 뜻이다&quot;</strong>를
적는 게 더 도움이 됐다.</p>
<p>혹시 비슷한 스택으로 인수인계를 준비한다면, 용어집을 별도 문서로 빼는 걸 추천한다.
본문에서 매번 괄호로 설명하면 글이 늘어지고, 안 하면 읽히지 않는다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[배포했는데 왜 그대로죠? 조용히 실패하는 것들에 대하여]]></title>
            <link>https://velog.io/@sojin-zip/%EB%B0%B0%ED%8F%AC%ED%96%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EA%B7%B8%EB%8C%80%EB%A1%9C%EC%A3%A0-%EC%A1%B0%EC%9A%A9%ED%9E%88-%EC%8B%A4%ED%8C%A8%ED%95%98%EB%8A%94-%EA%B2%83%EB%93%A4%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</link>
            <guid>https://velog.io/@sojin-zip/%EB%B0%B0%ED%8F%AC%ED%96%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EA%B7%B8%EB%8C%80%EB%A1%9C%EC%A3%A0-%EC%A1%B0%EC%9A%A9%ED%9E%88-%EC%8B%A4%ED%8C%A8%ED%95%98%EB%8A%94-%EA%B2%83%EB%93%A4%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC</guid>
            <pubDate>Fri, 11 Sep 2026 03:38:24 GMT</pubDate>
            <description><![CDATA[<h1 id="배포했는데-왜-그대로죠-조용히-실패하는-것들에-대하여">배포했는데 왜 그대로죠? 조용히 실패하는 것들에 대하여</h1>
<p>Nuxt + Strapi로 회사 홈페이지를 개편하면서 겪은 일들을 정리했다.
공통점이 하나 있다. <strong>에러를 내지 않고 실패한다</strong>는 것이다.</p>
<p>빌드는 성공하고, 배포 명령은 0을 반환하고, 검증 스크립트는 통과한다.
그런데 결과가 틀렸다. 이런 실패가 제일 오래 간다. 아무도 모르니까.</p>
<hr>
<h2 id="1-prerender를-버리고-swr로-갔다">1. prerender를 버리고 SWR로 갔다</h2>
<p>Nuxt의 <code>routeRules</code>는 경로별로 렌더링 전략을 다르게 줄 수 있다. 자주 쓰는 선택지는 넷이다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>언제 렌더</th>
<th>캐시 위치</th>
</tr>
</thead>
<tbody><tr>
<td><code>prerender</code></td>
<td><strong>빌드 시점</strong>에 HTML 생성</td>
<td>정적 파일</td>
</tr>
<tr>
<td><code>ssr</code></td>
<td>요청마다 서버에서 렌더</td>
<td>없음</td>
</tr>
<tr>
<td><code>isr</code></td>
<td>첫 요청에 렌더 후 N초간 재사용</td>
<td>캐시 스토리지</td>
</tr>
<tr>
<td><code>swr</code></td>
<td>캐시를 먼저 주고 뒤에서 갱신</td>
<td>캐시 스토리지</td>
</tr>
</tbody></table>
<p>처음엔 &quot;안 바뀌는 페이지는 prerender&quot; 라는 교과서대로 짰다.
개인정보처리방침 같은 페이지가 대상이었다.</p>
<p>그런데 도커 빌드가 깨졌다.</p>
<pre><code>[nitro] Error: connect ECONNREFUSED 127.0.0.1:6379</code></pre><p>원인은 이랬다. prerender는 <strong>빌드 컨테이너 안에서 실제로 페이지를 렌더</strong>한다.
그 페이지가 캐시 스토리지(Redis)를 쓰도록 설정돼 있으면, 빌드 시점에 Redis에 붙으려 한다.
빌드 컨테이너에는 Redis가 없다.</p>
<p><code>swr</code>로 바꾸니 해결됐다. 렌더 결과가 캐시된다는 점은 같은데, 렌더 시점이 빌드가 아니라
첫 요청이라 빌드가 인프라에 의존하지 않는다.</p>
<pre><code class="language-ts">// nuxt.config.ts
const prod = process.env.NODE_ENV === &#39;production&#39;
// dev에서는 캐시를 끈다. 안 그러면 코드를 고쳐도 옛 화면이 나온다.
const swr3600 = prod ? { swr: 3600 } : {}

export default defineNuxtConfig({
  routeRules: {
    &#39;/&#39;: swr3600,
    &#39;/about/**&#39;: swr3600,
    &#39;/contact&#39;: { ssr: true },   // 폼 제출이라 캐시하면 안 된다
  },
})</code></pre>
<p>정리하면 이렇다. <strong>prerender는 빌드 환경에 런타임 의존성을 끌고 들어온다.</strong>
CMS API를 호출하는 페이지라면 빌드 시점에 CMS도 떠 있어야 한다.
폐쇄망이거나 CI가 격리돼 있으면 이게 발목을 잡는다.</p>
<hr>
<h2 id="2-빌드-시점에-굳어버리는-값이-있다">2. 빌드 시점에 굳어버리는 값이 있다</h2>
<p>이게 제일 크게 데인 부분이다. 어느 날 검색 결과에서 사이트가 사라졌다.</p>
<p>범인은 이 한 줄이었다.</p>
<pre><code class="language-dockerfile">ARG NUXT_PUBLIC_SITE_URL=http://10.0.x.x:3000   # 기본값이 내부 IP</code></pre>
<p>테스트 서버로 보내려고 빌드 인자를 생략했다. 그러면 기본값이 들어간다.
문제는 이 값이 <strong>런타임에 못 고친다</strong>는 것이다.</p>
<p>Nuxt의 <code>runtimeConfig.public</code>은 보통 <code>NUXT_PUBLIC_*</code> 환경변수로 런타임에 덮어쓸 수 있다.
그래서 다들 런타임 값이라고 생각한다. 그런데 SEO 모듈들은 다르다.</p>
<pre><code class="language-ts">// nuxt.config.ts
export default defineNuxtConfig({
  site: { url: process.env.NUXT_PUBLIC_SITE_URL },   // ← 빌드 시점에 평가
  i18n: { baseUrl: process.env.NUXT_PUBLIC_SITE_URL },
})</code></pre>
<p><code>nuxt.config.ts</code>는 <strong>빌드할 때 실행되는 파일</strong>이다. 여기서 읽은 값은 설정에 박힌다.
그 설정을 기반으로 canonical, hreflang, og:url, JSON-LD, robots.txt, sitemap이 만들어진다.</p>
<p>결과적으로 사이트맵 전체가 내부 IP를 가리켰다. 크롤러 입장에서는 접근 불가능한 주소다.</p>
<h3 id="어떻게-구분하나">어떻게 구분하나</h3>
<p>간단한 기준이 있다. <strong><code>nuxt.config.ts</code>에서 읽는 값은 빌드 타임, <code>useRuntimeConfig()</code>로 읽는 값은 런타임이다.</strong></p>
<table>
<thead>
<tr>
<th>값</th>
<th>시점</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>site.url</code>, <code>i18n.baseUrl</code></td>
<td>빌드</td>
<td>nuxt.config에서 평가</td>
</tr>
<tr>
<td>nitro 캐시 스토리지 주소</td>
<td>빌드</td>
<td>위와 같음</td>
</tr>
<tr>
<td>GTM/GA 측정 ID</td>
<td>런타임</td>
<td>runtimeConfig로만 읽음</td>
</tr>
</tbody></table>
<h3 id="검증하는-법">검증하는 법</h3>
<p>빌드 산출물을 직접 뒤지는 게 확실하다.</p>
<pre><code class="language-bash"># 이미지 안에 내부 IP가 섞였는지
docker run --rm --entrypoint sh myapp:latest \
  -c &#39;grep -rlE &quot;10\.|192\.168\.&quot; .output | wc -l&#39;    # 0이어야 한다</code></pre>
<p>한 가지 더. 빌드 인자를 넣었다고 끝이 아니다.
Dockerfile의 멀티스테이지에서 <code>ARG</code>가 <strong>빌드 스테이지에만</strong> 있고 런타임 스테이지에는 없으면,
런타임에 필요한 값은 별도로 주입해야 한다. 우리는 GTM ID가 그랬다.
빌드 산출물에는 없고 컨테이너 환경변수로 들어간다.</p>
<hr>
<h2 id="3-docker-load는-배포가-아니다">3. <code>docker load</code>는 배포가 아니다</h2>
<p>폐쇄망이라 레지스트리를 못 쓴다. 로컬에서 빌드해서 tar로 옮긴다.</p>
<pre><code class="language-bash">docker save -o app.tar myapp:latest
scp app.tar server:~/
ssh server &#39;docker load -i app.tar&#39;</code></pre>
<p>여기까지 하고 &quot;배포 끝&quot;이라고 생각했다. 사이트는 그대로였다.</p>
<p><code>docker load</code>는 <strong>이미지 저장소만</strong> 갱신한다. 실행 중인 컨테이너는 예전 이미지 ID를 붙잡고 있다.
컨테이너를 재생성해야 한다.</p>
<pre><code class="language-bash">docker compose up -d web    # 재생성 (O)
docker compose restart web  # 같은 컨테이너 재시작 (X, 옛 이미지 그대로)</code></pre>
<p><code>restart</code>가 특히 함정이다. 명령은 성공하고 컨테이너는 새로 뜬 것처럼 보이는데
이미지는 바뀌지 않는다.</p>
<h3 id="latest로-배포한다면"><code>:latest</code>로 배포한다면</h3>
<p><code>docker load</code>는 같은 태그를 덮어쓴다. <strong>로드하기 전에 현재 이미지를 태그로 남겨두지 않으면
롤백할 대상이 사라진다.</strong></p>
<pre><code class="language-bash">T=$(date +%Y%m%d-%H%M%S)
docker tag myapp:latest myapp:bak-$T   # 로드 전에!
docker load -i app.tar</code></pre>
<hr>
<h2 id="4-컨테이너를-바꿨는데도-옛-화면이-나온다">4. 컨테이너를 바꿨는데도 옛 화면이 나온다</h2>
<p>3번을 해결하고도 화면이 안 바뀌는 경우가 있다. 1번에서 선택한 <code>swr</code> 때문이다.</p>
<p>SWR 캐시는 <strong>Redis에 저장된다.</strong> 컨테이너를 갈아끼워도 Redis는 그대로 살아 있으니
이전 이미지가 만들어둔 HTML이 계속 나간다.</p>
<p>그래서 배포 후 캐시를 비워야 한다. Nuxt에서는 보통 이런 엔드포인트를 직접 만든다.</p>
<pre><code class="language-ts">// server/api/revalidate.post.ts
export default defineEventHandler(async (event) =&gt; {
  const secret = getHeader(event, &#39;x-revalidate-secret&#39;)
  if (secret !== useRuntimeConfig().revalidateSecret) {
    throw createError({ statusCode: 401 })
  }
  // 캐시 스토리지 비우기
  await useStorage(&#39;cache&#39;).clear()
  return { ok: true }
})</code></pre>
<p>CMS의 발행 웹훅도 같은 엔드포인트를 호출하게 해두면 콘텐츠 갱신이 즉시 반영된다.</p>
<p>개발 환경에서 &quot;코드를 고쳤는데 옛날 화면이 나온다&quot;거나 hydration mismatch가 뜨는 것도
같은 원인인 경우가 많다. 그래서 위 코드처럼 dev에서는 캐시를 아예 끄는 편이 낫다.</p>
<hr>
<h2 id="5-검증이-통과를-위장할-때">5. 검증이 통과를 위장할 때</h2>
<p>2번에서 만든 검증 스크립트는 이랬다.</p>
<pre><code class="language-bash">docker run -d --name check -p 3398:3000 myapp:latest
sleep 15
curl -s http://127.0.0.1:3398/ | grep -c &#39;10\.\|192\.168\.&#39;   # 0이면 통과</code></pre>
<p>몇 달간 잘 돌았다. 그런데 어느 날 뜯어보니 <strong>이 스크립트는 아무것도 검사하지 않고 있었다.</strong></p>
<p>컨테이너를 단독으로 띄웠으니 Redis가 없다. <code>swr</code> 라우트는 캐시 스토리지에 붙지 못해 500을 뱉는다.
<code>curl</code>은 빈 응답을 받는다. 빈 문자열에서 <code>grep -c</code>는 0을 반환한다.</p>
<p><strong>0은 &quot;통과&quot;의 값이기도 하고 &quot;검사 대상이 없었다&quot;의 값이기도 하다.</strong>
두 경우가 구분되지 않는다.</p>
<p>고친 버전은 이렇다.</p>
<pre><code class="language-bash">docker network create check-net
docker run -d --name check-redis --network check-net --network-alias redis redis:7-alpine
docker run -d --name check --network check-net -p 3398:3000 myapp:latest
sleep 25

# 먼저 검증이 성립하는지 확인한다
curl -s -o /dev/null -w &#39;status=%{http_code}\n&#39; http://127.0.0.1:3398/   # 200이어야 함
curl -s http://127.0.0.1:3398/ | grep -c &#39;10\.\|192\.168\.&#39;</code></pre>
<p>여기서 얻은 교훈이 이 글에서 제일 하고 싶은 말이다.</p>
<blockquote>
<p>검증 스크립트는 <strong>&quot;검증이 성립했는지&quot;를 먼저 검증해야 한다.</strong></p>
</blockquote>
<p><code>grep -c</code>가 0이라는 사실만으로는 아무것도 알 수 없다. 응답이 200이었다는 전제가 있어야
비로소 0이 &quot;깨끗하다&quot;는 뜻이 된다. 부정 조건(무언가가 없음)을 검사할 때는
항상 이 함정이 있다. 테스트가 0건을 통과했다는 것과 테스트가 실행되지 않았다는 것은 다르다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>네 가지 사례의 공통점은 <strong>실패가 에러로 드러나지 않았다</strong>는 것이다.</p>
<table>
<thead>
<tr>
<th>사례</th>
<th>겉보기</th>
<th>실제</th>
</tr>
</thead>
<tbody><tr>
<td>prerender + 캐시 스토리지</td>
<td>빌드 실패</td>
<td>그나마 이건 티가 났다</td>
</tr>
<tr>
<td>빌드 타임 환경변수</td>
<td>빌드 성공</td>
<td>SEO 메타가 내부 주소로 굳음</td>
</tr>
<tr>
<td><code>docker load</code> / <code>restart</code></td>
<td>명령 성공</td>
<td>옛 이미지 그대로</td>
</tr>
<tr>
<td>SWR 캐시</td>
<td>배포 성공</td>
<td>옛 HTML 그대로</td>
</tr>
<tr>
<td>검증 스크립트</td>
<td>통과</td>
<td>아무것도 검사 안 함</td>
</tr>
</tbody></table>
<p>그래서 지금은 배포 절차마다 <strong>&quot;이 확인이 실제로 유효한가&quot;</strong>를 한 줄씩 덧붙여 둔다.
상태 코드를 먼저 찍고, 이미지 ID가 바뀌었는지 비교하고, 캐시를 비운 뒤에 확인한다.</p>
<p>조용히 실패하는 것들은 소리 내게 만들어야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude Code 스킬로 Vue 3 리팩토링 자동화하기]]></title>
            <link>https://velog.io/@sojin-zip/Claude-Code-%EC%8A%A4%ED%82%AC%EB%A1%9C-Vue-3-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@sojin-zip/Claude-Code-%EC%8A%A4%ED%82%AC%EB%A1%9C-Vue-3-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-%EC%9E%90%EB%8F%99%ED%99%94%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 08 Sep 2026 05:48:34 GMT</pubDate>
            <description><![CDATA[<h1 id="claude-code-스킬로-vue-3-리팩토링-자동화하기--vue3-optimizer-개발기">Claude Code 스킬로 Vue 3 리팩토링 자동화하기 — vue3-optimizer 개발기</h1>
<h2 id="왜-만들었나">왜 만들었나</h2>
<p>Vue 3 + TypeScript로 작업하다 보면 리팩토링이 필요한 지점은 매번 비슷합니다.</p>
<ul>
<li>Options API로 짜여진 오래된 컴포넌트</li>
<li>수동으로 짠 debounce, 이벤트 리스너, localStorage 동기화 로직 — VueUse로 바꾸면 훨씬 짧아지는 것들</li>
<li>prop drilling으로 얽혀있는 전역 상태 — Pinia 스토어 하나면 정리되는 구조</li>
<li>렌더링 최적화 여지가 있는데 놓치고 지나간 부분들</li>
</ul>
<p>문제는 이걸 리뷰할 때마다 머릿속으로 체크리스트를 새로 떠올려야 한다는 점이었습니다. 그래서 아예 Claude Code 스킬로 만들어서, 컴포넌트 하나를 던지면 정해진 기준으로 훑고 개선점을 제안하도록 만들었습니다.</p>
<h2 id="무엇을-하는-스킬인가">무엇을 하는 스킬인가</h2>
<p><code>vue3-optimizer</code>는 Vue 3 SFC를 아래 기준으로 분석합니다.</p>
<ol>
<li>Composition API 전환 (<code>&lt;script setup lang=&quot;ts&quot;&gt;</code>, 타입 명시된 <code>defineProps</code>/<code>defineEmits</code>)</li>
<li>VueUse 컴포저블 채택 여부</li>
<li>Pinia 통합 (prop drilling → 스토어, <code>storeToRefs</code> 사용 여부)</li>
<li>렌더링 최적화 (<code>shallowRef</code>, <code>v-memo</code>, <code>defineAsyncComponent</code> 등)</li>
<li>레이아웃 최적화 (CSS <code>contain</code>, <code>content-visibility</code>, 레이아웃 스래싱 방지)</li>
<li>번들/코드 스플리팅</li>
<li>대량 리스트 처리 (가상 스크롤)</li>
<li>watch/lifecycle 정밀도 (cleanup 누락으로 인한 메모리 누수 등)</li>
<li>무거운 연산 오프로딩 (Web Worker, debounce)</li>
<li>이미지/에셋 최적화</li>
<li>TypeScript 엄격성</li>
<li>컴파일러 힌트 (<code>v-once</code>, <code>Suspense</code> fallback)</li>
<li>접근성 (요청 시에만 별도로)</li>
</ol>
<p>13개 카테고리로 나눈 이유는, 성능 최적화 하나만 보다 보면 타입 안정성이나 접근성 같은 걸 놓치기 쉬워서입니다. 카테고리를 명시적으로 나눠두면 리뷰할 때 빠뜨리는 항목이 줄어듭니다.</p>
<h2 id="설계에서-가장-신경-쓴-부분-propose-then-apply">설계에서 가장 신경 쓴 부분: propose-then-apply</h2>
<p>이 스킬에서 가장 중요하게 잡은 원칙은 <strong>먼저 제안하고, 승인받은 다음에만 실제 파일을 고친다</strong>는 것이었습니다.</p>
<pre><code>1. 파일을 읽고 분석
2. Before/After 코드 스니펫 + 한 줄 근거로 제안 목록 제시
3. 사용자가 승인 (전체 또는 번호 지정)
4. 승인된 항목만 실제로 파일에 반영</code></pre><p>리팩토링 스킬을 처음 설계할 때는 &quot;분석 → 바로 적용&quot;이 더 빠르다고 생각했는데, 실제로 써보니 문제가 있었습니다. VueUse 컴포저블 교체나 Pinia 스토어 추출처럼 구조를 바꾸는 제안은, 프로젝트 컨벤션이나 팀 상황에 따라 &quot;맞다/아니다&quot;가 갈리는 경우가 많았습니다. 그래서 항상 diff부터 보여주고, 원하는 항목만 골라 적용할 수 있게 바꿨습니다.</p>
<pre><code class="language-typescript">// 나쁜 예 — 수동 debounce
const search = ref(&#39;&#39;)
let timer: ReturnType&lt;typeof setTimeout&gt;
watch(search, (val) =&gt; {
  clearTimeout(timer)
  timer = setTimeout(() =&gt; fetchResults(val), 300)
})

// 좋은 예 — VueUse 컴포저블 사용
const search = ref(&#39;&#39;)
const debouncedSearch = refDebounced(search, 300)
watch(debouncedSearch, (val) =&gt; fetchResults(val))</code></pre>
<h2 id="만들면서-신경-쓴-디테일">만들면서 신경 쓴 디테일</h2>
<p><strong>존재하지 않는 VueUse 함수를 지어내지 않기.</strong> LLM이 그럴듯한 이름의 컴포저블을 생성해내는 경우가 있어서, 실제 존재하는 함수 목록(<code>useDebounceFn</code>, <code>useEventListener</code>, <code>useIntersectionObserver</code> 등)을 기준으로 교차 확인하도록 명시했습니다. 확실하지 않으면 추측 대신 모른다고 말하게 했습니다.</p>
<p><strong>프로젝트의 기존 컨벤션을 존중하기.</strong> 리팩토링이지 재작성이 아니므로, 기존 네이밍이나 파일 구조는 그대로 유지하도록 했습니다.</p>
<p><strong>Vue 버전 확인 후 문법 제안.</strong> <code>package.json</code>의 실제 Vue 버전을 먼저 확인하고, 3.5 이상에서만 쓸 수 있는 문법(<code>useTemplateRef</code>, <code>defineModel</code>, reactive props destructure)은 버전이 맞을 때만 제안하도록 했습니다.</p>
<h2 id="배운-점">배운 점</h2>
<p>스킬을 만들면서 느낀 건, &quot;무엇을 자동화할지&quot;보다 &quot;어디까지 자동으로 하고 어디서부터 사람이 판단해야 하는지&quot;를 정하는 게 더 어려운 작업이라는 점이었습니다. 특히 구조를 바꾸는 리팩토링(스토어 추출, 컴포저블 분리)은 자동 적용보다 제안 단계에서 멈추는 게 오히려 신뢰도를 높였습니다.</p>
<h2 id="다음-계획">다음 계획</h2>
<ul>
<li>접근성 카테고리를 별도 옵션으로 분리해서 필요할 때만 켤 수 있게 개선</li>
<li>체크리스트 항목별로 실제 프로젝트에 적용해본 사례를 레퍼런스로 축적</li>
</ul>
<hr>
<p><em>이 스킬은 Claude Code의 skill 기능으로 만들었고, <code>SKILL.md</code> 하나에 워크플로우와 체크리스트를 정의하는 방식입니다. Claude Code의 스킬 시스템에 대해 궁금하신 분들은 댓글로 물어봐 주세요.</em></p>
]]></description>
        </item>
    </channel>
</rss>