<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>도니 House</title>
    <link>https://gse06044.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 3 Aug 2026 06:59:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>도니도니도니도니</managingEditor>
    <item>
      <title>Next.js 하이드레이션 에러 우아하게 처리하기</title>
      <link>https://gse06044.tistory.com/87</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next.js가 어떻게 돌아가는지 부터 파악해보기&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 프로젝트를 진행하면서 hydration에러가 발생했습니다.React error 418이 &lt;b&gt;Hydration&lt;/b&gt;에러가 발생했다라는 뜻이라고 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-22 14.25.16.png&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYzuLz/dJMcag0cXFd/jkG6wpVC114Y0XCW50u1tK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYzuLz/dJMcag0cXFd/jkG6wpVC114Y0XCW50u1tK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYzuLz/dJMcag0cXFd/jkG6wpVC114Y0XCW50u1tK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYzuLz%2FdJMcag0cXFd%2FjkG6wpVC114Y0XCW50u1tK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1062&quot; height=&quot;352&quot; data-filename=&quot;스크린샷 2026-06-22 14.25.16.png&quot; data-origin-width=&quot;1062&quot; data-origin-height=&quot;352&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 근데 왜 발생했을까요&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dzY9sa/dJMcajbwrQJ/KWni6O7jhbrsGV4lOpEfWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dzY9sa/dJMcajbwrQJ/KWni6O7jhbrsGV4lOpEfWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dzY9sa/dJMcajbwrQJ/KWni6O7jhbrsGV4lOpEfWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdzY9sa%2FdJMcajbwrQJ%2FKWni6O7jhbrsGV4lOpEfWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;360&quot; data-origin-width=&quot;360&quot; data-origin-height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 우선 Hydration이 뭔지 부터 저는 바로잡고자 다시한번 공부를 했습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Hydration?&amp;nbsp;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-22 14.27.44.png&quot; data-origin-width=&quot;2290&quot; data-origin-height=&quot;1184&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CMN5F/dJMcaglDdXA/fqINr2xebtwlv29rOWBJzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CMN5F/dJMcaglDdXA/fqINr2xebtwlv29rOWBJzK/img.png&quot; data-alt=&quot;이정환님의 인프런 강의를 참고한 이미지입니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CMN5F/dJMcaglDdXA/fqINr2xebtwlv29rOWBJzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCMN5F%2FdJMcaglDdXA%2FfqINr2xebtwlv29rOWBJzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2290&quot; height=&quot;1184&quot; data-filename=&quot;스크린샷 2026-06-22 14.27.44.png&quot; data-origin-width=&quot;2290&quot; data-origin-height=&quot;1184&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이정환님의 인프런 강의를 참고한 이미지입니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이번 Next.js에서 어떻게 렌더링을 하는지에 대해서 봅시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유저가 브라우저에서 접속 요청을 하면 &lt;b&gt;서버에서 사전 렌더링&lt;/b&gt;이 시작됩니다. 이때 Next.js는 서버 컴포넌트(SC)와 클라이언트 컴포넌트(CC)를 구분해서 처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 서버 컴포넌트는 서버에서 실행된 뒤 &lt;b&gt;RSC Payload&lt;/b&gt; 형태로 직렬화됩니다. 클라이언트 컴포넌트는 &lt;b&gt;서버에서 HTML로 렌더링&lt;/b&gt;됩니다. 이 둘을 합쳐 완성된 HTML을 브라우저에 전달하고 유저는 즉시 화면을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;여기서 주의할 점&lt;/b&gt; &amp;mdash; SC는 RSC Payload로 클라이언트에 전달될 뿐 추가 작업이 없습니다. 반면 CC는 JS Bundle을 통해 &lt;b&gt;Hydration&lt;/b&gt; 과정이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- JS Bundle에는 SC가 제외된 CC만 포함되어 있으며 Hydration은 서버에서 받은 HTML에 이벤트 핸들러를 연결하는 과정입니다. 이 과정이 완료되어야 버튼 클릭 상태 변경 같은 상호작용이 가능해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 한번 정리하면 아래 그림으로 정리를 할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유저 접속 요청 -&amp;gt; SC, CC구분 해서 따로 실행 -&amp;gt; 실행된 결과 HTML을 전달 -&amp;gt; 유저가 화면 볼 수 있음 -&amp;gt; JS Bundle(CC만) -&amp;gt; 하이드레이션&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1256&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgedo0/dJMcaaThsKu/KRWSXzInKjeAIVRY0ewzUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgedo0/dJMcaaThsKu/KRWSXzInKjeAIVRY0ewzUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgedo0/dJMcaaThsKu/KRWSXzInKjeAIVRY0ewzUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbgedo0%2FdJMcaaThsKu%2FKRWSXzInKjeAIVRY0ewzUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;487&quot; height=&quot;416&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1256&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다시 프로젝트로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이제 흐름을 이해했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 근데 왜 프로젝트에 에러가 발생할까요?? 코드를 봅시다&lt;/p&gt;
&lt;pre id=&quot;code_1782106728429&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  const getGenerationStatus = sessionStorage.getItem(
    WRITE_GENERATION_STATUS_KEY
  );

  const helperText =
    getGenerationStatus === &quot;generating&quot;
      ? &quot;글 생성 중입니다. 버튼을 눌러 생성 화면으로 이동하세요.&quot;
      : getGenerationStatus === &quot;done&quot;
      ? &quot;생성이 완료되었습니다. 버튼을 눌러 결과를 확인하세요.&quot;
      : getGenerationStatus === &quot;error&quot; &amp;amp;&amp;amp; Boolean(getGenerationStatus)
      ? &quot;생성 중 오류가 발생했습니다. 버튼을 눌러 다시 확인하세요.&quot;
      : `저장 가능: 포스트 최대 ${MAX_STORED_POSTS}개까지. 생성 후 자동으로 저장됩니다.`;

  const buttonLabel =
    getGenerationStatus === &quot;generating&quot;
      ? &quot;생성 화면으로 이동&quot;
      : getGenerationStatus === &quot;done&quot;
      ? &quot;생성 결과 보기&quot;
      : getGenerationStatus === &quot;error&quot; &amp;amp;&amp;amp; Boolean(getGenerationStatus)
      ? &quot;생성 화면으로 이동&quot;
      : &quot;AI 글 생성하기&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 코드를 보면 세션 스토리지를 사용합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 여기서 문제가 발생하는데 세션 스토리지는 브라우저에만 존재합니다. 이게 왜 문제가 되는지 살펴보면&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;서버에서 렌더링할 때 &amp;rarr; sessionStorage 없음 &amp;rarr; getGenerationStatus = null&lt;br /&gt;&lt;br /&gt;브라우저에서 Hydration할 때 &amp;rarr; sessionStorage 있음 &amp;rarr; getGenerationStatus = &quot;generating&quot; (등)&lt;br /&gt;&lt;br /&gt;서버 HTML이랑 클라이언트 HTML이 달라서 &amp;rarr; &lt;b&gt;  Hydration 에러&lt;/b&gt;&lt;/blockquote&gt;
&lt;div&gt;
&lt;div data-test-render-count=&quot;1&quot;&gt;
&lt;div&gt;
&lt;div data-is-streaming=&quot;false&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 서버 렌더링 시 sessionStorage가 없어 null로 렌더링되는데 Hydration 과정에서 클라이언트가 sessionStorage 값을 읽어 다른 값을 렌더링하기 때문에 불일치 에러가 발생합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Next.js 공식문서에도 다음과 같이 나와있습니다. &lt;a href=&quot;https://nextjs.org/docs/messages/react-hydration-error#common-causes&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nextjs.org/docs/messages/react-hydration-error#common-causes&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1782107029891&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Text content does not match server-rendered HTML&quot; data-og-description=&quot;Next.js by Vercel is the full-stack React framework for the web.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/messages/react-hydration-error#common-causes&quot; data-og-url=&quot;https://nextjs.org/docs/messages/react-hydration-error&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/messages/react-hydration-error#common-causes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/messages/react-hydration-error#common-causes&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Text content does not match server-rendered HTML&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Next.js by Vercel is the full-stack React framework for the web.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nextjs.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2168&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D3huZ/dJMcaiRiIxx/kFkAGzGmq7eiRxy9y2GCK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D3huZ/dJMcaiRiIxx/kFkAGzGmq7eiRxy9y2GCK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D3huZ/dJMcaiRiIxx/kFkAGzGmq7eiRxy9y2GCK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD3huZ%2FdJMcaiRiIxx%2FkFkAGzGmq7eiRxy9y2GCK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2168&quot; height=&quot;1414&quot; data-origin-width=&quot;2168&quot; data-origin-height=&quot;1414&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Hydration 오류가 발생할 경우중 &lt;b&gt;3번 케이스&lt;/b&gt;가 제가 해당하는 케이스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 해결방법도 아래에 나와있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2248&quot; data-origin-height=&quot;1900&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmgeYG/dJMcafmNd2K/Ql53Jky7GMqQ50LCYz3BFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmgeYG/dJMcafmNd2K/Ql53Jky7GMqQ50LCYz3BFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmgeYG/dJMcafmNd2K/Ql53Jky7GMqQ50LCYz3BFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmgeYG%2FdJMcafmNd2K%2FQl53Jky7GMqQ50LCYz3BFK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2248&quot; height=&quot;1900&quot; data-origin-width=&quot;2248&quot; data-origin-height=&quot;1900&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2246&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/O8ZbE/dJMcafmNd3w/M7rwBkY7jc7t1gvOd9I1eK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/O8ZbE/dJMcafmNd3w/M7rwBkY7jc7t1gvOd9I1eK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/O8ZbE/dJMcafmNd3w/M7rwBkY7jc7t1gvOd9I1eK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FO8ZbE%2FdJMcafmNd3w%2FM7rwBkY7jc7t1gvOd9I1eK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2246&quot; height=&quot;720&quot; data-origin-width=&quot;2246&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- useEffect가 가장 쉬워보이는데 함정이 있는데 초기설정 값이 나왔다가 다시 실제값이 넣어지는데 이로 인해 깜빡이는 것처럼 보이는 현상이 발생할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 코드로 예시를 보면&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 아래 코드를 보면 처음 마운트 이후 한번만 값을 읽기 때문에 SessionStorage 값을 변경해도 UI가 자동으로 안바뀝니다.&lt;/p&gt;
&lt;pre id=&quot;code_1782107649441&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [status, setStatus] = useState&amp;lt;string | null&amp;gt;(null);

useEffect(() =&amp;gt; {
  setStatus(sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY));
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div data-test-render-count=&quot;1&quot;&gt;
&lt;div data-is-streaming=&quot;false&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;다른 방법 뭐가 있을까?&amp;nbsp;&lt;/h4&gt;
&lt;div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot;&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;color: #23272f; text-align: start;&quot;&gt;useSyncExternalStore 도입&lt;/span&gt;&lt;/span&gt;&lt;/h4&gt;
&lt;div&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;color: #23272f; text-align: start;&quot;&gt;- 문서 자료를 찾던중 &lt;span style=&quot;color: #23272f; text-align: start;&quot;&gt;useSyncExternalStore라는 리액트 훅을 발견했습니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;color: #333333; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;span style=&quot;color: #23272f; text-align: start;&quot;&gt;&lt;span style=&quot;color: #23272f; text-align: start;&quot;&gt;- &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;useSyncExternalStore는 React 18에서 추가된 훅으로 &lt;/span&gt;&lt;b&gt;외부 스토어를 구독&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;하기 위한 공식 API입니다.&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;- &lt;span style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot;&gt;useSyncExternalStore의 도입 이유는 외부스토어를 구독하고 있어 실시간으로 반영이 가능하기 때문입니다.&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot;&gt;- &lt;span style=&quot;background-color: #ffffff; color: #23272f; text-align: start;&quot;&gt;useSyncExternalStore는&lt;/span&gt; 인자를 다음과 같이 3개를 받습니다.&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1782107837039&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const value = useSyncExternalStore(
  subscribe,          // 스토어 변경 감지 구독 함수
  getSnapshot,        // 클라이언트에서 현재 값 읽기
  getServerSnapshot   // 서버에서 현재 값 읽기 (SSR용)
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;subscribe&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스토어가 변경됐을 때 React에게 알려주는 함수로 React가 내부적으로 callback을 넘겨주고 변경이 생기면 그걸 호출하면 됩니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;() =&amp;gt; {
  window.addEventListener('storage', callback);
  return () =&amp;gt; window.removeEventListener('storage', callback);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;getSnapshot&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 현재 스토어 값을 읽는 함수로 매 렌더링마다 호출되고 이전 값이랑 다르면 리렌더링 트리거합니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;() =&amp;gt; sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;getServerSnapshot&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 렌더링할 때 사용할 값이고 sessionStorage처럼 서버에 없는 API면 null을 반환합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1782107939860&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;() =&amp;gt; null&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에 적용시켜보기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 현재 같은 탭 안에서 sessionStorage 변경을 UI에 반영을 해야하기 때문에 커스텀 이벤트로 직접 발행했습니다&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #d9d9d9;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;pre id=&quot;code_1782109549990&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const WRITE_GENERATION_STATUS_KEY = &quot;self:write-generation-status&quot;;
export const GENERATION_STATUS_CHANGE_EVENT = &quot;write-generation-status-change&quot;;

export function setGenerationStatus(status: GenerationStatus): void {
  sessionStorage.setItem(WRITE_GENERATION_STATUS_KEY, status); // ① 값 저장
  notifyGenerationStatusChange();                               // ② 알림
}

function notifyGenerationStatusChange(): void {
  window.dispatchEvent(new Event(GENERATION_STATUS_CHANGE_EVENT));
}

export function getGenerationStatus(): GenerationStatus | null {
  const raw = sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY);
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- dispatchEvent로 상태변경을 알리고 useGenerationStatus가 subscribe를 통해 그 신호를 읽도록 구현했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1782109623812&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function subscribe(onStoreChange: () =&amp;gt; void) {
  window.addEventListener(GENERATION_STATUS_CHANGE_EVENT, onStoreChange);
  return () =&amp;gt; window.removeEventListener(GENERATION_STATUS_CHANGE_EVENT, onStoreChange);
}

function getSnapshot() {
  return getGenerationStatus(); // 클라이언트: sessionStorage 읽기
}

function getServerSnapshot() {
  return null; // 서버 + hydration 첫 렌더: 항상 null
}

export function useGenerationStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 서버와 클라이언트&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span data-streamdown=&quot;strong&quot;&gt;첫 렌더&lt;/span&gt;는 둘 다&lt;span&gt;&amp;nbsp;&lt;/span&gt;null&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;AI 글 생성하기&quot;로 하였고 hydration 이후&lt;span&gt;&amp;nbsp;&lt;/span&gt;getSnapshot()이 실제 값을 읽어 UI가 바뀝니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;div&gt;
&lt;div data-test-render-count=&quot;1&quot;&gt;
&lt;div data-is-streaming=&quot;false&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 에러를 통해 Hydration이 단순히 &quot;서버 HTML에 이벤트 연결하는 것&quot; 이상의 의미가 있다는 걸 체감했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버와 클라이언트의 렌더링 결과가 반드시 일치해야 한다는 제약이 있고 sessionStorage처럼 브라우저에만 존재하는 API를 아무 생각 없이 쓰면 바로 터진다라는 것을 깨달았습니다..  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEffect로 빠르게 해결할 수도 있었지만 이번엔 useSyncExternalStore를 써보면서 외부 스토어를 React답게 구독하는 방법도 익혔다. 에러 하나가 꽤 많은 걸 알려주는 좋은 계기가 된거같습니다.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>
      <category>프론트엔드</category>
      <category>frontend</category>
      <category>Next.js</category>
      <category>useSyncExternalStore</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/87</guid>
      <comments>https://gse06044.tistory.com/87#entry87comment</comments>
      <pubDate>Mon, 22 Jun 2026 16:06:09 +0900</pubDate>
    </item>
    <item>
      <title>미들웨어(프록시)로 유연하게 처리 해보기</title>
      <link>https://gse06044.tistory.com/86</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;다시 한번&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 더 해빗(TheHabit)이라는 습관 형성 챌린지 서비스를 다시 만들면서 겪은 이야기입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 온보딩 페이지를 구현하면서 고려하지 못한 부분에 대해서 보완해보고자 프로젝트를 다시 시작했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cqbo85/dJMcacQ2Lyd/NPXcMxbKPJTTdGFKUwlsG0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cqbo85/dJMcacQ2Lyd/NPXcMxbKPJTTdGFKUwlsG0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cqbo85/dJMcacQ2Lyd/NPXcMxbKPJTTdGFKUwlsG0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcqbo85%2FdJMcacQ2Lyd%2FNPXcMxbKPJTTdGFKUwlsG0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;480&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 과거에 왜 하지 않았을까 생각해봤을때 딱 아래 사진과 똑같았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 예외 처리 혹은 다른 여러가지 상황을 고려하지 못했던거 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 일단 작동하니 넘겼던 과거를 후회하면서...&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;224&quot; data-origin-height=&quot;225&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLCJce/dJMcacQ2Lzx/UvmrRzuvDzTsSuz2t0ETb0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLCJce/dJMcacQ2Lzx/UvmrRzuvDzTsSuz2t0ETb0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLCJce/dJMcacQ2Lzx/UvmrRzuvDzTsSuz2t0ETb0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLCJce%2FdJMcacQ2Lzx%2FUvmrRzuvDzTsSuz2t0ETb0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;224&quot; height=&quot;225&quot; data-origin-width=&quot;224&quot; data-origin-height=&quot;225&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 여러 예외 상황 등을 바로 잡아보고자 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;505&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Xvmi6/dJMcadoQSfA/8KEisYGMAf6hBYEHiovp2k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Xvmi6/dJMcadoQSfA/8KEisYGMAf6hBYEHiovp2k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Xvmi6/dJMcadoQSfA/8KEisYGMAf6hBYEHiovp2k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXvmi6%2FdJMcadoQSfA%2F8KEisYGMAf6hBYEHiovp2k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;790&quot; height=&quot;505&quot; data-origin-width=&quot;790&quot; data-origin-height=&quot;505&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;과거의 구현이 어떻게 되었고 현재 어떻게 수정할지에 대해&lt;/h2&gt;
&lt;pre id=&quot;code_1782044408755&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 쿠키를 날것으로 직접 읽었음
const token = req.cookies.get('next-auth.session-token')?.value;
console.log('pathname', pathname); // console.log도 남아있었음

if (token &amp;amp;&amp;amp; pathname.startsWith('/onboarding')) {
  return NextResponse.redirect(new URL('/user/dashboard', req.url));
}
if (!token &amp;amp;&amp;amp; pathname.startsWith('/user')) {
  return NextResponse.redirect(new URL('/login', req.url));
}

온보딩 컴포넌트
// 완료 기록 자체가 없었음
const handleNext = () =&amp;gt; {
  if (currentStep &amp;lt; ONBOARDING_LIST.length - 1) {
    setCurrentStep(currentStep + 1);
  } else {
    router.push('/login'); // 그냥 로그인 페이지로 보냄
  }
};


const onboarding = req.cookies.get('onboarding')?.value;

if (onboarding === 'done' &amp;amp;&amp;amp; isOnboardingPath) {
  // 근데 /dashboard 로 잘못 보내고 있었음 (버그)
  return NextResponse.redirect(new URL(token ? '/user/dashboard' : '/dashboard', req.url));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 과거에 만든 미들웨어 코드입니다. 지금와서 보니 많이 아쉽습니다..&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 일단 가장 큰 문제는 쿠키 형식으로 구현했었는데 문제는 DB에 저장을 하지 않아 유저가 개발자라면 쿠키에 데이터를 삭제시키면 문제가 생길거 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 또하 경로또한 팀원들끼리 정했던 규칙에서 바뀌는 상황이 발생해 이를 또한 수정이 필요해 보입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선점 찾아보기&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 일단 가장 먼저 온보딩했다고 토큰이나 불린 값으로 DB에 저장하기&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 미들웨어 경로 다시 수정해서 지정하기&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  여기서 주의할 점&amp;nbsp;&lt;/b&gt;&lt;br /&gt;1. 일단 가장 먼저 온보딩했다고 토큰이나 불린 값으로 DB에 저장하기&amp;nbsp; -&amp;gt; 2가지를 상황을 가정해야 합니다.&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; a. 유저가 온보딩을 봤는데 로그인을 안했다면?&amp;nbsp;&lt;br /&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; b. 유저가 로그인을 하지 않고 온보딩을 봤는데 로그인을 한다면 그때의 온보딩 처리&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 위와 같은 두가지 상황을 추가로 고려해서 생각해봤습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;두가지 해결해보자&lt;/h3&gt;
&lt;pre id=&quot;code_1782046976783&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// app/api/onboarding/complete/route.ts
export async function POST() {
  const session = await getServerSession(authOptions);

  // 로그인 유저면 DB에 저장 (다른 경로)
  if (session?.user?.id) {
    await supabase
      .from('users')
      .update({ onboarding_completed: true })
      .eq('id', session.user.id);

    return NextResponse.json({ redirectTo: '/user/dashboard' });
  }

  // ✅ 비로그인이면 쿠키를 심는다
  const response = NextResponse.json({ redirectTo: '/demo' });

  response.cookies.set('onboarding', 'done', {
    httpOnly: true,        // JS에서 접근 불가 (보안)
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 365, // 1년
    path: '/',
  });

  return response;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;- 저는 다음과 같이 해결해봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b&gt;로그인을 한 유저가 온보딩을 완료시&lt;/b&gt;&lt;/span&gt; :&amp;nbsp; 기존 수퍼베이스 users 테이블에 온보딩을 완료하면 onboarding_completed : true로 DB에 저장을 하고 리다이렉트를 시켰습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span style=&quot;color: #f89009;&quot;&gt;&lt;b&gt;로그인을 하지 않은 유저가 온보딩을 완료시&lt;/b&gt;&lt;/span&gt; : 쿠키에 onboarding:done 이라는 값을 넣고 온보딩을 한번 보면 더 이상 접근을 하지 않도록 쿠키에 데이터를 넣었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 다음으로는 미들웨어 처리입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1782047280471&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// middleware.ts
export default async function middleware(req: NextRequest) {
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
  const onboardingCookie = req.cookies.get('onboarding')?.value;

  const isLoggedIn = !!token?.id;
  const hasGuestOnboardingCookie = onboardingCookie === 'done';

  // ✅ 핵심: 로그인 됨 + 쿠키 있음 &amp;rarr; DB에 동기화
  if (isLoggedIn &amp;amp;&amp;amp; hasGuestOnboardingCookie) {
    await syncGuestOnboardingToDb(token.id);
  }

  // ...이후 리다이렉트 로직
}

// libs/onboarding/onboarding.server.ts
export async function syncGuestOnboardingToDb(userId: string) {
  const supabase = getSupabaseAdmin();

  await supabase
    .from('users')
    .update({
      onboarding_completed: true,
      updated_at: new Date().toISOString(),
    })
    .eq('id', userId);
}

그리고 동기화 후 쿠키는 응답에서 삭제합니다.

// middleware.ts 의 respond 함수
const respond = (response: NextResponse) =&amp;gt; {
  if (isLoggedIn &amp;amp;&amp;amp; hasGuestOnboardingCookie) {
    response.cookies.delete('onboarding'); // ✅ 쿠키 삭제
  }
  return response;
};

// middleware.ts
// 로그인 됨 + 쿠키 있음 &amp;rarr; DB에 동기화
if (isLoggedIn &amp;amp;&amp;amp; hasGuestOnboardingCookie) {
  await syncGuestOnboardingToDb(token.id); // &amp;larr; 이 부분
}

// libs/onboarding/onboarding.server.ts
export async function syncGuestOnboardingToDb(userId: string) {
  await supabase
    .from('users')
    .update({ onboarding_completed: true })
    .eq('id', userId);
}

// 과거
export const config = {
  matcher: ['/', '/user/:path*', '/onboarding/:path*'],
};

// 현재
export const config = {
  matcher: ['/', '/user/:path*', '/onboarding/:path*', '/login'],
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 미들웨어로 유저가 경로에 접근할려고 할때를 제어해봤습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 게스트 때 온보딩을 완료한 사용자가 나중에 로그인하면 미들웨어가 쿠키를 감지해서 DB에 자동으로 동기화하고 쿠키를 삭제합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;미들웨어 ??&amp;nbsp;&lt;br /&gt;- 미들웨어란 요청과 응답 사이에 끼어드는 코드라고 생각합니다.&amp;nbsp;&lt;br /&gt;- 이해는 되는데 이거를 왜 사용하는지는 예시를 봅시다.&amp;nbsp;&lt;br /&gt;
&lt;pre id=&quot;code_1782048342187&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;미들웨어 없이 각 페이지에서 직접 처리하면 이렇게 됩니다.

// 모든 protected 페이지마다 이 코드가 반복됨
const { data: session } = useSession();

useEffect(() =&amp;gt; {
  if (!session) router.replace('/login');
}, [session]);​&lt;/code&gt;&lt;/pre&gt;
- 아래 코드를 보면 세션으로 유저가 로그인했는지 파악하고 로그인이 안되어 있다면 UseEffect 안의 조건문이 실행되어 login으로 이동을 합니다.&amp;nbsp;&lt;br /&gt;- 여기서 하나의 문제가 발생하는데 우선 화면이 바로 이동하는게 아니라 리다이렉트가 일어나 화면이 나오고 그 다음 이동을 한다는 점입니다. 그리고 만약 위와 같이 다른 곳도 로그인이 되었는지 일일히 처리하기 위해서 위 코드를 반복적으로 호출해야 한다는 문제도 있습니다.&amp;nbsp;&lt;br /&gt;- 이때 미들웨어를 사용해서 처리하면 좋습니다.&amp;nbsp;&lt;br /&gt;- 하나 주의사항이 있는데 미들웨어는 기본적으로 matcher 경로를 따로 지정해주지 않으면 어떤 페이지에서든 미들웨어가 먼저 실행이 되어 성능의 저하가 생길수도 있습니다.&amp;nbsp;&lt;br /&gt;
&lt;pre id=&quot;code_1782048592386&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const config = {
  matcher: ['/', '/user/:path*', '/onboarding/:path*', '/login'],
};​&lt;/code&gt;&lt;/pre&gt;
- 그래서 다음과 같이 특정 미들웨어가 필요한 경로에만 다음과 같이 작성해주는 것이 올바른 방법입니다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작동은 했지만 구멍이 있던 코드를 다시 들여다보니 사실 당연히 고려해야 했던 상황들이었습니다. 이번 개선을 통해 온보딩 완료 여부를 DB와 쿠키 두 곳에서 관리하고 미들웨어 하나로 모든 경우를 제어하게 됐습니다. 다음엔 처음 설계할 때부터 이런 예외 상황을 먼저 생각해보려 합니다.&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>frontend</category>
      <category>middleware</category>
      <category>Next.js</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/86</guid>
      <comments>https://gse06044.tistory.com/86#entry86comment</comments>
      <pubDate>Sun, 21 Jun 2026 22:31:09 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트를 다방면으로 생각해보기</title>
      <link>https://gse06044.tistory.com/85</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;다시 시작..&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;235&quot; data-origin-height=&quot;188&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6CJd6/dJMcahY1d6Q/QwMxETe3x0j9J4DHUfKp0k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6CJd6/dJMcahY1d6Q/QwMxETe3x0j9J4DHUfKp0k/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6CJd6/dJMcahY1d6Q/QwMxETe3x0j9J4DHUfKp0k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6CJd6%2FdJMcahY1d6Q%2FQwMxETe3x0j9J4DHUfKp0k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;235&quot; height=&quot;188&quot; data-origin-width=&quot;235&quot; data-origin-height=&quot;188&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div data-is-intersecting=&quot;true&quot; data-turn-id-container=&quot;request-6a296c8a-a1ac-83a7-9b53-3e66bebe3d83-5&quot;&gt;
&lt;div&gt;
&lt;div data-conversation-screenshot-content=&quot;&quot;&gt;
&lt;div&gt;
&lt;div data-turn-start-message=&quot;true&quot; data-message-model-slug=&quot;gpt-5-5-thinking&quot; data-message-id=&quot;a75a80be-05ce-45be-9302-c935179b43ef&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;blockquote data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;803&quot; data-start=&quot;598&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-is-last-node=&quot;&quot; data-end=&quot;803&quot; data-start=&quot;600&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트를 구현하면서 기능을 완성하는 데 집중한 나머지 다양한 관점에서 충분히 고민하지 못했다는 것을 깨달았다.&lt;br /&gt;구현이 끝나면 &amp;lsquo;&lt;b&gt;이제 다 했다&lt;/b&gt;&amp;rsquo;는 안도감이 들었고 그 때문에 사용성이나 예외 상황 개선 가능성을 놓치기도 했다.&lt;br /&gt;이번 기회를 통해 구현에서 끝나는 것이 아니라 완성 이후에도 더 나은 방향을 고민하는 습관을 만들어가고자 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;598&quot; data-origin-height=&quot;478&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwgXbC/dJMcahdGFTe/gQNbnZUd9obJDvZL1j0mX0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwgXbC/dJMcahdGFTe/gQNbnZUd9obJDvZL1j0mX0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwgXbC/dJMcahdGFTe/gQNbnZUd9obJDvZL1j0mX0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbwgXbC%2FdJMcahdGFTe%2FgQNbnZUd9obJDvZL1j0mX0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;598&quot; height=&quot;478&quot; data-origin-width=&quot;598&quot; data-origin-height=&quot;478&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예외 상황 생각해보기  &lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 현재 상황을 다이어 그램으로 생각해 봤다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1524&quot; data-origin-height=&quot;1074&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNmI1L/dJMcahrfeUH/HMsFT0kC1EzsukP4PK3Qd1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNmI1L/dJMcahrfeUH/HMsFT0kC1EzsukP4PK3Qd1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNmI1L/dJMcahrfeUH/HMsFT0kC1EzsukP4PK3Qd1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNmI1L%2FdJMcahrfeUH%2FHMsFT0kC1EzsukP4PK3Qd1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1524&quot; height=&quot;1074&quot; data-origin-width=&quot;1524&quot; data-origin-height=&quot;1074&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 간단하다 유저가 블로그 글 생성을 할때 나가면 api를 연결해서 데이터를 유지해서 db에 insert할지 아니면 네트워크를 abosrtController로 취소할지에 대한 그림이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AbortController로 진행 중인 저장 요청 취소하기 또는 글 생성 중 유저 이탈 시 안전하게 요청 취소하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유저가 글 생성 취소할때 연결을 취소 시키는 방법이다. 흐름은 다음과 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.&amp;nbsp;글&amp;nbsp;생성&amp;nbsp;완료&amp;nbsp;&amp;rarr;&amp;nbsp;자동으로&amp;nbsp;DB&amp;nbsp;저장&amp;nbsp;시도&amp;nbsp;(데이터&amp;nbsp;유지)&lt;br /&gt;2.&amp;nbsp;저장&amp;nbsp;중&amp;nbsp;유저가&amp;nbsp;나가면&amp;nbsp;&amp;rarr;&amp;nbsp;AbortController로&amp;nbsp;취소&amp;nbsp;신호&amp;nbsp;(AbortSignal)&lt;br /&gt;3.&amp;nbsp;이미&amp;nbsp;진행&amp;nbsp;중인&amp;nbsp;요청은&amp;nbsp;최대한&amp;nbsp;마무리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;코드로 흐름을 보면&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 아래의 뒤로 가기 버튼이 클릭이 되면&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781240109606&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import Link from &quot;next/link&quot;;
import { ArrowLeft } from &quot;lucide-react&quot;;

export default function GeneratingDraftHeader() {
  return (
    &amp;lt;header className=&quot;px-8 py-6 border-b border-navy-700 shrink-0&quot;&amp;gt;
      &amp;lt;div className=&quot;flex items-center gap-4&quot;&amp;gt;
        &amp;lt;Link
          href=&quot;/write&quot;  // 단순히 /write로 이동
          className=&quot;flex items-center gap-2 text-slate-400 hover:text-white transition-colors&quot;&amp;gt;
          &amp;lt;ArrowLeft size={20} /&amp;gt;
          &amp;lt;span&amp;gt;뒤로 가기&amp;lt;/span&amp;gt;
        &amp;lt;/Link&amp;gt;
        &amp;lt;div className=&quot;h-6 w-px bg-slate-600&quot; /&amp;gt;
        &amp;lt;h1 className=&quot;text-xl font-semibold text-white&quot;&amp;gt;AI 블로그 생성&amp;lt;/h1&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/header&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 자동으로 저장되도록 구현한 코드를 signal을 통해 신호 응답에 따라 제어를 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- AbortController를 선언하고 ref를 사용해서 컴포넌트 언마운트시 클린업 함수를 통해 자동으로 취소 되도록 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;activeSaveAbortRef.current?.abort(); -&amp;gt; abort()함수가 요청을 취소 시키는 함수다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781240148839&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 진행 중인 저장 API 호출을 추적하는 ref
const activeSaveAbortRef = useRef&amp;lt;AbortController | null&amp;gt;(null);

useEffect(() =&amp;gt; {
  return () =&amp;gt; {
    // 컴포넌트 언마운트 시 자동 취소
    activeSaveAbortRef.current?.abort();
  };
}, []);

// 자동 저장 시작
const controller = new AbortController();
activeSaveAbortRef.current = controller;

const { postId } = await saveGeneratedArticle(
  article,
  payload.selectedTemplate,
  { signal: controller.signal }  // &amp;larr; 신호 전달
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 다음으로 ai한테 api 요청을 하는 작업을 중단시켜야 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781240372546&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  if (!payload) {
    setError(&quot;작성 정보가 없습니다.&quot;);
    setIsGenerating(false);
    return;
  }

  const controller = new AbortController();  // fetch 신호 생성
  
  const run = async () =&amp;gt; {
    try {
      const res = await fetch(aiEndpoint(), {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify(payload),
        signal: controller.signal,  // &amp;larr; fetch 요청에 신호 연결
      });
      
     
      // ... 처리
    } catch (err) {
      if (isAbortError(err) || controller.signal.aborted) return;
      // AbortError는 무시 (정상적인 취소)
      setError(/* 실제 에러만 처리 */);
    }
  };

  run();

  return () =&amp;gt; controller.abort();  // &amp;larr; 언마운트 시 요청 취소
}, [payload, templateKey]);&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&amp;nbsp;&lt;/h3&gt;
&lt;pre id=&quot;code_1781240955145&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;사용자가 &quot;뒤로 가기&quot; 클릭
&amp;darr;
Next.js에서 /write 페이지로 네비게이션
&amp;darr;
GeneratingDraft 컴포넌트 언마운트 (리액트 라이프사이클)
&amp;darr;
useGeneratingDraft의 cleanup 함수 실행
│
├─ activeSaveAbortRef.current?.abort()
│ └─ saveGeneratedArticle의 fetch 요청 취소
│
└─ useArticleGeneration의 cleanup 함수 실행
└─ controller.abort()
└─ AI API (Gemini/OpenAI) fetch 요청 취소
&amp;darr;
모든 진행 중인 요청 중단
&amp;darr;
상태 업데이트 안 됨 (isAbortError로 필터링)
&amp;darr;
/write 페이지 렌더링&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 또한 현재 코드로만 보면 어떤 효과가 있는지 알고싶어 실제 openai 코드의 토큰 사용량을 비교해봤다. 우선 만약 abortController가 없을시에는 기존 토큰에서 600토큰 가량을 낭비하게 되지만, 이를 &lt;b&gt;abortController를 사용하니 800토큰 만큼의 낭비를&lt;/b&gt; 줄일 수 있게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpxEWR/dJMcab5BD3G/DNFOMNgka5iSNJD1ZcQZb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpxEWR/dJMcab5BD3G/DNFOMNgka5iSNJD1ZcQZb0/img.png&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;258&quot; data-is-animation=&quot;false&quot; style=&quot;width: 46.9139%; margin-right: 10px;&quot; data-widthpercent=&quot;47.47&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpxEWR/dJMcab5BD3G/DNFOMNgka5iSNJD1ZcQZb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpxEWR%2FdJMcab5BD3G%2FDNFOMNgka5iSNJD1ZcQZb0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;648&quot; height=&quot;258&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4AQcP/dJMcaffUFGz/ydKJ81ZzolJ0RAKvavV5m1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4AQcP/dJMcaffUFGz/ydKJ81ZzolJ0RAKvavV5m1/img.png&quot; data-origin-width=&quot;606&quot; data-origin-height=&quot;218&quot; data-is-animation=&quot;false&quot; style=&quot;width: 51.9233%;&quot; data-widthpercent=&quot;52.53&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4AQcP/dJMcaffUFGz/ydKJ81ZzolJ0RAKvavV5m1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4AQcP%2FdJMcaffUFGz%2FydKJ81ZzolJ0RAKvavV5m1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;606&quot; height=&quot;218&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2번째 시나리오 유저가 글쓰는 사이트를 나가거나 닫아도 유지해서 보여주기&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유저가 시도할만한 것들을 막아보자&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 유저가 뒤로가기 했을때 데이터 유지&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 유저가 경로를 강제로 쳤을때의 예외처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;유저가 뒤로가기를 한다면??&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유저가 뒤로가기를 했을때 고려해볼만한 문제는 아무런 예외처리를 토큰이 낭비하는 문제를 발견했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이를 해결해보고자 세션 스토리지에 상태를 넣어 이를 가져와 &lt;b&gt;상태에 따라 유저가 생성하기 버튼을 클릭해도 값을 재생성하지&lt;/b&gt; 못하도록 막아보았다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1781506599567&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function setGenerationStatus(status: GenerationStatus): void {
  if (typeof window === &quot;undefined&quot;) return;
  sessionStorage.setItem(WRITE_GENERATION_STATUS_KEY, status);
  notifyGenerationStatusChange();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 우선 간단하게 세션 스토리지에 위와 같이 만들어 상태 값을 넣어서 제어를 해보고자 했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 저 status의 상태는 크게 3가지로 나눴다.&lt;span style=&quot;color: #c5a5c5;&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #c5a5c5;&quot;&gt;type&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #fac863;&quot;&gt;GenerationStatus&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #d7deea;&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #88c6be;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color: #8dc891;&quot;&gt;generating&lt;/span&gt;&lt;span style=&quot;color: #88c6be;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #d7deea;&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #88c6be;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color: #8dc891;&quot;&gt;done&lt;/span&gt;&lt;span style=&quot;color: #88c6be;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #d7deea;&quot;&gt;|&lt;/span&gt;&lt;span style=&quot;color: #bbbbbb;&quot;&gt; &lt;/span&gt;&lt;span style=&quot;color: #88c6be;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color: #8dc891;&quot;&gt;error&lt;/span&gt;&lt;span style=&quot;color: #88c6be;&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color: #fc929e;&quot;&gt;;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- generating일때 뒤로가기를 눌렀을때 세션 스토리지의 상태 값을 가져와 상태에 따라 경로 리다이렉트와 버튼text를 다르게 보여수정해봤다. 왼쪽 버튼이 스토리지에 값이 없을때이고 스토리지에 generating이면 생성화면이 나오도록 수정했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxomPZ/dJMcahdJxrE/nHVW9qShiyjUAM4Zbbqt5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxomPZ/dJMcahdJxrE/nHVW9qShiyjUAM4Zbbqt5K/img.png&quot; data-origin-width=&quot;536&quot; data-origin-height=&quot;282&quot; data-is-animation=&quot;false&quot; style=&quot;width: 39.7907%; margin-right: 10px;&quot; data-widthpercent=&quot;40.26&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxomPZ/dJMcahdJxrE/nHVW9qShiyjUAM4Zbbqt5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxomPZ%2FdJMcahdJxrE%2FnHVW9qShiyjUAM4Zbbqt5K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;536&quot; height=&quot;282&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blc4OU/dJMcahdJxrg/pWWebUKxuddUJMWhpJopbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blc4OU/dJMcahdJxrg/pWWebUKxuddUJMWhpJopbk/img.png&quot; data-origin-width=&quot;440&quot; data-origin-height=&quot;156&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;59.74&quot; style=&quot;width: 59.0465%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blc4OU/dJMcahdJxrg/pWWebUKxuddUJMWhpJopbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fblc4OU%2FdJMcahdJxrg%2FpWWebUKxuddUJMWhpJopbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;440&quot; height=&quot;156&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-15 16.02.15.png&quot; data-origin-width=&quot;634&quot; data-origin-height=&quot;54&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9Kwf8/dJMcabkjsHy/YCWNndB80HOgsrC1yLCN4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9Kwf8/dJMcabkjsHy/YCWNndB80HOgsrC1yLCN4k/img.png&quot; data-alt=&quot;스토리지 상태 값&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9Kwf8/dJMcabkjsHy/YCWNndB80HOgsrC1yLCN4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9Kwf8%2FdJMcabkjsHy%2FYCWNndB80HOgsrC1yLCN4k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;634&quot; height=&quot;54&quot; data-filename=&quot;스크린샷 2026-06-15 16.02.15.png&quot; data-origin-width=&quot;634&quot; data-origin-height=&quot;54&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;스토리지 상태 값&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;유저가 경로를 강제로 리다이렉트 한다면?&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;- 우선 가장 먼저 떠오른 것은 Next.js의 미들웨어로 처리하는 방법이 먼저 떠올랐다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;- 현재 Next.js 에서 proxt.ts로 미들웨어 이름이 변경이 되었기 때문에 이에 맞게 수정을 했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;- 우선 기존 세션 스토리지에 값을 넣어놨기때문에 이를 활용해보고자 했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  여기서 문제점 미들웨어는 세션스토리지에 접근을 못한다... 그래서 이를 해결해보고자 쿠키에 직접 데이터를 넣어 해결해보고자 했다.&amp;nbsp;&lt;br /&gt;쿠키는 요청할때마다 서버로 데이터가 직접 가기 떄문에 쿠키를 사용했다.&amp;nbsp;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1781508085667&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function guardGeneratingPage(req) {
  if (pathname !== &quot;/write/generating&quot;) return;  // 다른 경로는 무시
  const hasEntry = req.cookies.get(&quot;write-generating-entry&quot;)?.value === &quot;1&quot;;
  if (!hasEntry) {
    return redirect(&quot;/write&quot;);   // ❌ 쿠키 없음 &amp;rarr; 차단
  }
  const res = next();
  clearGeneratingEntryCookie(res);  // ✅ 통과 후 쿠키 삭제 (1회용)
  return res;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 그래서 위오같이 쿠키에 임시 토큰 1을 넣어 이를 통해 유저가 특정 경로로 들어오면 들어오지 못하도록 막았다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;⚡️ 토큰 제어??&amp;nbsp;&lt;br /&gt;버튼 누를 때 쿠키 넣고 &amp;rarr; 첫 입장 때 proxy가 확인 후 바로 삭제 &lt;br /&gt;- 위 플로우를 만들면 경로를 다시 재 진입할때 쿠키에 데이터가 없어 미들웨어를 통해 경로 보호가 된다고 생각이 들었고 현재 잘 작동이 된다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아직 해결해야할 숙제&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 새로고침하면 쿠키가 삭제되는데 이를 어떻게 방지할지 좀 생각해봐야겠다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 그리고 추가로 세션 스토리지 방식은 보안 적인 측면이나 많은 문제가 생길수 있어 db에 데이터를 저장시켜 이를 보완해보고자 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 항상 한 방법으로만 생각해보지 말자. 프로젝트 개선할 사항은 정말 무궁무진하다는 것을 깨달았다.&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>프로젝트</category>
      <category>프론트엔드</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/85</guid>
      <comments>https://gse06044.tistory.com/85#entry85comment</comments>
      <pubDate>Sun, 14 Jun 2026 11:41:33 +0900</pubDate>
    </item>
    <item>
      <title>클라이언트 중복 방어의 한계와 서버 이중 방어 &amp;mdash; isSubmitting부터 idempotency key까지</title>
      <link>https://gse06044.tistory.com/84</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피드백 생성 기능을 구현하면서 한 가지 문제를 발견했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 버튼을 빠르게 여러 번 클릭하면 GPT API 호출이 그 횟수만큼 전송됐다. API 비용은 호출 횟수에 비례하고 동일한 피드백이 여러 번 생성되면 DB도 오염되어 막아야 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;200&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bzfLxT/dJMcaaZQSY3/0KRCFrPKi9PzQvYMU5iku1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bzfLxT/dJMcaaZQSY3/0KRCFrPKi9PzQvYMU5iku1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bzfLxT/dJMcaaZQSY3/0KRCFrPKi9PzQvYMU5iku1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbzfLxT%2FdJMcaaZQSY3%2F0KRCFrPKi9PzQvYMU5iku1%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;200&quot; height=&quot;200&quot; data-origin-width=&quot;200&quot; data-origin-height=&quot;200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1차 시도 &amp;mdash; isSubmitting으로 클라이언트 방어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 떠오른 건 버튼 상태 관리였다. API 호출 중에는 버튼을 비활성화하면 중복 클릭 자체를 막을 수 있다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1780816449506&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [isSubmitting, setIsSubmitting] = useState(false);

const handleGenerate = async () =&amp;gt; {
  if (isSubmitting) return;
  setIsSubmitting(true);
  try {
    await generateFeedback();
  } finally {
    setIsSubmitting(false);
  }
};

&amp;lt;button disabled={isSubmitting} onClick={handleGenerate}&amp;gt;
  피드백 생성
&amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI 레벨에서는 깔끔하게 막혔다. 하지만 구현 직후 한 가지 의문이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;한계 발견 &amp;mdash; 네트워크 탭으로 우회 가능&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저 개발자 도구 네트워크 탭에서 요청을 직접 복사해 재전송하면 isSubmitting 상태와 무관하게 서버로 요청이 전달된다. 클라이언트 방어는 어디까지나 UI 레벨의 방어다. 서버 입장에서는 어디서 온 요청인지 알 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;클라이언트 방어만으로는 완전하지 않다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2차 방어 &amp;mdash; 서버에서 DB 체크&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서도 방어 로직을 추가했다. 피드백 생성 요청이 들어오면 DB에 해당 챌린지의 피드백이 이미 존재하는지 먼저 확인한다. 있으면 GPT를 재호출하지 않고 기존 데이터를 그대로 반환한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1780817841345&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 서버 API route
const existing = await db.feedback.findFirst({
  where: { challengeId },
});

if (existing) {
  return Response.json({ feedback: existing });
}

// 없을 때만 GPT 호출
const feedback = await generateWithGPT(routineData);
await db.feedback.create({ data: { challengeId, ...feedback } });';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 클라이언트를 우회해 요청이 들어와도 GPT가 중복 호출되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개념 정리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이중 방어란?&lt;/b&gt; 클라이언트와 서버 양쪽에 각각 독립적인 방어 로직을 두는 패턴이다. 클라이언트 방어가 뚫려도 서버에서 한 번 더 걸러낸다. 어느 한쪽만 믿지 않는다는 원칙이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;idempotency key란?&lt;/b&gt; 동일한 요청을 여러 번 보내도 결과가 한 번만 처리되도록 보장하는 고유 키다. 클라이언트가 요청 시 UUID 같은 고유값을 헤더에 담아 보내고 서버는 이 키를 기준으로 이미 처리된 요청인지 판단한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1780818000903&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 클라이언트
const idempotencyKey = crypto.randomUUID();

await fetch('/api/feedback', {
  method: 'POST',
  headers: {
    'Idempotency-Key': idempotencyKey,
  },
});

// 서버
const key = request.headers.get('Idempotency-Key');
const processed = await db.idempotencyKeys.findFirst({ where: { key } });

if (processed) {
  return Response.json({ feedback: processed.result });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;회고&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;isSubmitting으로 클라이언트 방어를 구현했을 때 처음엔 충분하다고 생각했다. 그런데 네트워크 탭을 직접 조작하면 우회가 가능하다는 걸 깨닫고 나서 방어의 범위를 다시 생각하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 방어는 사용자 경험을 위한 것이고 서버 방어는 데이터 무결성을 위한 것이다. 둘은 목적이 다르고 역할도 다르다. 어느 한쪽만으 로는 완전하지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음에 유사한 기능을 구현한다면 idempotency key(멱등성 키) 도입을 진지하게 고려할 것 같다.&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/84</guid>
      <comments>https://gse06044.tistory.com/84#entry84comment</comments>
      <pubDate>Sun, 7 Jun 2026 19:05:59 +0900</pubDate>
    </item>
    <item>
      <title>AbortController 도입시도</title>
      <link>https://gse06044.tistory.com/83</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 문제점&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 항상 프로젝트에는 새로운 문제점이 생긴다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;251&quot; data-origin-height=&quot;201&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cF45eV/dJMcai4w2Q4/OGOwFB2U4KaVsWCHOkK3n0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cF45eV/dJMcai4w2Q4/OGOwFB2U4KaVsWCHOkK3n0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cF45eV/dJMcai4w2Q4/OGOwFB2U4KaVsWCHOkK3n0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcF45eV%2FdJMcai4w2Q4%2FOGOwFB2U4KaVsWCHOkK3n0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;359&quot; height=&quot;287&quot; data-origin-width=&quot;251&quot; data-origin-height=&quot;201&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 정의&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 문제점은 클라이언트 측에서 &lt;b&gt;블로그 글 생성 -&amp;gt; 취소 할때의 문제&lt;/b&gt;가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 해결 시도하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 클라이에서 블로그 글 생성 -&amp;gt; 취소 할때의 &lt;b&gt;문제가 뭔지를 우선 정의&lt;/b&gt;했다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;흐름은 다음과 같다.&lt;br /&gt;&amp;nbsp; 1. 사용자가 블로그 글을 생성한다.&amp;nbsp;&lt;br /&gt;&amp;nbsp; 2. 갑자기 사용자가 블로그 글을 취소해버리거나 뒤로 가기를 눌러버린다. &lt;br /&gt;&amp;nbsp; 3. 내가 구현한 코드에는 취소나 뒤로가기를 누르면 그대로 AI API 요청을 보낸다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 위와 같은 문제를 나는 &lt;b&gt;클라이언트 측에서&lt;/b&gt; &lt;b&gt;AbortController&lt;/b&gt;를 활용해서 비동기 작업을 취소해보도록 했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0evUs/dJMcagsaXXm/4LNeMM2a6sVBk7ROvWB6Qk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0evUs/dJMcagsaXXm/4LNeMM2a6sVBk7ROvWB6Qk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0evUs/dJMcagsaXXm/4LNeMM2a6sVBk7ROvWB6Qk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0evUs%2FdJMcagsaXXm%2F4LNeMM2a6sVBk7ROvWB6Qk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;300&quot; height=&quot;168&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;168&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;AbortController ?&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- AbortController란 &lt;b&gt;현재 진행중인 비동기 작업을 취소&lt;/b&gt;할수 있게 해주는 Web 표준 Api라고 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1780309509751&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const controller = new AbortController();

controller.signal  // 취소 신호를 감지하는 객체
controller.abort() // 취소 신호를 발생시키는 함수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 사용법은 간단하게 new 키워드로 AbortController를 선언하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Signal 객체는 취소 신호를 감지하는 객체이고, abort() 함수를 호출하면 그 즉시 비동기 작업이 취소가 된다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 뭔지는 이해했는데 언제 주로 사용할까??&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780309632582&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  fetch(&quot;/api/gemini&quot;, { body: JSON.stringify(payload) })
    .then(res =&amp;gt; res.json())
    .then(data =&amp;gt; setArticle(data)); // 문제 발생 가능
}, [payload]);


//AbortController 사용
useEffect(() =&amp;gt; {
  const controller = new AbortController();

  fetch(&quot;/api/gemini&quot;, { signal: controller.signal });

  return () =&amp;gt; controller.abort(); // 네트워크 요청 자체를 끊음
}, [payload]);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 위 코드를 봐보자 나는 현재 fetch 함수로 Next.js 서버로 gemini API로 요청을 보냈다. 이때 만&lt;b&gt;약 유저가 뒤로가기를 누르거나 탭을 꺼버린다면 위 요청은 취소&lt;/b&gt;가 되어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이때 AbortController를 사용한다. 흐름을 보면 아래와 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- fetch는 Web 표준 API라 Signal을 보고 스스로 멈추는데 이를 활용해서 Payload의 값이 변경이 되면 CleanUp 함수가 먼저 실행되어 취소가 되도록 유도하는 방법이다.&lt;/p&gt;
&lt;pre id=&quot;code_1780310057078&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fetch가 signal 감시 중
    &amp;darr;
signal.aborted = true 감지
    &amp;darr;
fetch 내부에서 AbortError throw
    &amp;darr;
요청 중단&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;해결방법(&lt;span style=&quot;color: #666666; text-align: left;&quot;&gt; 2. 갑자기 사용자가 블로그 글을 취소해버리거나 뒤로 가기를 눌러버린다.)&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 위 개념을 알고 이제 문제를 해결해보고자 했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1780310318223&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; useEffect(() =&amp;gt; {
    if (!payload) {
      setError(&quot;작성 정보가 없습니다.&quot;);
      setIsGenerating(false);
      return;
    }

    const controller = new AbortController();
    setArticle(null);
    setError(&quot;&quot;);
    setIsGenerating(true);

    const run = async () =&amp;gt; {
      try {
        const res = await fetch(aiEndpoint(), {
          method: &quot;POST&quot;,
          headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
          body: JSON.stringify(payload),
          signal: controller.signal,
        });

        if (!res.ok) {
          const json = (await res.json().catch(() =&amp;gt; ({}))) as {
            error?: string;
          };
          throw new Error(json.error ?? `HTTP ${res.status}`);
        }

        const parsed = (await res.json()) as ArticleGenerationResponse;
        setArticle({
          title: parsed.title,
          content: String(parsed.content ?? &quot;&quot;),
          keywords: Array.isArray(parsed.keywords)
            ? parsed.keywords.map(String)
            : [],
          template: templateKey,
        });
      } catch (err) {
        if (controller.signal.aborted) return;
        setError(
          err instanceof Error ? err.message : &quot;글 생성에 실패했습니다.&quot;
        );
      } finally {
        if (!controller.signal.aborted) {
          setIsGenerating(false);
        }
      }
    };

    run();

    return () =&amp;gt; controller.abort();
  }, [payload, templateKey]);

  return { article, error, isGenerating };&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 코드가 길지만 컨트롤러만 우선 보면 aiEndPoint()함수는 gemini한테 블로그 글을 작성해서 나한테 보내달라고 하는 api 요청 함수인데 유저가 뒤로 가기를했을때 혹은 페이지를 나갔을때 clenaup함수를 사용해 다음과 같은 문제를 방지하고자 했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 문제가 있었다. 연결이 사실 2개였던 것이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;[브라우저] ──①──▶ [Next.js /api/gemini] ──②──▶ [Gemini]&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 ①번 연결만 끊으면 된다고 생각했지만, ②번 연결은 별개의 HTTP 요청이라 클라이언트가 abort()를 해도 Next.js &amp;rarr; Gemini 호출은 그대로 진행되고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 클라이언트가 나가도 서버는 Gemini 응답을 끝까지 기다리고 있는 상태였다.&lt;br /&gt;&lt;br /&gt;또 다른 문제는 SDK는 AbortController를 지원하지 않는다는 점이었다. 나는 이를 해결하고자 fetch로 직접 호출을 시도했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;정리&amp;nbsp;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;AbortController는 신호를 보내는 것일 뿐 받는 쪽이 직접 처리해야 한다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;fetch는 signal을 내장 지원하지만 SDK는 지원하지 않을 수 있다. &lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;연결이 여러 개라면 각 연결마다 signal을 직접 연결해줘야 한다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>SSE</category>
      <category>프론트엔드</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/83</guid>
      <comments>https://gse06044.tistory.com/83#entry83comment</comments>
      <pubDate>Mon, 1 Jun 2026 20:57:23 +0900</pubDate>
    </item>
    <item>
      <title>React Activity?</title>
      <link>https://gse06044.tistory.com/82</link>
      <description>&lt;h1 data-end=&quot;415&quot; data-start=&quot;404&quot; data-section-id=&quot;xfxt7i&quot;&gt;React 렌더링&lt;/h1&gt;
&lt;p data-end=&quot;501&quot; data-start=&quot;417&quot; data-ke-size=&quot;size16&quot;&gt;Activity 컴포넌트를 이해하기 전에 먼저 &lt;b&gt;React에서 상태 업데이트가 어떻게 이루어지고 왜 리렌더링이 발생하는지&lt;/b&gt; 이해할 필요가 있다.&lt;/p&gt;
&lt;p data-end=&quot;611&quot; data-start=&quot;503&quot; data-ke-size=&quot;size16&quot;&gt;React 공식 문서에서는 Activity가 hidden 상태일 때 &lt;b&gt;렌더링을 수행하지 않는다&lt;/b&gt;고 설명하고 있기 때문에, 이를 이해하기 위해 먼저 React의 기본 렌더링 과정을 살펴보았다.&lt;/p&gt;
&lt;p data-end=&quot;637&quot; data-start=&quot;613&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 코드가 있다고 하자.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1773305533067&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App() {
const [count, setCount] = useState(0);

return (
&amp;lt;button onClick={() =&amp;gt; setCount(count + 1)}&amp;gt;
클릭
&amp;lt;/button&amp;gt;
);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;858&quot; data-start=&quot;800&quot; data-ke-size=&quot;size16&quot;&gt;버튼을 클릭하면 setCount가 호출되고 React는 다음과 같은 과정을 통해 UI를 업데이트한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;pre id=&quot;code_1773305523943&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setState
&amp;darr;
updateQueue에 update 추가
&amp;darr;
render phase 시작
&amp;darr;
컴포넌트 함수 실행
&amp;darr;
새로운 UI 계산
&amp;darr;
Reconciliation (이전 UI와 비교)
&amp;darr;
commit phase
&amp;darr;
DOM 업데이트&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;1004&quot; data-start=&quot;1001&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1034&quot; data-start=&quot;1006&quot; data-section-id=&quot;1i4y3qd&quot;&gt;Reconciler와 Reconciliation&lt;/h1&gt;
&lt;p data-end=&quot;1082&quot; data-start=&quot;1036&quot; data-ke-size=&quot;size16&quot;&gt;위 과정에서 중요한 부분은 &lt;b&gt;Reconciliation(재조정)&lt;/b&gt; 단계이다.&lt;/p&gt;
&lt;p data-end=&quot;1156&quot; data-start=&quot;1084&quot; data-ke-size=&quot;size16&quot;&gt;React는 이전 UI와 새로운 UI를 비교하여 &lt;b&gt;어떤 부분이 변경되었는지 판단&lt;/b&gt;하고 필요한 부분만 DOM을 업데이트한다.&lt;/p&gt;
&lt;p data-end=&quot;1232&quot; data-start=&quot;1158&quot; data-ke-size=&quot;size16&quot;&gt;이 작업을 수행하는 것이 &lt;b&gt;Reconciler&lt;/b&gt;이며, 이 과정에서 React는 &lt;b&gt;Fiber Tree&lt;/b&gt;라는 자료구조를 사용한다.&lt;/p&gt;
&lt;hr data-end=&quot;1237&quot; data-start=&quot;1234&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1251&quot; data-start=&quot;1239&quot; data-section-id=&quot;m6sva2&quot;&gt;Fiber Tree&lt;/h1&gt;
&lt;p data-end=&quot;1309&quot; data-start=&quot;1253&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Fiber Tree는 React가 컴포넌트 구조를 관리하기 위해 사용하는 내부 트리 구조이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1361&quot; data-start=&quot;1311&quot; data-ke-size=&quot;size16&quot;&gt;각 컴포넌트는 하나의 &lt;b&gt;Fiber Node&lt;/b&gt;로 표현되며, 다음과 같은 정보가 저장된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;type &amp;rarr; 컴포넌트 타입&lt;br /&gt;&lt;span&gt;props &amp;rarr; 전달된 props&lt;/span&gt;&lt;br /&gt;&lt;span&gt;memoizedState &amp;rarr; 현재 state&lt;/span&gt;&lt;br /&gt;&lt;span&gt;child &amp;rarr; 첫 번째 자식 노드&lt;/span&gt;&lt;br /&gt;&lt;span&gt;sibling &amp;rarr; 형제 노드&lt;/span&gt;&lt;br /&gt;&lt;span&gt;return &amp;rarr; 부모 노드&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1506&quot; data-start=&quot;1480&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 컴포넌트 구조가 있을 때&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1773305551956&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;App&amp;gt;
&amp;lt;Header /&amp;gt;
&amp;lt;Content /&amp;gt;
&amp;lt;/App&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1560&quot; data-ke-size=&quot;size16&quot;&gt;React 내부에서는 다음과 같은 Fiber Tree가 만들어진다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Fiber(App)&lt;br /&gt;&lt;span&gt; ├ Fiber(Header)&lt;/span&gt;&lt;br /&gt;&lt;span&gt; └ Fiber(Content)&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;1657&quot; data-start=&quot;1654&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1674&quot; data-start=&quot;1659&quot; data-section-id=&quot;4ad9qn&quot;&gt;상태 업데이트가 발생하면&lt;/h1&gt;
&lt;p data-end=&quot;1753&quot; data-start=&quot;1676&quot; data-ke-size=&quot;size16&quot;&gt;setState가 호출되면 React는 바로 state를 변경하지 않고 먼저 &lt;b&gt;updateQueue&lt;/b&gt;에 상태 변경 요청을 저장한다.&lt;/p&gt;
&lt;p data-end=&quot;1779&quot; data-start=&quot;1755&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 다음과 같은 상태가 있다고 하자.&lt;/p&gt;
&lt;pre id=&quot;code_1773305570807&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App() {
const [count, setCount] = useState(0)
const [name, setName] = useState(&quot;A&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;1925&quot; data-start=&quot;1892&quot; data-ke-size=&quot;size16&quot;&gt;React 내부에서는 Hook 상태가 다음과 같이 관리된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;Fiber(App)&lt;br /&gt;&lt;span&gt; memoizedState&lt;/span&gt;&lt;br /&gt;&lt;span&gt; &amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span&gt; Hook1&lt;/span&gt;&lt;br /&gt;&lt;span&gt; state = 0&lt;/span&gt;&lt;br /&gt;&lt;span&gt; next &amp;rarr;&lt;/span&gt;&lt;br /&gt;&lt;span&gt; Hook2&lt;/span&gt;&lt;br /&gt;&lt;span&gt; state = &quot;A&quot;&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2060&quot; data-start=&quot;2036&quot; data-ke-size=&quot;size16&quot;&gt;이때 count를 증가시키는 함수가 호출되면&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;setState 호출&lt;br /&gt;&lt;span&gt;&amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;updateQueue에 update 추가&lt;/span&gt;&lt;br /&gt;&lt;span&gt;&amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;render phase에서 updateQueue 처리&lt;/span&gt;&lt;br /&gt;&lt;span&gt;&amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;새로운 state 계산&lt;/span&gt;&lt;br /&gt;&lt;span&gt;&amp;darr;&lt;/span&gt;&lt;br /&gt;&lt;span&gt;memoizedState 업데이트&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2231&quot; data-start=&quot;2176&quot; data-ke-size=&quot;size16&quot;&gt;이 과정에서 React는 &lt;b&gt;새로운 Fiber Tree(workInProgress)&lt;/b&gt;를 생성한다.&lt;/p&gt;
&lt;hr data-end=&quot;2236&quot; data-start=&quot;2233&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2253&quot; data-start=&quot;2238&quot; data-section-id=&quot;b14vn2&quot;&gt;Fiber Tree 비교&lt;/h1&gt;
&lt;p data-end=&quot;2275&quot; data-start=&quot;2255&quot; data-ke-size=&quot;size16&quot;&gt;React는 상태 업데이트가 발생하면&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;current Fiber Tree&lt;/span&gt;&lt;br /&gt;&lt;span&gt;VS&lt;/span&gt;&lt;br /&gt;&lt;span&gt;workInProgress Fiber Tree&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2341&quot; data-start=&quot;2334&quot; data-ke-size=&quot;size16&quot;&gt;를 비교한다.&lt;/p&gt;
&lt;p data-end=&quot;2405&quot; data-start=&quot;2343&quot; data-ke-size=&quot;size16&quot;&gt;이 비교 과정이 바로 &lt;b&gt;Reconciliation&lt;/b&gt;이며, 이를 수행하는 것이 &lt;b&gt;Reconciler&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-end=&quot;2418&quot; data-start=&quot;2407&quot; data-ke-size=&quot;size16&quot;&gt;구조는 다음과 같다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;React&lt;br /&gt;&lt;span&gt;└ Fiber Tree&lt;/span&gt;&lt;br /&gt;&lt;span&gt; └ Fiber Node&lt;/span&gt;&lt;br /&gt;&lt;span&gt; ├ memoizedState&lt;/span&gt;&lt;br /&gt;&lt;span&gt; ├ updateQueue&lt;/span&gt;&lt;br /&gt;&lt;span&gt; └ hooks&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;2537&quot; data-start=&quot;2534&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2564&quot; data-start=&quot;2539&quot; data-section-id=&quot;z1uwqt&quot;&gt;왜 Fiber Tree를 두 개 유지할까?&lt;/h1&gt;
&lt;p data-end=&quot;2599&quot; data-start=&quot;2566&quot; data-ke-size=&quot;size16&quot;&gt;React 내부에는 두 개의 Fiber Tree가 존재한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;current Fiber tree&lt;/span&gt;&lt;br /&gt;&lt;span&gt;workInProgress Fiber tree&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2731&quot; data-start=&quot;2655&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2690&quot; data-start=&quot;2655&quot; data-section-id=&quot;5czij3&quot;&gt;&lt;b&gt;current&lt;/b&gt; &amp;rarr; 현재 화면에 렌더링된 UI 상태&lt;/li&gt;
&lt;li data-end=&quot;2731&quot; data-start=&quot;2691&quot; data-section-id=&quot;27mmk8&quot;&gt;&lt;b&gt;workInProgress&lt;/b&gt; &amp;rarr; 다음 UI 상태를 계산하는 트리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2753&quot; data-start=&quot;2733&quot; data-ke-size=&quot;size16&quot;&gt;렌더링 과정은 다음과 같이 진행된다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1773305628444&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;render phase
current ───────────► workInProgress
▲ │
│ │
└────── commit ────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2983&quot; data-start=&quot;2905&quot; data-ke-size=&quot;size16&quot;&gt;React는 &lt;b&gt;workInProgress tree에서 새로운 UI를 계산한 뒤 commit 단계에서 current tree와 교체한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;2993&quot; data-start=&quot;2985&quot; data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3052&quot; data-start=&quot;2995&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3024&quot; data-start=&quot;2995&quot; data-section-id=&quot;1oyfurh&quot;&gt;렌더링 도중 오류가 발생해도 기존 UI는 유지되고&lt;/li&gt;
&lt;li data-end=&quot;3052&quot; data-start=&quot;3025&quot; data-section-id=&quot;ggdohr&quot;&gt;계산이 완료된 결과만 DOM에 반영되기 때문에&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3083&quot; data-start=&quot;3054&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UI 업데이트가 안정적으로 이루어질 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;3083&quot; data-start=&quot;3054&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;React Activity&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- React Activity는 새롭게 추가된 컴포넌트로 컴포넌트를 &quot;숨김 상태로 유지하면서&quot; 상태를 보존한다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 보통 React에서 버튼을 클릭하면 state &lt;b&gt;상태가 변경이 되어, 리렌더링&lt;/b&gt;이 발생한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 하지만 Activity의 경우는 unmount 되지 않아 리렌더링이 발생하지 않는다&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773298452159&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Activity mode=&quot;hidden&quot;&amp;gt;
  &amp;lt;ChatPanel /&amp;gt;
&amp;lt;/Activity&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Activity가 어떻게 리렌더링이 일어나지 않도록 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;Activity가 리렌더링을 하지 않는 이유를&lt;/b&gt; 간단히 말하면 일종의 스킵을 통해 리렌더링을 하지 않는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 보통의 상태 업데이트가 일어난다면 아래와 같이 일어나지만, React Activity는 다르다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773306637170&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setState
&amp;darr;
updateQueue에 update 추가
&amp;darr;
render phase 시작
&amp;darr;
컴포넌트 함수 실행
&amp;darr;
새 Fiber 생성
&amp;darr;
Reconciliation
&amp;darr;
commit phase
&amp;darr;
DOM 업데이트&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- React는 memoizedState를 통해 visible한 상태인지 hidden 상태인지를 관리한다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 다시 React 동작원리를 보면 리액트에서 렌더링은 아래와 같이 렌더단계에서 내부에서는 Fiber를 하나씩 순회하며 렌더링 작업을 수행한다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- React는 memoizedState를 통해 &lt;b&gt;Offscreen Fiber가 visible 상태인지 hidden 상태인지 관리한다.&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;memoizedState === null &amp;rarr; visible&lt;br /&gt;&lt;span&gt;memoizedState !== null &amp;rarr; hidden&lt;/span&gt;&lt;/blockquote&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;- &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Activity가 hidden 상태가 되면 React는 해당 Fiber의 memoizedState에 &lt;/span&gt;&lt;b&gt;OffscreenState 객체를 저장한다.&lt;/b&gt;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1773307630583&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;Fiber(Activity)
   │
   └ Fiber(Offscreen)
        memoizedState = OffscreenState (hidden)
        │
        └ Fiber(ChatPanel)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;pre id=&quot;code_1773307130984&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;App
├ Header
├ Activity
│  └ ChatPanel
│     ├ MessageList
│     └ Input
└ Footer&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-end=&quot;701&quot; data-start=&quot;523&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;701&quot; data-start=&quot;525&quot; data-ke-size=&quot;size16&quot;&gt;Activity는 Offscreen Fiber로 구현되며, &lt;b&gt;hidden 상태일 때 memoizedState에 OffscreenState가 저장된다&lt;/b&gt;. React는 이를 통해 hidden 상태를 판단하고 subtree 렌더링을 스킵하지만, Fiber 구조 자체는 유지되기 때문에 subtree는 제거되지 않는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Activity 사용은 그러면 언제?&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 뭐 대충 Activity가 이전값을 memoziedState에 OffsreenState에 저장되어 Fiber노드가 유지되어 유지가 된다는거는 알겠는데 이거를 언제 써야 잘 썼다고 소문이 날까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 유튜브나 강의를 보면 Activity를 상태 조건에 따라 컴포넌트 렌더링 되는 모달? 같은 경우 사용하면 무작정 좋다고 하는데 이거는 아닌거 같다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 나도 처음은 그냥 오 편하고 좋네? 이 생각으로 있는 모달 모두 Activity처리헀는데 생각을 조금만 해보니 오히려 문제가 있을수도 있지 않을까? 생각이 들었다. 뭐 예를들어 잠깐 보여주는 모달 toast나 그런것들 까지 굳이?? 오히려 저 상태값을 저장하고 있는 메모리한테 미안하다 라는 생각이 들었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 그래서 올바르게 사용하고자 한다면 유지가 되어야하는 유저 정보를 보여주는 모달? 같은 거나? 댓글모달 아니면 회원가입할때 유지되어야 하는 상태일때 사용하면 좋을거 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;479&quot; data-start=&quot;466&quot; data-section-id=&quot;cngbf0&quot;&gt;회원가입 단계형 모달&lt;/li&gt;
&lt;li data-end=&quot;490&quot; data-start=&quot;480&quot; data-section-id=&quot;53260c&quot;&gt;결제 단계 모달&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;- 사람들이 그냥 좋다고 하는 것에는 항상 나쁜점도 있는거 같다. 항상 잘 알아보고 적절할때 스킬을 사용하면 더 멋진 개발자가 되지 않을까 생각이 들었다.&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>Activity</category>
      <category>REACT</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/82</guid>
      <comments>https://gse06044.tistory.com/82#entry82comment</comments>
      <pubDate>Thu, 12 Mar 2026 18:47:08 +0900</pubDate>
    </item>
    <item>
      <title>쿠키, 세션, 스토리지</title>
      <link>https://gse06044.tistory.com/81</link>
      <description>&lt;h1 data-end=&quot;153&quot; data-start=&quot;116&quot;&gt;✅ &lt;b&gt;React에서 데이터 저장 방식 정리&amp;nbsp;&lt;/b&gt;&lt;/h1&gt;
&lt;h2 data-end=&quot;181&quot; data-start=&quot;155&quot; data-ke-size=&quot;size26&quot;&gt;1) React의 상태(state)와 한계&lt;/h2&gt;
&lt;p data-end=&quot;276&quot; data-start=&quot;182&quot; data-ke-size=&quot;size16&quot;&gt;React는 컴포넌트 내부의 &lt;b&gt;state&lt;/b&gt;로 화면에 필요한 데이터를 관리한다.&lt;br /&gt;setState가 호출되면 상태가 업데이트되고 UI는 자동으로 다시 렌더링된다.&lt;/p&gt;
&lt;p data-end=&quot;321&quot; data-start=&quot;278&quot; data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;state는 컴포넌트 메모리에 존재하는 일시적인 값&lt;/b&gt;이기 때문에:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;365&quot; data-start=&quot;323&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;339&quot; data-start=&quot;323&quot;&gt;페이지 &lt;b&gt;새로고침&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;365&quot; data-start=&quot;340&quot;&gt;컴포넌트 &lt;b&gt;언마운트(페이지 이동)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;392&quot; data-start=&quot;367&quot; data-ke-size=&quot;size16&quot;&gt;와 같은 상황에서 &lt;b&gt;초기값으로 리셋&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-end=&quot;471&quot; data-start=&quot;394&quot; data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;중요!!&lt;/b&gt;&lt;br /&gt;state는 &amp;ldquo;리렌더링&amp;rdquo; 때문에 사라지는 것이 아니다.&lt;br /&gt;사라지는 이유는 오직 &lt;b&gt;새로고침 또는 언마운트&lt;/b&gt; 때문.&lt;/p&gt;
&lt;p data-end=&quot;520&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;로그인 정보처럼 지속되어야 하는 값은 state만으로 유지할 수 없다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-end=&quot;525&quot; data-start=&quot;522&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;564&quot; data-start=&quot;527&quot; data-ke-size=&quot;size26&quot;&gt;2) 지속적으로 유지해야 하는 데이터는 브라우저 저장소에 저장&lt;/h2&gt;
&lt;p data-end=&quot;642&quot; data-start=&quot;565&quot; data-ke-size=&quot;size16&quot;&gt;페이지 이동, 새로고침, 앱 재실행 후에도 유지해야 하는 데이터는&lt;br /&gt;&lt;b&gt;React state가 아닌 브라우저 저장소&lt;/b&gt;에 저장해야 한다.&lt;/p&gt;
&lt;p data-end=&quot;663&quot; data-start=&quot;644&quot; data-ke-size=&quot;size16&quot;&gt;대표적인 저장 방식은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;722&quot; data-start=&quot;665&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;682&quot; data-start=&quot;665&quot;&gt;&lt;b&gt;Cookie (쿠키)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;701&quot; data-start=&quot;683&quot;&gt;&lt;b&gt;localStorage&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;722&quot; data-start=&quot;702&quot;&gt;&lt;b&gt;sessionStorage&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;798&quot; data-start=&quot;724&quot; data-ke-size=&quot;size16&quot;&gt;이 저장소들은 컴포넌트 생명주기와 상관없이 브라우저에 남아 있기 때문에&lt;br /&gt;새로고침 또는 컴포넌트 이동이 발생해도 데이터가 유지된다.&lt;/p&gt;
&lt;hr data-end=&quot;803&quot; data-start=&quot;800&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;833&quot; data-start=&quot;805&quot;&gt;3) 쿠키(Cookie)와 세션(Session)&lt;/h1&gt;
&lt;h2 data-end=&quot;842&quot; data-start=&quot;835&quot; data-ke-size=&quot;size26&quot;&gt;✔ 쿠키&lt;/h2&gt;
&lt;p data-end=&quot;892&quot; data-start=&quot;843&quot; data-ke-size=&quot;size16&quot;&gt;쿠키는 &lt;b&gt;브라우저와 서버가 정보를 교환하기 위해 사용하는 작은 데이터 저장공간&lt;/b&gt;이다.&lt;/p&gt;
&lt;h3 data-end=&quot;903&quot; data-start=&quot;894&quot; data-ke-size=&quot;size23&quot;&gt;쿠키 특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1069&quot; data-start=&quot;904&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;939&quot; data-start=&quot;904&quot;&gt;브라우저가 서버에 요청을 보낼 때 &lt;b&gt;자동으로 포함&lt;/b&gt;된다.&lt;/li&gt;
&lt;li data-end=&quot;978&quot; data-start=&quot;940&quot;&gt;&lt;b&gt;도메인 단위&lt;/b&gt;로 저장되며, 다른 도메인에는 전송되지 않는다.&lt;/li&gt;
&lt;li data-end=&quot;1033&quot; data-start=&quot;979&quot;&gt;브라우저마다 서로 다른 저장공간을 사용한다.&lt;br /&gt;(Chrome 쿠키 &amp;ne; Safari 쿠키)&lt;/li&gt;
&lt;li data-end=&quot;1069&quot; data-start=&quot;1034&quot;&gt;max-age, expires로 유효기간 설정 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1082&quot; data-start=&quot;1071&quot; data-ke-size=&quot;size23&quot;&gt;쿠키의 문제점&lt;/h3&gt;
&lt;p data-end=&quot;1110&quot; data-start=&quot;1083&quot; data-ke-size=&quot;size16&quot;&gt;쿠키는 기본적으로 JS에서 읽을 수 있기 때문에:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;document&lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;&lt;span&gt;cookie&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1171&quot; data-start=&quot;1139&quot; data-ke-size=&quot;size16&quot;&gt;이 코드로 브라우저에 저장된 쿠키를 그대로 읽을 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1231&quot; data-start=&quot;1173&quot; data-ke-size=&quot;size16&quot;&gt;따라서 쿠키에 로그인 정보 같은 민감한 데이터를 직접 저장하면&lt;br /&gt;삭제&amp;middot;수정&amp;middot;탈취(XSS)에 취약해진다.&lt;/p&gt;
&lt;hr data-end=&quot;1236&quot; data-start=&quot;1233&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1254&quot; data-start=&quot;1238&quot; data-ke-size=&quot;size26&quot;&gt;✔ 세션(Session)&lt;/h2&gt;
&lt;p data-end=&quot;1298&quot; data-start=&quot;1256&quot; data-ke-size=&quot;size16&quot;&gt;세션은 &lt;b&gt;사용자 정보를 쿠키에 직접 저장하지 않기 위한 인증 방식&lt;/b&gt;이다.&lt;/p&gt;
&lt;h3 data-end=&quot;1312&quot; data-start=&quot;1300&quot; data-ke-size=&quot;size23&quot;&gt;세션 동작 방식&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1476&quot; data-start=&quot;1313&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1347&quot; data-start=&quot;1313&quot;&gt;사용자가 로그인하면 서버가 &lt;b&gt;세션 ID&lt;/b&gt;를 생성한다.&lt;/li&gt;
&lt;li data-end=&quot;1382&quot; data-start=&quot;1348&quot;&gt;실제 사용자 정보는 서버 내부(세션 저장소)에 저장한다.&lt;/li&gt;
&lt;li data-end=&quot;1413&quot; data-start=&quot;1383&quot;&gt;브라우저에는 &lt;b&gt;세션 ID만 쿠키에 저장&lt;/b&gt;한다.&lt;/li&gt;
&lt;li data-end=&quot;1443&quot; data-start=&quot;1414&quot;&gt;브라우저는 요청마다 세션 ID를 자동 전송한다.&lt;/li&gt;
&lt;li data-end=&quot;1476&quot; data-start=&quot;1444&quot;&gt;서버는 세션 ID를 기반으로 사용자 정보를 조회한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1520&quot; data-start=&quot;1478&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;민감한 정보는 브라우저가 아닌 서버가 보관하기 때문에 안전하다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-end=&quot;1525&quot; data-start=&quot;1522&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1554&quot; data-start=&quot;1527&quot;&gt;4) XSS와 CSRF (쿠키가 취약한 이유)&lt;/h1&gt;
&lt;h3 data-end=&quot;1588&quot; data-start=&quot;1556&quot; data-ke-size=&quot;size23&quot;&gt;✔ XSS (Cross-Site Scripting)&lt;/h3&gt;
&lt;p data-end=&quot;1648&quot; data-start=&quot;1589&quot; data-ke-size=&quot;size16&quot;&gt;악성 스크립트가 웹페이지에서 실행되는 공격.&lt;br /&gt;document.cookie로 쿠키를 탈취할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;1699&quot; data-start=&quot;1650&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;br /&gt;HttpOnly 옵션을 설정하면 JS로 쿠키를 읽을 수 없게 된다.&lt;/p&gt;
&lt;hr data-end=&quot;1704&quot; data-start=&quot;1701&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-end=&quot;1745&quot; data-start=&quot;1706&quot; data-ke-size=&quot;size23&quot;&gt;✔ CSRF (Cross-Site Request Forgery)&lt;/h3&gt;
&lt;p data-end=&quot;1797&quot; data-start=&quot;1746&quot; data-ke-size=&quot;size16&quot;&gt;쿠키가 자동 전송되는 특성을 이용하여&lt;br /&gt;사용자가 의도하지 않은 요청을 서버로 보내는 공격.&lt;/p&gt;
&lt;p data-end=&quot;1801&quot; data-start=&quot;1799&quot; data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;span&gt;img&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;src&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&lt;span&gt;&quot;&lt;a href=&quot;https://bank.com/transfer?to=hacker&amp;amp;amount=1000000&quot;&gt;https://bank.com/transfer?to=hacker&amp;amp;amount=1000000&lt;/a&gt;&quot;&lt;/span&gt;&lt;/span&gt;&lt;span&gt; /&amp;gt; &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1911&quot; data-start=&quot;1880&quot; data-ke-size=&quot;size16&quot;&gt;로그인 쿠키가 자동 전송 &amp;rarr; 서버는 정상 요청으로 착각.&lt;/p&gt;
&lt;p data-end=&quot;1922&quot; data-start=&quot;1913&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1965&quot; data-start=&quot;1923&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1947&quot; data-start=&quot;1923&quot;&gt;쿠키에 SameSite 옵션 설정&lt;/li&gt;
&lt;li data-end=&quot;1965&quot; data-start=&quot;1948&quot;&gt;CSRF Token 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1970&quot; data-start=&quot;1967&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2006&quot; data-start=&quot;1972&quot;&gt;5) localStorage / sessionStorage&lt;/h1&gt;
&lt;h2 data-end=&quot;2025&quot; data-start=&quot;2008&quot; data-ke-size=&quot;size26&quot;&gt;✔ localStorage&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2129&quot; data-start=&quot;2026&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2054&quot; data-start=&quot;2026&quot;&gt;브라우저에 &lt;b&gt;영구적으로 저장&lt;/b&gt;되는 데이터&lt;/li&gt;
&lt;li data-end=&quot;2087&quot; data-start=&quot;2055&quot;&gt;새로고침 / 탭 닫기 / 브라우저 종료 후에도 유지&lt;/li&gt;
&lt;li data-end=&quot;2106&quot; data-start=&quot;2088&quot;&gt;서버로 자동 전송되지 않음&lt;/li&gt;
&lt;li data-end=&quot;2129&quot; data-start=&quot;2107&quot;&gt;JS로 접근 가능 &amp;rarr; XSS 취약&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2173&quot; data-start=&quot;2131&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 예:&lt;/b&gt;&lt;br /&gt;다크모드 설정, 비로그인 장바구니, 사용자 환경설정 등&lt;/p&gt;
&lt;hr data-end=&quot;2178&quot; data-start=&quot;2175&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2199&quot; data-start=&quot;2180&quot; data-ke-size=&quot;size26&quot;&gt;✔ sessionStorage&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2297&quot; data-start=&quot;2200&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2222&quot; data-start=&quot;2200&quot;&gt;&lt;b&gt;탭 단위로 저장&lt;/b&gt;되는 데이터&lt;/li&gt;
&lt;li data-end=&quot;2237&quot; data-start=&quot;2223&quot;&gt;탭을 닫으면 삭제됨&lt;/li&gt;
&lt;li data-end=&quot;2255&quot; data-start=&quot;2238&quot;&gt;다른 탭과 공유되지 않음&lt;/li&gt;
&lt;li data-end=&quot;2274&quot; data-start=&quot;2256&quot;&gt;서버로 자동 전송되지 않음&lt;/li&gt;
&lt;li data-end=&quot;2297&quot; data-start=&quot;2275&quot;&gt;JS로 접근 가능 &amp;rarr; XSS 취약&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2352&quot; data-start=&quot;2299&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 예:&lt;/b&gt;&lt;br /&gt;회원가입 단계(step form) 임시 저장, 페이지 이동 중 임시 데이터&lt;/p&gt;
&lt;hr data-end=&quot;2357&quot; data-start=&quot;2354&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2381&quot; data-start=&quot;2359&quot;&gt;  최종 요약&amp;nbsp;&lt;/h1&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;React&lt;/span&gt;&lt;/span&gt;&lt;span&gt;의 state는 컴포넌트 메모리에 존재하기 때문에 새로고침 또는 언마운트 시 사라진다. &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;지속되어야 하는 데이터는 브라우저 저장소(&lt;/span&gt;&lt;span&gt;&lt;span&gt;Cookie&lt;/span&gt;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&lt;span&gt;localStorage&lt;/span&gt;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&lt;span&gt;sessionStorage&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)에 저장해야 한다. &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;쿠키는 서버와 자동으로 데이터를 주고받는 저장소지만, &lt;/span&gt;&lt;span&gt;&lt;span&gt;JS&lt;/span&gt;&lt;/span&gt;&lt;span&gt;로 접근 가능한 특성 때문에 &lt;/span&gt;&lt;span&gt;&lt;span&gt;XSS&lt;/span&gt;&lt;/span&gt;&lt;span&gt;와 &lt;/span&gt;&lt;span&gt;&lt;span&gt;CSRF&lt;/span&gt;&lt;/span&gt;&lt;span&gt;에 취약할 수 있다. 이를 해결하기 위해 &lt;/span&gt;&lt;span&gt;&lt;span&gt;HttpOnly&lt;/span&gt;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&lt;span&gt;SameSite&lt;/span&gt;&lt;/span&gt;&lt;span&gt; 옵션을 사용한다.&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt; 세션은 사용자 정보를 서버에 저장하고, 쿠키에는 세션 &lt;/span&gt;&lt;span&gt;&lt;span&gt;ID&lt;/span&gt;&lt;/span&gt;&lt;span&gt;만 저장하는 구조로 쿠키의 보안 문제를 해결하며 안전하게 인증 상태를 유지할 수 있게 한다. &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;localStorage&lt;/span&gt;&lt;/span&gt;&lt;span&gt;는 브라우저에 영구 저장되고, &lt;/span&gt;&lt;span&gt;&lt;span&gt;sessionStorage&lt;/span&gt;&lt;/span&gt;&lt;span&gt;는 탭 단위로 관리된다. 두 저장소 모두 서버에 자동 전송되지 않기 때문에 &lt;/span&gt;&lt;span&gt;&lt;span&gt;UI&lt;/span&gt;&lt;/span&gt;&lt;span&gt; 상태나 임시 데이터 저장에 적합하다. &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>로컬 스토리지</category>
      <category>쿠키 세션</category>
      <category>프론트엔드</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/81</guid>
      <comments>https://gse06044.tistory.com/81#entry81comment</comments>
      <pubDate>Sat, 6 Dec 2025 20:12:58 +0900</pubDate>
    </item>
    <item>
      <title>React 딥다이브</title>
      <link>https://gse06044.tistory.com/80</link>
      <description>&lt;h1&gt;React hook 알아보기&lt;/h1&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useCallback&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useMemo가 값을 기억했다며느 useCallbak은 인수로 넘겨받은 콜백 자체를 기억한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;즉 &lt;b&gt;useCallback은 특정 함수를 새로 만들지 않고 다시 재사용&lt;/b&gt;한다는 의미이다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  음 솔직히 뭔지 모르겠다 ... 예시를 보자아&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;✅ 확인하고 넘어가기&amp;nbsp;&lt;br /&gt;- React에서 리 렌더링이 일어나면 함수가 다시 만들어지고 다시 실행이 된다. -&amp;gt; 이로 인해 함수 내부 변수가 함수 등이 다른 메모리 주소를 가르킨다.&amp;nbsp;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1760934501243&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;

export default function SignupPage() {
  const [step, setStep] = useState(1);

  const onClick = () =&amp;gt; {
    setStep(step + 1);
  };

  useEffect(() =&amp;gt; {
    console.log(&quot;click&quot;);
  }, [onClick]);

  return (
    &amp;lt;div&amp;gt;
      {step}
      &amp;lt;button onClick={onClick}&amp;gt;next&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다음과 같은 예제를 보자 버튼을 클릭하면 onClick 함수가 실행이 된다. 그로 인데 setState 상태가 변경이 되어 리액트가 리렌더링을 일으킨다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;리렌더링이 일어나니 컴포넌트 내부에 있는 모든 변수, 함수가 다시 생성되고 실행된다. 이로 인해 버튼을 클릭하면 click이라는 콘솔이 나오게 된다. 그렇다면 실행이 되지 말아야 하는 함수의 경우 메모이제이션이 필요하지 않을까??? 해서 나온것이 useCallback이다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760935254263&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useCallback, useEffect, useState } from &quot;react&quot;;

export default function SignupPage() {
  const [step, setStep] = useState(1);

  const onClick = useCallback(() =&amp;gt; {
    setStep(step + 1);
  }, [step]);

  useEffect(() =&amp;gt; {
    console.log(&quot;click&quot;);
  }, [onClick]);

  return (
    &amp;lt;div&amp;gt;
      {step}
      &amp;lt;button onClick={onClick}&amp;gt;next&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useCallback은 다음과 같이 사용하면 된다. 의존성 배열의 값이 변경이 되면 메모이제이션이 된 onClick 함수가 실행이 된다. 음 근데 결국 똑같이 버튼 클릭하면 리렌더링 발생하는거자나????   맞긴하다. 하지만 다른 예제를 보면 왜 사용하면 좋은지 알 수 있다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760935879817&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useCallback, useState } from &quot;react&quot;;

function Child({ onClick }: { onClick: () =&amp;gt; void }) {
  console.log(&quot;  자식 리렌더링&quot;);
  return &amp;lt;button onClick={onClick}&amp;gt;자식 버튼&amp;lt;/button&amp;gt;;
}

export default function SignupPage() {
  const [count, setCount] = useState(0);

  // ❌ 리렌더링될 때마다 새로운 함수가 생성됨
  const handleClick = useCallback(() =&amp;gt; {
    console.log(&quot;clicked&quot;);
  }, []);

  console.log(&quot;  부모 리렌더링&quot;);

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;p&amp;gt;count: {count}&amp;lt;/p&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setCount(count + 1)}&amp;gt;부모 버튼&amp;lt;/button&amp;gt;
      &amp;lt;Child onClick={handleClick} /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위예제를 보면 부모 컴포넌트가 자식 컴포넌트한테 onClick이라는 props를 전달해서 자식 컴포넌트가 이를 사용중인데, 부모 컴포넌트가 리렌더링이 발생하면 자식 컴포넌트 또한 강제로 리렌더링이 되는 것을 볼 수 있다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;이유는 부모컴포넌트의 setCount로 인해 상태 변경이 되어 전체 리렌더링이 발생하는데 자식 또한 props가 변경되었다고 판단해 리렌더링이 발생한다. 이를 useCallback을 쓰면 음 근데 부모 리렌더링 이랑 자식 리렌더링 둘다 발생하는데 ???  &lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;useCallback&lt;br /&gt;- useCallback은 함수 주소를 고정시켜주는 것이지, 리렌더링 방지하는 기능은 따로 없다는 것을 알았다...&amp;nbsp;&lt;br /&gt;- 위 원인을 찾아보니 결국 props의 값이 변경이 되지 않더라도 부모 컴포넌트가 리렌더링이 발생하니 같이 리렌더링이 발생하는 것이었다...&amp;nbsp;&lt;br /&gt;이를 통해 useCallback은 함수 메모리 주소를 고정시켜주지만, 결국 리렌더링이 일어난다는 것을 알게되었다...&lt;br /&gt;해결방안은 의존성 배열에 따라 리렌더링 되도록 하는 것이 useCallback을 유연하게 사용하는 것이다&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size16&quot;&gt;useCallback + React.memo&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 훅을 같이 사용하면 최적화하는데 도움이 될거 같다. useCallback은 함수의 참조 값을 고정시키고 React.memo는 props를 비교해 같다면 리렌더링을 방지하니 이 둘을 혼합해서 사용해보자&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useRef&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useRef는 useState와 비교할 때 컴포넌트 내부에서 렌더링이 일어나도 변경 가능한 상태값을 저장하고 있다. 하지만 2가지 차이가 있다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useRef는 반환값인 객체 내부에 있는 current로 값에 접근 변경할 수 있다&lt;/li&gt;
&lt;li&gt;useRef는 그 값이 변하더라도 렌더링을 발생시키지 않는다.&amp;nbsp;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;여기서 알 수 있는 점은 useRef는 값의 변경이 일어나도 리렌더링이 발생하지 않는다는 것이다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;보통 useRef는 검색 엔진을 최적화할때 사용하면 좋을거 같다.&amp;nbsp; &amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;⚠️ 여기서 주의할 점은 리렌더링이 발생하지 않아 화면상에는 변경점을 찾아볼 수 없다는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>리액트 훅</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/80</guid>
      <comments>https://gse06044.tistory.com/80#entry80comment</comments>
      <pubDate>Mon, 20 Oct 2025 14:10:06 +0900</pubDate>
    </item>
    <item>
      <title>프로젝트를 하면서 고민점들 (라우팅 렌더링)</title>
      <link>https://gse06044.tistory.com/79</link>
      <description>&lt;h1 data-ke-size=&quot;size16&quot;&gt;페이지 이동시 리렌더링이 발생하는 문제&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  개발 중 헤더의 li를 클릭하면 링크는 잘 이동되지만, active 표시가 되지 않는 문제가 발생했다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;320&quot; data-origin-height=&quot;320&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVGKAa/dJMb9PsQa8e/GxafJKxkCRX1Rl1WqQvyNk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVGKAa/dJMb9PsQa8e/GxafJKxkCRX1Rl1WqQvyNk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVGKAa/dJMb9PsQa8e/GxafJKxkCRX1Rl1WqQvyNk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVGKAa%2FdJMb9PsQa8e%2FGxafJKxkCRX1Rl1WqQvyNk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;320&quot; height=&quot;320&quot; data-origin-width=&quot;320&quot; data-origin-height=&quot;320&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  이유가 뭘까 state가 왜 초기화 되는지 찾아보자&amp;nbsp;&lt;/li&gt;
&lt;li&gt;우선 가설을 세웠다.&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;라우팅이 된 후 active가 안되는 것이니 Next.js의 문제인가&amp;nbsp;&lt;/li&gt;
&lt;li&gt;그래서 Header의 라우팅이 현재 어떻게 되었는지 찾아보자&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;app/&lt;br /&gt;├──&amp;nbsp;layout.tsx&lt;br /&gt;├──&amp;nbsp;page.tsx&lt;br /&gt;├──&amp;nbsp;_components/&lt;br /&gt;&amp;nbsp;│&amp;nbsp;&amp;nbsp;&amp;nbsp;├── Header/&lt;br /&gt;&amp;nbsp;│&amp;nbsp;&amp;nbsp;&amp;nbsp;│&amp;nbsp;&amp;nbsp;&amp;nbsp;└── Header.tsx&lt;br /&gt;&amp;nbsp;│&amp;nbsp;&amp;nbsp;&amp;nbsp;└── layout/&lt;br /&gt;&amp;nbsp;│&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;└── LayoutProvider.tsx&lt;br /&gt;└──&amp;nbsp;globals.css&lt;/li&gt;
&lt;li&gt;현재 나의 헤더는 layout에 LayoutProvider 컴포넌트를 감싸서 페이지 마다 다른 레이아웃을 적용시키기 위해서 분리 했다&amp;nbsp;&lt;/li&gt;
&lt;li&gt;현재 나는 LayoutProvider를 Server Component로 했다. Next.js를 찾아보니 Segment 단위 서버 렌더링을 한다고 한다. 이게 원인거 같다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Segment는 단위 서버 렌더링은 경로 이동 ex) / -&amp;gt; /about 으로 이동하면 서버 컴포넌트를 새로 렌더링해서 스트리밍을 한다고 한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이로 인해 경로 이동을 하게 되면 서버 컴포넌트안에 Header가 있기 때문에 리 렌더링이 되는 것 같다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;해결방안&amp;nbsp;&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;간단한 방안은 usePathname()을 사용하는 것이다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;변경 전 코드는 li를 클릭하면 setStatus로 이름을 넣어 업데이트 시켜 그와 일치하면 스타일을 주는 것을 만들었었다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760789941004&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [status, setStatus] = useState(&quot;default&quot;);
const handleStatus = (name: string, href: string) =&amp;gt; {
  setStatus(name);
  if (name === &quot;사부작 게시판&quot;) {
    setIsOpen(true);
  } else {
    setIsOpen(false);
  }
  if (href) {
    router.push(href);
  }
};

&amp;lt;li
  key={item.id}
  onClick={() =&amp;gt; handleStatus(item.name, item.href)}
  className={
    status === item.name
      ? styles.navLinkItemLinkActive
      : styles.navLinkItemLink
  }
&amp;gt;
  {item.name}
&amp;lt;/li&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  usePathname&lt;br /&gt;- usePathname은 현재 URL 경로를 읽는 클라이언트 전용 훅이다.&amp;nbsp;&lt;br /&gt;- usePathname은 클라이언트 쪽에서 관리를 하기 때문에, 페이지 이동시 리렌더링이 발생은 하지만, 컴포넌트 자체가 다시 생성은 되지 않아 위의 문제점을 해결할 수 있다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  다른 해결 방안&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;client component로 ??&amp;nbsp;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;뭔가 가장 먼저 떠오른 방법은 LayoutProvider.tsx를 client 컴포넌트로 변경하는 방법이 떠올랐다. Segment 단위 서버 렌더링을 한다고 했으니 그러면 client 컴포넌트로 변경한다면 리렌더링만 발생하지 않을까 고민이 되었다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;⚠️근데 주의사항이 있었다. 현재 경로가 components / layout 폴더 안에 LayoutProvider가 있기 때문에, 클라이언트 컴포넌트로 바꾼다고 해서 app 라우팅 계층이 아니라 단순 Import 된 컴포넌트로 처리를 한다는 것이다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;이를 해결하기 위해서는 LayoutProvider를 app 폴더 경로로 옮겨야 한다. 하지만 폴더구조를 맞추기 위해서 위 방법은 택하지 않도록 하였다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h1&gt;정리&amp;nbsp;&lt;/h1&gt;
&lt;div&gt;
&lt;div&gt;문제원인해결
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;1579&quot; data-start=&quot;1332&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;1579&quot; data-start=&quot;1372&quot;&gt;
&lt;tr data-end=&quot;1463&quot; data-start=&quot;1372&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1400&quot; data-start=&quot;1372&quot;&gt;페이지 이동 시 Header state 초기화&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1441&quot; data-start=&quot;1400&quot;&gt;Segment 단위 서버 렌더링으로 LayoutProvider 재생성&lt;/td&gt;
&lt;td data-col-size=&quot;md&quot; data-end=&quot;1463&quot; data-start=&quot;1441&quot;&gt;usePathname() 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1579&quot; data-start=&quot;1464&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1490&quot; data-start=&quot;1464&quot;&gt;&quot;use client&quot;로도 해결 안 됨&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1520&quot; data-start=&quot;1490&quot;&gt;_components 폴더는 라우트 계층 아님&lt;/td&gt;
&lt;td data-col-size=&quot;md&quot; data-end=&quot;1579&quot; data-start=&quot;1520&quot;&gt;LayoutProvider를 app 폴더로 이동 or Header를 root layout로 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프론트엔드</category>
      <category>Next.js</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/79</guid>
      <comments>https://gse06044.tistory.com/79#entry79comment</comments>
      <pubDate>Sat, 18 Oct 2025 22:22:06 +0900</pubDate>
    </item>
    <item>
      <title>React 딥다이브</title>
      <link>https://gse06044.tistory.com/78</link>
      <description>&lt;h1&gt;리액트 훅 파해치기&lt;/h1&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useState&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useState 컴포넌트는 내부에서 상태를 정의하고, 이 상태를 관리할 수 있게 해주는 훅이다&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760617410552&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from 'react'

const [state, setState] = useState(initialState);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useState 인수로는 사용할 초기값 state 초기값을 넘겨준다. 아무런 값을 넘겨주지 않으면 undefined가 나온다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;useState 훅의 반환 값은 배열이며, 배열의 첫번째 원소로 state 값 자체를 사용이 가능하고, 두번째 setState 함수를 사용해 state의 값을 변경이 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760617755406&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Component() {
  let state = 'hello'
}

 function handleButtonClick() {
   state = 'hi'
 }

 return (
   &amp;lt;&amp;gt;
     &amp;lt;h1&amp;gt;{state}&amp;lt;/h1&amp;gt;
     &amp;lt;button onClick={handleButtonClick}&amp;gt;hi&amp;lt;/button&amp;gt;
   &amp;lt;/&amp;gt;
 )
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위 코드는 리액트에서 동작하지 않는다. 리액트에서 렌더링은 return 함수를 실행한 다음, 실행 결과를 이전의 리액트 트리와 비교해 필요한 부분만 업데이트 된다. 리액트에서 리렌더링이 일어나는 조건은 부모컴포넌트가 렌더링 되거나, key 값이 변경이 되거나, props, state가 변경이 되어야 리렌더링이 발생한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;위와 같은 이유로 다음 코드는 버튼을 눌러도 아무런 동작을 하지 않는다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760618024847&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Component() {
   const [, trigger] = useState()
   
   let state = 'hello'
}

 function handleButtonClick() {
   state = 'hi'
   trigger()
 }

 return (
   &amp;lt;&amp;gt;
     &amp;lt;h1&amp;gt;{state}&amp;lt;/h1&amp;gt;
     &amp;lt;button onClick={handleButtonClick}&amp;gt;hi&amp;lt;/button&amp;gt;
   &amp;lt;/&amp;gt;
 )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;그렇다면?? 다음은 코드에서는 setState 함수를 실행시켜 렌더링이 일어나게 했는데 여전히 버튼 클릭해도 아무런 값이 나오지 않는다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;이유는 return 값을 비교해 실행되기 때문이다. 즉 매번 렌더링이 발생할 때마다 함수는 새롭게 다시 실행되고, 새롭게 실행되는 함수에서 state는 매번 hello로 초기화 되고 아무리 state를 변경해도 hello로 초기화 된다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;i&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;여기서 알 수 있는 것은 렌더링이 발생하면 컴포넌트 자체는 새롭게 다시 실행된다는 것이다&lt;br /&gt;&lt;/span&gt;&lt;/i&gt;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1760619026003&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function Component() {
  const [state, setState] = useState(false)
  
  function handleClick() {
    setState((prev) =&amp;gt; !prev)
  }
  
  return (
    &amp;lt;&amp;gt;
     &amp;lt;button onClick={handleClick}&amp;gt;+&amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;위처럼 사용해야 정상적으로 작동을 한다. 근데 궁금한게 위에서 말한 렌더링이 발생하면 컴포넌트 자체가 다시 새롭게 실행이 되는데 어떻게 초기화가 안될까  &lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;✅ 이유는 리액트 파이버 때문이다.&amp;nbsp;&lt;br /&gt;만약 setState 함수가 호출되면 리액트 Fiber에 존재하는 &lt;b&gt;updateQueue&lt;/b&gt;에 등록한다. 이를&lt;b&gt; 기반으로 리액트는 다시 컴포넌트를 실행&lt;/b&gt;시킨다.&amp;nbsp;&lt;br /&gt;
&lt;pre id=&quot;code_1760620205318&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setState(nextValue)

Fiber.memoizedState = {        // useState 훅 저장 공간
  memoizedState: currentValue, // 현재 상태값
  updateQueue: [               // 변경 요청들
    { action: nextValue }
  ]
}​&lt;/code&gt;&lt;/pre&gt;
setState가 업데이트 되면 아래와 같이 &lt;b&gt;파이버는 updateQueue에 새로운 상태를 적용시키고 이 결과를 memoziedState에 저장한다고&lt;/b&gt; 생각하자.&amp;nbsp;&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;게으른 초기화&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일반적으로 useState에서 기본값을 선언하기 위해 useState() 인수로 원시값을 넣는 경우가 대부분일 것이다. 그러나 이 useState 인수로 특정한 값을 넘기는 함수를 넣어줄 수 있다. useState에 변수 대신 함수를 넘기는 것을 게으른 초기화(lazy initailzation)이라 한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;리액트 공식문서에서 &lt;b&gt;lazy initailzation은 복잡하거나 무거운 연산을 포함하고 있을때 사용하라고&lt;/b&gt; 되어있다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;이 초기화 함수는 오로지 &lt;b&gt;state가 처음 만들어질때만 사용하고 리렌더링이 발생&lt;/b&gt;하면 무시된다고 한다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-10-16 22.21.14.png&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;246&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rLPiP/dJMb9WrSTuj/EeYYNI7zcvPHNT3KMKJyfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rLPiP/dJMb9WrSTuj/EeYYNI7zcvPHNT3KMKJyfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rLPiP/dJMb9WrSTuj/EeYYNI7zcvPHNT3KMKJyfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrLPiP%2FdJMb9WrSTuj%2FEeYYNI7zcvPHNT3KMKJyfk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;926&quot; height=&quot;246&quot; data-filename=&quot;스크린샷 2025-10-16 22.21.14.png&quot; data-origin-width=&quot;926&quot; data-origin-height=&quot;246&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;게으른 초기화는 정리하면 useState 인수로 함수를 전달해야할 때를 방지하고자 사용하면 된다고 알고있자!!&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의사항으로는 lazy initailzation에 함수 자체를 넣어줘야지 함수를 호출하는 식으로 만들면 리렌더링되면 함수가 호출되니 이를 주의하자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;useEffect&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useEffect는 애플리케이션 내 컴포넌트들의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 매커니즘이다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760622358999&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  
}, [props, state])&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useEffect의 첫 번째 인수로는 실행할 부수효과가 있는 함수를, 두 번째 인수로는 의존성 배열을 전달한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;의존성 배열이 변경될때 콜백함수가 실행이 된다고 하는데 어떻게 실행이 되는걸까..??  &amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1760622635734&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { useEffect } from &quot;react&quot;;

export default function Footer() {
  const [counter, setCounter] = useState(0)

  useEffect(() =&amp;gt; {
    console.log(counter) // 1 2 3 4...
  }, []);
  
  useEffect(() =&amp;gt; {
    console.log(counter) // 1 2 3 4...
  }, [counter]);

  useEffect(() =&amp;gt; {
       console.log(counter) // 1 2 3 4...
  }, [counter]);
  
  useEffect(() =&amp;gt; {
  console.log(&quot;  effect 실행&quot;);

  return () =&amp;gt; {
    console.log(&quot;  cleanup 실행&quot;);
  };
}, []);



  return &amp;lt;button onClick={handleClick}&amp;gt;Click me&amp;lt;/button&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;useEffect는 state와 props의 변화속에서 일어나는 렌더링 과정에서 실행되는 부수 효과 함수라고 볼 수 있다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;위 useEffect 훅에서 의존성 배열은 빈배열[] 이다. 이러면 useEffect는 화면에 처음 마운트 되었을때만 렌더링을 실행한다. state, props가 변경이 되어도 한번만 실행이 된다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;다음 useEffect의 의존성 배열에는 [counter] state가 들어있는데, 이는 state의 값이 변경될 때 리렌더링이 발생하는 의미이다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;clean up 함수&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;clean up 함수는 말그대로 정리를 위한 함수이다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;위 예제에서 외부 API 등록하거나, setTimeout등 useEffect 안에서 side effect가 실행이 되면 브라우저는 메모리 누수가 생길 수 있다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;무조건 적으로 useEffect를 사용했다고 해서 clean up 함수를 사용해야 하는 것은 아니다.&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;useEffect 조금더 깊이 파보기&amp;nbsp;&lt;br /&gt;&lt;br /&gt;- useEffect가 side effect를 처리하기 위해서 만든거는 알겠다..   근데 side effect나 이런 외부에서 일어나는 일들을 리액트 내부에서 처리하지 않을까??&amp;nbsp;&lt;br /&gt;- 이는 React가 순수하지 않은 작업을 commit Phase 단계에서 실행을 하고 싶어서라고 한다.&amp;nbsp;&lt;br /&gt;- React는 &lt;b&gt;UI 렌더링과 side effect를 완전히 분리&lt;/b&gt;하고 싶어 한다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;이유는 리액트 렌더링 단계에서 렌더 단계에서 JSX를 계산할 때 예측 가능한 렌더링을 유지하고 싶어서 라고 한다.&amp;nbsp;&lt;br /&gt;이로 인해 side effect가 일어나는 경우 렌더 단계에서 실행이 되는 것이 아니라 커밋 단계에서 실행이 된다 !!!&amp;nbsp;&lt;br /&gt;&lt;br /&gt;  그렇다면 모든 훅들???도 커밋 단계에서 실행이 될까???&amp;nbsp;&lt;br /&gt;- 정답은 모든 훅은 아니다. side effect를 일으키지 않는 훅들은 커밋 단계가 아닌 렌더 단계에서 실행이 된다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;useEffect =&amp;gt; Passive Effect?&amp;nbsp;&lt;br /&gt;- 리액트에서 useEffect를 수동 효과 라고도 하는데 브라우저의 DOM 업데이트와 페인팅을 모두 끝난 후에 비동기로 실행이 되기 때문이다.&amp;nbsp;&lt;br /&gt;- 리액트에서 내부적으로 Fiber Tree를 돌면서 모든 useEffect들을 한번에 실행하지 않고 queue에 넣어둔다. 이를 Passive Effect Queue라고 한다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;  정리&lt;/h1&gt;
&lt;h2 data-end=&quot;293&quot; data-start=&quot;252&quot; data-ke-size=&quot;size26&quot;&gt;1️⃣ useState &amp;mdash; 상태의 저장소, Fiber에 의존하는 이유&lt;/h2&gt;
&lt;h3 data-end=&quot;304&quot; data-start=&quot;295&quot; data-ke-size=&quot;size23&quot;&gt;  정의&lt;/h3&gt;
&lt;p data-end=&quot;359&quot; data-start=&quot;305&quot; data-ke-size=&quot;size16&quot;&gt;useState는 &lt;b&gt;컴포넌트 내부에서 상태(state)를 정의하고 관리하기 위한 훅&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre id=&quot;code_1760698944806&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useState } from 'react';

const [state, setState] = useState(initialState);&lt;/code&gt;&lt;/pre&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;592&quot; data-start=&quot;457&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;504&quot; data-start=&quot;457&quot;&gt;initialState: 초기 상태 값 (넘기지 않으면 undefined)&lt;/li&gt;
&lt;li data-end=&quot;592&quot; data-start=&quot;505&quot;&gt;반환값: [state, setState]
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;592&quot; data-start=&quot;534&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;552&quot; data-start=&quot;534&quot;&gt;state: 현재 상태 값&lt;/li&gt;
&lt;li data-end=&quot;592&quot; data-start=&quot;555&quot;&gt;setState: 상태를 갱신하고 리렌더링을 트리거하는 함수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;2030&quot; data-start=&quot;1983&quot; data-ke-size=&quot;size26&quot;&gt;2️⃣ useEffect &amp;mdash; 부수효과(Side Effect)를 관리하는 메커니즘&lt;/h2&gt;
&lt;h3 data-end=&quot;2041&quot; data-start=&quot;2032&quot; data-ke-size=&quot;size23&quot;&gt;  정의&lt;/h3&gt;
&lt;p data-end=&quot;2100&quot; data-start=&quot;2042&quot; data-ke-size=&quot;size16&quot;&gt;useEffect는 &lt;b&gt;렌더링 과정에서 발생하는 부수효과(side effect)&lt;/b&gt;를 다루는 훅이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1760699423818&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
// 부수효과 로직
return () =&amp;gt; { // cleanup 로직 };
}, [deps]);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2298&quot; data-start=&quot;2197&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2214&quot; data-start=&quot;2197&quot;&gt;첫 번째 인수: 실행할 함수&lt;/li&gt;
&lt;li data-end=&quot;2298&quot; data-start=&quot;2215&quot;&gt;두 번째 인수: 의존성 배열(deps)
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2298&quot; data-start=&quot;2243&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2267&quot; data-start=&quot;2243&quot;&gt;[]: 최초 마운트 시 한 번만 실행&lt;/li&gt;
&lt;li data-end=&quot;2298&quot; data-start=&quot;2270&quot;&gt;[state]: 해당 값이 바뀔 때마다 실행&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>리액트</category>
      <author>도니도니도니도니</author>
      <guid isPermaLink="true">https://gse06044.tistory.com/78</guid>
      <comments>https://gse06044.tistory.com/78#entry78comment</comments>
      <pubDate>Fri, 17 Oct 2025 20:11:01 +0900</pubDate>
    </item>
  </channel>
</rss>