<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>예외 처리</title>
    <link>https://unhandled.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 25 May 2026 03:08:24 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>unhandled</managingEditor>
    <image>
      <title>예외 처리</title>
      <url>https://tistory1.daumcdn.net/tistory/7548393/attach/2945db941cfa4f258d3686bd079d7412</url>
      <link>https://unhandled.tistory.com</link>
    </image>
    <item>
      <title>08. 개인 포트폴리오 프로젝트 상세 모달 구현하기</title>
      <link>https://unhandled.tistory.com/entry/08-%EA%B0%9C%EC%9D%B8-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%81%EC%84%B8-%EB%AA%A8%EB%8B%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;bull;&amp;nbsp;&lt;span&gt; Next.js이 제공하는 모달 구현 방법&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next.js는 Intercepting Routes와 Parallel Routes라는 두 가지 기능을 통해 손쉽게 모달을 구현할 수 있게 해 줍니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1750478511648&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;File-system conventions: Intercepting Routes | Next.js&quot; data-og-description=&quot;Use intercepting routes to load a new route within the current layout while masking the browser URL, useful for advanced routing patterns such as modals.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes&quot; data-og-url=&quot;https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/jZmeH/hyY79K0Rpz/wu0JggtWGMd2KXkkJAyaWK/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/bNPSZD/hyY8NA4uPx/1oXLXhFLQK33JV4xfjiWB0/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/EyrZh/hyZcriNHAm/vhL7p8kQzeci1l2dIxYDqk/img.png?width=1600&amp;amp;height=976&amp;amp;face=0_0_1600_976&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/jZmeH/hyY79K0Rpz/wu0JggtWGMd2KXkkJAyaWK/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/bNPSZD/hyY8NA4uPx/1oXLXhFLQK33JV4xfjiWB0/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/EyrZh/hyZcriNHAm/vhL7p8kQzeci1l2dIxYDqk/img.png?width=1600&amp;amp;height=976&amp;amp;face=0_0_1600_976');&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;File-system conventions: Intercepting Routes | Next.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Use intercepting routes to load a new route within the current layout while masking the browser URL, useful for advanced routing patterns such as modals.&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 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;size18&quot;&gt;&lt;b&gt; Parallel Routes(패러렐 라우트)&lt;/b&gt;&lt;/p&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 data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Intercepting Routes(인터셉팅 라우트)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로를 가로채서 다른 페이지를 보여주는 기능입니다.&amp;nbsp;아래 캡처를 보시면 주소창 url과 응답 헤더의 X-Nextjs-Rewritten-Path가 다른 것을 확인하실 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;모달4.png&quot; data-origin-width=&quot;3866&quot; data-origin-height=&quot;2250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0zOoy/btsOL2g5sEb/4ruOnZm6VCLm60WFyLE5AK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0zOoy/btsOL2g5sEb/4ruOnZm6VCLm60WFyLE5AK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0zOoy/btsOL2g5sEb/4ruOnZm6VCLm60WFyLE5AK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0zOoy%2FbtsOL2g5sEb%2F4ruOnZm6VCLm60WFyLE5AK%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;3866&quot; height=&quot;2250&quot; data-filename=&quot;모달4.png&quot; data-origin-width=&quot;3866&quot; data-origin-height=&quot;2250&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;&lt;b&gt;주소창&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/project/1&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;Response Headers의 X-Nextjs-Rewritten-Path&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;/(.)project/:id&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;주소창에는 /project/1이 떠있지만 실제로 보여주는 것(모달)은 /(.)project/:id에 대응되는 페이지인 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;bull;&amp;nbsp;&lt;span&gt; &lt;/span&gt;&lt;/b&gt;&lt;b&gt;모달 구현 후&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;파일 구조는 아래와 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;모달1.png&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;952&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ejJiVA/btsOKz8hajU/n9Rt3ueUqZYvp2AIklLXB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ejJiVA/btsOKz8hajU/n9Rt3ueUqZYvp2AIklLXB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ejJiVA/btsOKz8hajU/n9Rt3ueUqZYvp2AIklLXB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FejJiVA%2FbtsOKz8hajU%2Fn9Rt3ueUqZYvp2AIklLXB1%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;353&quot; height=&quot;390&quot; data-filename=&quot;모달1.png&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;952&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;i&gt;&lt;b&gt;1번 - 모달&amp;nbsp;&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(app/@modal/(.)project/[id]/page.tsx)&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;/project/1과 같은 경로에 Link, Router와 같은 &lt;b&gt;클라이언트 사이드 내비게이션&lt;/b&gt;을 통해 접근할 때 보여주는 페이지 컴포넌트입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(.)은 대응되는 페이지 컴포넌트와의 경로 레벨 차를 의미합니다. 같은 레벨의 경우 (.)를 추가합니다. @modal 폴더(Parallel Routes)는 고려되지 않으므로 위 이미지의 1번과 2번은 같은 레벨입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;모달2.png&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btPaMV/btsOKGsBgHE/LBbEbqfb5tj7oZbKwxwkuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btPaMV/btsOKGsBgHE/LBbEbqfb5tj7oZbKwxwkuk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btPaMV/btsOKGsBgHE/LBbEbqfb5tj7oZbKwxwkuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtPaMV%2FbtsOKGsBgHE%2FLBbEbqfb5tj7oZbKwxwkuk%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;682&quot; height=&quot;83&quot; data-filename=&quot;모달2.png&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;100&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;i&gt;&lt;b&gt;2번 - 페이지&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;(app/project/[id]/page.tsx)&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;/project/1과 같은 경로에 주소창 접근 또는 새로 고침을 통한 접근(&lt;b&gt;풀 페이지 로드) 시&lt;/b&gt; 보여주는 페이지 컴포넌트입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1번 &lt;b&gt;모달이 떠있는 상태에서 새로고침&lt;/b&gt;을 했을 때, 그리고 &lt;b&gt;1번을 통한 클라이언트 사이드 내비게이션이 모종의 이유로 실패했을 경우&lt;/b&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;만약 1번은 있지만 2번은 구현하지 않은 상태라면 모달이 떠있는 상태에서 새로고침시 404 페이지를 마주할 것입니다.&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번과 2번은 그 내용물을 완전히 다르게 구현할 수 있습니다. (같은 경로지만 사용자의 접근 방식에 따라 각기 다른 컨텐츠 제공이 가능한 것입니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;bull;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;b&gt;이러한 방식으로 구현한 모달의 장점&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 기능의 대표적인 장점이자 기존 상태관리 방식으로 구현한 모달과의 차이점은 각각의 모달이 독자적인 url 경로를 가질 수 있다는 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;많은 이커머스 웹사이트에서 고객들이 남긴 상품 후기나 상품 후기 이미지를 모달의 형태로 보여줍니다. 이때 &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;모달을 보다가 무의식적으로 뒤로 가기 버튼을 누르면 모달창만 꺼지는 게 아니라 아예 판매 상품 페이지를 벗어나 이전 페이지로 이동해 버립니다. 그럼 고객들은 다시 상품 상세 페이지로 들어와야 하는 번거로움을 겪거나 아예 벗어났으니 돌아오지 않는(이탈해 버리는) 상황이 발생할 수 있습니다. &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;하지만 독자적인 url 경로를 갖는 모달은 모달이 떠있는 상태에서 뒤로 가기 버튼을 눌러도 모달만 꺼지고 원래 보고 있던 상품 상세 페이지는 유지됩니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;사실 쇼핑몰들의 위와 같은 기존의 방식이 고객들에게 큰 불편함을 안겨주는 것은 아닙니다. 대부분 어둡게 오버레이 된 모달 바깥 부분을 누르면 모달만 꺼지게 되어있고 사용자들도 직관적으로 그렇게 동작하길 기대합니다. 게다가&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt; 이러한 상품 후기 모달은 보통 모니터 화면을 크게 차지하지 않습니다.&lt;span&gt; 이 말은 사용자가 모달을 끄기 위해 마우스 커서를 뒤로 가기 버튼까지 이동시키는 것보다 그냥 오버레이를 누를 가능성이 더 높다는 것입니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&lt;span&gt;그럼에도 웹사이트 내비게이션에 있어 키보드 단축키나 마우스의 뒤로가기/앞으로가기 버튼을 주로 사용하는 사람들도 있으므로&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;nbsp;아주 세세한 부분의 UX까지 신경 쓰기 위해 추가된 기능인 것 같습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;이는 이커머스의 상품 후기뿐만 아니라 피드 기능이 존재하는 커뮤니티나 SNS(인스타그램 등)을 고려하여 출시된 기능 같아 보였습니다. 그리고&lt;/span&gt;&lt;span&gt; 독자적인 URL이 존재하므로 모달 컨텐츠(상품 리뷰이든 게시글이든)의 공유가 편하고 SEO에도 유리하다는 장점도 언급할 수 있을 것 같습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;bull;&amp;nbsp;&lt;span&gt; 프로젝트 데이터 관련 화면&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제 포트폴리오에서는 현재 프로젝트 데이터를 세 곳에서 보여주고 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(각각 주소창 경로를 확인해 주세요!)&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 메인 페이지의 프로젝트 섹션 (/)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;메인페이지_프로젝트.png&quot; data-origin-width=&quot;1073&quot; data-origin-height=&quot;1167&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQ2OrF/btsOK4s3ebT/ljqqyHqMp3Q2vuKQKrQsek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQ2OrF/btsOK4s3ebT/ljqqyHqMp3Q2vuKQKrQsek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQ2OrF/btsOK4s3ebT/ljqqyHqMp3Q2vuKQKrQsek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQ2OrF%2FbtsOK4s3ebT%2FljqqyHqMp3Q2vuKQKrQsek%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;567&quot; height=&quot;1167&quot; data-filename=&quot;메인페이지_프로젝트.png&quot; data-origin-width=&quot;1073&quot; data-origin-height=&quot;1167&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 프로젝트 상세 모달 (/project/[id])&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(현재 CSS 수정 중이라 아래 캡처는 모달이 화면에 거의 꽉 차 보이는 상태입니다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;모달_프로젝트.png&quot; data-origin-width=&quot;1371&quot; data-origin-height=&quot;1162&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ARC0X/btsOKSzERkT/WrLU9rFF2ZSaPbpoBPKk81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ARC0X/btsOKSzERkT/WrLU9rFF2ZSaPbpoBPKk81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ARC0X/btsOKSzERkT/WrLU9rFF2ZSaPbpoBPKk81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FARC0X%2FbtsOKSzERkT%2FWrLU9rFF2ZSaPbpoBPKk81%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;594&quot; height=&quot;503&quot; data-filename=&quot;모달_프로젝트.png&quot; data-origin-width=&quot;1371&quot; data-origin-height=&quot;1162&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;&lt;b&gt;3. 프로젝트 상세 페이지 (/project/[id])&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;상세_프로젝트.png&quot; data-origin-width=&quot;1126&quot; data-origin-height=&quot;1082&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3wWRh/btsOKK9o1uJ/yuvxXmX8bh9OVeDcwnkcA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3wWRh/btsOKK9o1uJ/yuvxXmX8bh9OVeDcwnkcA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3wWRh/btsOKK9o1uJ/yuvxXmX8bh9OVeDcwnkcA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3wWRh%2FbtsOKK9o1uJ%2FyuvxXmX8bh9OVeDcwnkcA1%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;598&quot; height=&quot;575&quot; data-filename=&quot;상세_프로젝트.png&quot; data-origin-width=&quot;1126&quot; data-origin-height=&quot;1082&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;사실 완전히 똑같은 정보를 세 곳에서나 보여줄 필요는 없습니다. 다만 프로젝트 상세 페이지는 모달이 떠있는 상태에서 새로고침을 하는 경우와 클라이언트 사이드 내비게이션이 실패해서 풀 페이지 로드로 전환되는 경우도 고려해야 하니 그냥 남겨두기로 했습니다. 메인 페이지의 프로젝트 섹션은 다른 둘과 다르게 UI를 좀 더 심플하게 가져가고, 프로젝트 상세 모달과 페이지는 이와 반대로 컨텐츠를 좀 더 추가할 계획입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&amp;bull;&amp;nbsp; 앞으로의 계획&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 관련 컴포넌트들을 수정하고 전체적인 코드를 리팩토링하는 과정은 계속 이어질 것 같습니다. 그래도 이제 절반 이상은 거뜬히 온 것 같군요! 사실 블로그 글은 실제 진행 단계보다 느리고 모든 변경 사항에 대해 포스팅하지는 않고 있습니다. 앞으로는 글은 보다 간략하게 작성하고 프로젝트 진전을 위해 개발에 집중하려고 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>개인 프로젝트/Portfolio</category>
      <category>intercepting routes</category>
      <category>parallel routes</category>
      <category>개인 포트폴리오</category>
      <category>모달</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/55</guid>
      <comments>https://unhandled.tistory.com/entry/08-%EA%B0%9C%EC%9D%B8-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%81%EC%84%B8-%EB%AA%A8%EB%8B%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry55comment</comments>
      <pubDate>Sat, 21 Jun 2025 15:55:54 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 2 (feat. Mutation Observer)</title>
      <link>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-2-feat-Mutation-Observer</link>
      <description>&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15.2.4 버전을 기준으로 작성되었으며 제가 직접 진행한 실험을 토대로 개인적인 추측과 가설이 포함되어 있습니다. 상황의 이해를 위해 아래 링크된 이전 글을 읽고 오시길 권해드립니다.)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;재발한 이슈&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 포트폴리오의 내비게이션 바를 구현하면서 발생한 이슈입니다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;다른 페이지(/project/1)에서 메인 페이지(/)로 router.push의 방식으로 이동하면 다시 인터섹션 옵저버가 id를 감시하지 못합니다.&amp;nbsp;&amp;nbsp;&lt;/span&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;어? 이건 지난 글에서 나온 현상과 동일한데요? 새로고침 후에 옵저버가 감시할 id를 찾지 못해서 스크롤 이동을 감지하지 못하는 현상 말입니다. 그래서 그 당시 id도 비동기 서버 컴포넌트 밖으로 빼는 것으로 해결했었죠.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749866717426&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)&quot; data-og-description=&quot;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.) 지난 &quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/blF49x/hyY8KpngLf/9It8BfzMH2Vy0vQIJsVNIk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fg6yh/hyY71EObFR/JUOghoFNrSustISb6UMrK1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/blF49x/hyY8KpngLf/9It8BfzMH2Vy0vQIJsVNIk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/fg6yh/hyY71EObFR/JUOghoFNrSustISb6UMrK1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.) 지난&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;저 글 작성 시에는 주소창 접근(Full page load)의 방식으로 접근할 때의 문제는 해결했는데, 이번에는 클라이언트 사이드 내비게이션(router.push)을 사용해서 해당 페이지로 들어가니 해결된 줄 알았던 버그가 다시 등장한 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;못찾음.png&quot; data-origin-width=&quot;375&quot; data-origin-height=&quot;100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CMRXk/btsOCBp2coO/FnAuBAJXpserRNHtODodx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CMRXk/btsOCBp2coO/FnAuBAJXpserRNHtODodx1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CMRXk/btsOCBp2coO/FnAuBAJXpserRNHtODodx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCMRXk%2FbtsOCBp2coO%2FFnAuBAJXpserRNHtODodx1%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;375&quot; height=&quot;100&quot; data-filename=&quot;못찾음.png&quot; data-origin-width=&quot;375&quot; data-origin-height=&quot;100&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;심지어 이번 경우에는 지난번에는 찾았던 about-me-title의 id 조차 찾을 수 없었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 사라졌던 현상이 다시 보였는가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히도 이번 이슈의 원인은 금방 찾을 수 있었습니다. router.push와 같은 클라이언트 사이드 내비게이션으로 페이지를 이동하는 건 풀 페이지 로드와 달리 변경할 부분만 서버로부터 RSC payload를 받아서 갈아끼웁니다. (SPA의 방식처럼) 이번 같은 경우에는 이미 해당 페이지의 내비게이션 바 부분은 로드가 되어있으니 서버로부터 새로 받아올 필요가 없습니다. 내비게이션 바를 포함한 레이아웃은 그대로 유지하고, 내비게이션 바 아래의 부분만 갈아끼웁니다. 그러므로 지난번처럼 내비게이션 바 컴포넌트에 대한 자바스크립트 코드 또한 서버로부터 새로 받아올 필요가 없습니다. 그러므로 내비게이션 바의 자바스크립트가 풀 페이지 로드 시보다 더 빨리 실행될 수 있었던 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;범인은?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이번 이슈는 지난 글에서 다룬 이슈와는 차이가 있습니다. 지난번 이슈(풀 페이지 로드)는 터보팩이나 스트리밍 중 하나만 비활성화하면 발생하지 않는 문제였습니다. (저는 둘 다 하지 않고 id를 빼는 방식으로 해결했지만요) 그런데 이번엔 전적으로 스트리밍 사용 여부에 달려있었습니다. loading.tsx가 추가되어 스트리밍이 활성화되어 있는 상태라면 터보팩을 꺼도 해결이 되지 않습니다. 이 현상으로 보아 자바스크립트의 실행을 앞당기는데 더 큰 영향을 끼치는 요인은 터보팩이 아니라 스트리밍인 것 같습니다. 지난 글에서는 터보팩이 범인이고 스트리밍은 공범이라고 했는데, 사실상 &lt;b&gt;스트리밍이 주범&lt;/b&gt;이고 &lt;b&gt;터보팩이 공범&lt;/b&gt;인 것으로 봐야겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;프로덕션 환경&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 증상은 프로덕션 환경에서는 보이지 않았습니다. (전 15.2.x 버전을 사용 중이고, 이 버전에서는 프로덕션 환경에서 터보팩이 적용되지 않습니다.) 그런데 로컬에서 터보팩을 사용하지 않아도 발생하는 문제라면, 배포 환경에서는 왜 발생하는지 않은 걸까요? 터보팩을 사용하지 않고 스트리밍은 활성화되었다는 점은 동일한데?&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;이건 개발-프로덕션 환경의 렌더링 방식의 차이 때문입니다. 프로덕션 환경에서 정적 렌더링 되는 페이지는 개발 환경에서는 동적 렌더링처럼 동작합니다. 정적 렌더링 되는 프로덕션 환경에서는 이미 빌드타임에 서버 컴포넌트의 요소들은 HTML, RSC payload의 형태로 생성되어 대기 중(풀 라우트 캐시)입니다. 정적 렌더링의 경우 이걸 서버에서 곧바로 보내줄 수 있는데, 동적 렌더링의 경우는 풀 라우트 캐시를 생성하지 않기 때문에 런타임에 서버 데이터를 받아오고 그걸 기준으로 RSC를 생성하는 과정이 추가됩니다. 그만큼 비동기 서버 컴포넌트의 요소들이 DOM에 들어가는 시점이 지연됩니다. 그래서 터보팩(X) + 스트리밍(O)이라는 동일한 환경에서도 다른 결과를 보여준 것입니다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결해야 하나?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;어차피 개발 환경에서만 발생하고 프로덕션 환경에서는 발생하지 않는 문제라면 굳이 해결하지 않아도 괜찮을까요? 참고로 Next.js 15.3.0 버전부터는 프로덕션 환경에서도 (실험적 기능이긴 하지만) 터보팩 사용이 활성화됩니다. 그 상황에서 이 페이지를 동적 렌더링 되는 페이지로 변경하면 프로덕션 환경에서도 동일한 양상을 보입니다. 그러므로 해결해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Mutation Observer&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번엔 돔 트리의 변화를 감지하는 Mutation Observer라는 Web API 기능을 활용하겠습니다. 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;&lt;b&gt;Navbar.tsx의 해당 부분&lt;/b&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;pre id=&quot;code_1750119575417&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mutationObserver = new MutationObserver((mutations) =&amp;gt; {
      const hasNewSections = mutations.some((mutation) =&amp;gt; {
        return Array.from(mutation.addedNodes).some((node) =&amp;gt; {
          if (node.nodeType === 1) {
            const element = node as Element;
            return SECTION_ID_ARRAY.some(
              (id) =&amp;gt; element.id === id || element.querySelector(`#${id}`)
            );
          }
          return false;
        });
      });

      if (hasNewSections) {
        SECTION_ID_ARRAY.forEach((id) =&amp;gt; {
          if (!sectionRefs.current[id]) {
            const element = document.getElementById(id);
            if (element) {
              sectionRefs.current[id] = element;
            }
          }
        });
        setIntersectionObserver();
      }
    });&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;some함수는 특정 조건을 만족하는 요소가 있는지 확인하고 있다면 참을 리턴합니다.&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;addedNodes는 뮤테이션 옵저버의 전용 프로퍼티로 &quot;추가된 노드&quot;를 감시합니다.&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;node.nodeType === 1이면 &amp;lt;p&amp;gt; 또는 &amp;lt;div&amp;gt;와 같은 HTML 요소 노드입니다.&amp;nbsp; &amp;lt;span&amp;gt; &amp;lt;nav&amp;gt; 등 괄호로 둘러싸인 HTML 요소는 전부 포함됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;노드타입.png&quot; data-origin-width=&quot;1006&quot; data-origin-height=&quot;602&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XHWrx/btsOEzSXIPF/wEY5keLXFjYfktUGqKwo60/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XHWrx/btsOEzSXIPF/wEY5keLXFjYfktUGqKwo60/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XHWrx/btsOEzSXIPF/wEY5keLXFjYfktUGqKwo60/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXHWrx%2FbtsOEzSXIPF%2FwEY5keLXFjYfktUGqKwo60%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;613&quot; height=&quot;367&quot; data-filename=&quot;노드타입.png&quot; data-origin-width=&quot;1006&quot; data-origin-height=&quot;602&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;pre id=&quot;code_1750119732855&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const hasNewSections = mutations.some((mutation) =&amp;gt; {
  return Array.from(mutation.addedNodes).some((node) =&amp;gt; {
    if (node.nodeType === 1) {
      const element = node as Element;
        return SECTION_ID_ARRAY.some(
          (id) =&amp;gt; element.id === id || element.querySelector(`#${id}`)
        );
      }
    return false;
  });
});&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;중첩된 3개의 some 함수는 다음과 같이 동작합니다.&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;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;YES - 2번으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NO - 거짓 반환&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;2. HTML 요소인가? (nodeType이 1인가?)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;YES - 3번으로&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;NO - 거짓 반환&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;3. SECTION_ID_ARRAY 배열의 값과 element의 id가 동일하거나(element.id === id) 새로 추가된 element의 자식 중 우리가 찾는 id를 가진 요소가 있는가? (element.querySelector(`#${id}`))&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;YES - 참 반환&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;NO - 거짓 반환&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;그러니까 위 코드는 새로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;추가&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;된&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;HTML&amp;nbsp;태그 요소&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이면서 그&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;요소에 부여된 id&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;가 제가 처음에 지정했던&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;id배열 (SECTION_ID_ARRAY)&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;중 하나와 일치하면 참을 반환합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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;아래 코드는 뮤테이션 옵저버가 감시할 대상을 지정하여 감시하라고 명령하는 코드입니다. document의 body 태그를 감시하고, 그의 childList(직계 자식)와 subtree(모든 자손)까지 감시합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1750119305075&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;mutationObserver.observe(document.body, {
 childList: true,
 subtree: true,
 });&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;위에서 참을 반환한 경우 (hasNewSections가 참인 경우) 처음에 지정한 ID 배열을 순회해서 sectionRefs(감시 객체)에 3가지 id요소가 저장되어 있는지 확인하고 존재하지 않는 id요소가 있다면 sectionRefs에 그 id 요소를 넣습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1750120546068&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (hasNewSections) {
  SECTION_ID_ARRAY.forEach((id) =&amp;gt; {
    if (!sectionRefs.current[id]) {
      const element = document.getElementById(id);
        if (element) {
          sectionRefs.current[id] = element;
        }
      }
    });
  setIntersectionObserver();
}&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;그리고 setIntersectionObserver 함수를 실행하면&lt;/p&gt;
&lt;pre id=&quot;code_1750120765572&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function setIntersectionObserver() {
  SECTION_ID_ARRAY.forEach((id) =&amp;gt; {
    const el = sectionRefs.current[id];
      if (el) {
      observer.observe(el);
    }
  });
}&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;인터섹션 옵저버가 다시 감시할 대상을 sectionRefs 객체에서 찾고, 그 요소가 존재하면 감시하기 시작합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마이그레이션의 여지&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 뮤테이션 옵저버는 추가되는 모든 DOM 요소를 감지하기 때문에 성능 이슈를 발생할 여지가 있습니다.&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;size18&quot;&gt;&lt;b&gt;1. 전부 찾으면 뮤테이션 옵저버 종료&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1750121437680&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const allSectionsFound = SECTION_ID_ARRAY.every(
  (id) =&amp;gt; sectionRefs.current[id]
);

if (allSectionsFound) {
  mutationObserver.disconnect();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 클린업&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;리액트 컴포넌트가 언마운트될 때 인터섹션 옵저버나 뮤테이션 옵저버의 감시는 저절로 해제되지 않습니다. 이는 메모리 누수나 성능 저하를 발생시킬 수 있으므로 해당 내비게이션 바 컴포넌트가 언마운트될 때 각 옵저버의 감시도 끊어줍니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1750121507519&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; return () =&amp;gt; {
    observer.disconnect();
    mutationObserver.disconnect();
  };&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. 다른 방법 고려&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내비게이션 바에서 일일이 DOM의 변화를 감시하는 게 아니라 컴포넌트가 마운트 되고 DOM 요소가 추가되면 그 변경사항을 관리하는 다른 무언가(context API나 상태관리 라이브러리)를 만들고, 내비게이션 바에서 이를 참조해서 동작 트리거(인터섹션 옵저버가 감시 대상을 지정하는 시점)를 유발할 수도 있습니다.&amp;nbsp;&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&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;사실 이 모든 일이 스크롤 스파이 기능을 구현하는 것에서 시작했습니다. 내비게이션 바 버튼이 스크롤 스파이 기능과 연결되어 상태가 변하고, 지정한 섹션까지 스크롤을 이동시키는 기능까지 맡았으며, 제가 포트폴리오라는 주제 특성상 모든 것을 한 페이지에 최대한 보여주려고 했기 때문에 발생한 것입니다. 보통 내비게이션 바의 버튼은 다른 페이지로 이동시키는 용도로 사용하는 경우가 더 일반적이니까요.&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;이 문제는 만약 순수 리액트로만 프로젝트를 구축했었더라면 발생했을까요? 제 생각에는 아마 처음부터 id를 찾지 못하는 상황 자체가 발생하지 않았을 것 같습니다.&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-filename=&quot;제미나이_계란_알.png&quot; data-origin-width=&quot;1676&quot; data-origin-height=&quot;140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJzCj5/btsODpDLzq2/kVA1i37hMMatLeCDIi6WQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJzCj5/btsODpDLzq2/kVA1i37hMMatLeCDIi6WQ1/img.png&quot; data-alt=&quot;Gemini의 한마디&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJzCj5/btsODpDLzq2/kVA1i37hMMatLeCDIi6WQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJzCj5%2FbtsODpDLzq2%2FkVA1i37hMMatLeCDIi6WQ1%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;776&quot; height=&quot;65&quot; data-filename=&quot;제미나이_계란_알.png&quot; data-origin-width=&quot;1676&quot; data-origin-height=&quot;140&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Gemini의 한마디&lt;/figcaption&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;Next.js를 사용하면 이전에는 고려하지 않아도 되었던 부분들을 고려해야 되는 것 같습니다. 그로 인해 더 많은 것을 경험해 볼 수 있다는 점은 좋지만 이럴 때마다 Bottom-up 방식으로 문제를 해결해 나가야 하는 듯한 느낌을 받았습니다. 핵심 원인이 무엇인지 불분명하기에 Bottom-up 방식을 사용하는 것은 당연한 것이고 이게 좋지 않은 방법이라는 것이 아닙니다. 다만 변수 통제 후 실험을 통해 원인을 직접 확인하고 싶어도 고려해야 하는 변수가 너무 많아서 테스트가 수월하지 않습니다. App Router는 앞으로 이러한 부분들을 개선해 나갈 수 있을까요?&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;</description>
      <category>FE/Next.js</category>
      <category>App router</category>
      <category>Intersection Observer</category>
      <category>mutation observer</category>
      <category>Streaming</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/54</guid>
      <comments>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-2-feat-Mutation-Observer#entry54comment</comments>
      <pubDate>Tue, 17 Jun 2025 11:11:28 +0900</pubDate>
    </item>
    <item>
      <title>07. 개인 포트폴리오 내비게이션 바 구현하기</title>
      <link>https://unhandled.tistory.com/entry/07-%EA%B0%9C%EC%9D%B8-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EB%82%B4%EB%B9%84%EA%B2%8C%EC%9D%B4%EC%85%98-%EB%B0%94-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;bull;&amp;nbsp;&lt;span&gt; Navigation Bar&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; 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-filename=&quot;포커스.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qJiut/btsOBPaTkoB/FY1QDDJEpKCE4BubWrTIuK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qJiut/btsOBPaTkoB/FY1QDDJEpKCE4BubWrTIuK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qJiut/btsOBPaTkoB/FY1QDDJEpKCE4BubWrTIuK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqJiut%2FbtsOBPaTkoB%2FFY1QDDJEpKCE4BubWrTIuK%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;940&quot; height=&quot;72&quot; data-filename=&quot;포커스.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;72&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제가 구현하려 했던 내비게이션 바의 기능은 다음과 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. 내비게이션 바의 특정 버튼 클릭 시 그 섹션으로 스크롤 이동&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 현재 스크롤이 특정 섹션 안에 들어오면 그를 감지하여 내비게이션 바 포커스(CSS) 변경&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 중에서 2번이 바로 Scroll Spy(스크롤 스파이) 기능인데 이를 구현하기가 1번에 비해 꽤나 까다로웠습니다. 처음에는 스크롤이 움직일 때마다 위치를 검사하는 로직을 사용했는데, 이로 인한 성능 저하를 보완하기 위한 쓰로틀링 코드까지 추가되면서 코드가 많이 복잡해졌죠. 무엇보다 이때의 코드는 제가 이해하고 구현했다고 말할 수 있는 코드가 아니었습니다. 그래서 언젠가는 수정을 해야겠다고 생각하고 있었습니다. 그러다가 Intersection Observer라는 Web API를 사용하면 상대적으로 간단하게 구현할 수 있다는 것을 알게 되었고 그것을 사용하는 방식으로 코드를 변경했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;bull;&amp;nbsp;&lt;span&gt;&lt;span&gt; Intersection Observer&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터섹션 옵저버는 감시하는 대상이 뷰포트에 몇 퍼센트(대상의 전체 면적 중)가 들어와 있는지를 감지하여 트리거를 동작시키는 Web API입니다.&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;하지만 사실 이 방식도 그다지 평탄하진 않았습니다. Next.js의 고유한 특성으로 인한 이슈가 생겼고 이에 더해 영향을 끼치는 여러 변수들이 얽혀있어서 디버깅이 수월하지 않았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;bull;&amp;nbsp;&lt;span&gt; 3단계 트러블 슈팅&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;내비게이션 바 디버깅 과정은 최근 작성한 포스팅들에 나누어져 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. useState 사용으로 인한 Stale Closure 이슈&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&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;span style=&quot;color: #222222; text-align: start;&quot;&gt;&amp;rarr; useState 대신 useRef를 사용하여 최신값을 참조하도록 하여 해결했습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749617788806&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기&quot; data-og-description=&quot;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.) 내비게이션 바제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다. (아래 About&quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b0wWAn/hyY8ZsU3FF/Lke5d6FLHMLcLlNw5XDkK0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/ZsSFW/hyY77dBp9V/dXWSgtvtCeUn12KxvnnnMk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b0wWAn/hyY8ZsU3FF/Lke5d6FLHMLcLlNw5XDkK0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/ZsSFW/hyY77dBp9V/dXWSgtvtCeUn12KxvnnnMk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.) 내비게이션 바제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다. (아래 About&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. Intersection Obeserver의 threshold 옵션을 잘못 부여함&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;threshold 속성을 잘못 부여해서 긴 섹션(Projects)을 감지하지 못하는 현상이었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&amp;rarr; &lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;threshold를 0.0으로 수정해서 해당 섹션이 1픽셀이라도 뷰포트에 들어오면 트리거를 동작시키게 하여 해결했습니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749617806080&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Web API] Intersection Observer의 threshold 옵션이란?&quot; data-og-description=&quot;+) 이 글은 JavaScript 카테고리에 속해있지만 사실 Web API는 자바스크립트라는 언어에 국한되는 기능은 아닙니다. (ex) Web Assembly) 이 이슈도 지난 글에 언급했지만 분량이 길어질 것 같아서 분리했습&quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/Web-API-Intersection-Observer%EC%9D%98-threshold-%EC%98%B5%EC%85%98%EC%9D%B4%EB%9E%80&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/Web-API-Intersection-Observer%EC%9D%98-threshold-%EC%98%B5%EC%85%98%EC%9D%B4%EB%9E%80&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/BN7wE/hyY8QvYybz/yX6KbNN14UiDkhSYA0ole0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/EefeW/hyY8ZTZ0Oq/wWBtsD7daFUfJBbtKeQyC1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cPPYo1/hyY8Lg8Hmp/l3RzgyVyw1lknVYl6fPPLk/img.png?width=512&amp;amp;height=1414&amp;amp;face=0_0_512_1414&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/Web-API-Intersection-Observer%EC%9D%98-threshold-%EC%98%B5%EC%85%98%EC%9D%B4%EB%9E%80&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/Web-API-Intersection-Observer%EC%9D%98-threshold-%EC%98%B5%EC%85%98%EC%9D%B4%EB%9E%80&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/BN7wE/hyY8QvYybz/yX6KbNN14UiDkhSYA0ole0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/EefeW/hyY8ZTZ0Oq/wWBtsD7daFUfJBbtKeQyC1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cPPYo1/hyY8Lg8Hmp/l3RzgyVyw1lknVYl6fPPLk/img.png?width=512&amp;amp;height=1414&amp;amp;face=0_0_512_1414');&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;[Web API] Intersection Observer의 threshold 옵션이란?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;+) 이 글은 JavaScript 카테고리에 속해있지만 사실 Web API는 자바스크립트라는 언어에 국한되는 기능은 아닙니다. (ex) Web Assembly) 이 이슈도 지난 글에 언급했지만 분량이 길어질 것 같아서 분리했습&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. App Router의 여러 특성(렌더링, 스트리밍, 터보팩)들이 얽혀서 발생한 이슈 (핵심)&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;이는 데이터를 페칭하는 비동기 서버 컴포넌트 내부에 id값이 존재했고, 이로 인해&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Navbar의 useState가 실행될 때 인터섹션 옵저버가 감시할 대상을 찾아야 하는데 이때 id 값이 존재하지 않아서 생긴 이슈였습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 이슈는 앞선 1, 2번에 비해 디버깅이 까다로웠고 1, 2번 이슈를 진단하는 것에도 혼란을 가중시켰습니다. 왜냐하면 이 3번은 어떤 경우에는 &lt;b&gt;클로저 이슈가 사라진 것처럼&lt;/b&gt; 보이게 만들었고, &lt;b&gt;특정 섹션을 감지하지 못한 것은 threshold와 이 3번 둘 다 원인&lt;/b&gt;이었기 때문입니다. 이 때문에 threshold를 0.0으로 수정해도 &quot;여전히 안 되네? 그럼 threshold 때문이 아닌가?&quot;라는 착각을 하게 만들었죠. 사실상&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;threshold 옵션도 수정하고 이 3번에 대한 것도 알아내야 해결할 수 있는 문제였습니다.&amp;nbsp;&lt;/span&gt;이에 더해 무엇보다 &lt;b&gt;테스트 결과가 일관적이지 못했다는 것이&lt;/b&gt; 가장 혼란스러웠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;이 이슈는 개발 환경에서만 발생한 문제였습니다. 프로덕션 환경에서 정적 렌더링 되는 페이지는 개발 환경에서는 동적 렌더링과 유사하게 동작하고, 제 프로젝트에서의 터보팩은 개발 환경에서만 적용됩니다. (App Router 15.2.x 기준. 15.3.x 버전에는 프로덕션에도 적용됩니다.) &lt;/span&gt;이에 더해 loading.tsx로 인한 스트리밍도 영향을 끼쳤습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이건 터보팩과 스트리밍이 내비게이션 바의 자바스크립트를 빨리 실행시켜서 발생한 이슈라고 저는 추측 중입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&amp;rarr; id 값을 서버 컴포넌트 밖으로 빼내는 것으로 해결했습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;자세한 내용은 아래 링크된 글에 있습니다!&lt;/span&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1749617796365&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)&quot; data-og-description=&quot;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.) 지난 &quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0rEZ1/hyY5bhutln/6cIORtovPysxzLKeZEV2Pk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dfUS73/hyY8KWP7yc/8Hv5RdIakGP0wMz1Z72SO0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0rEZ1/hyY5bhutln/6cIORtovPysxzLKeZEV2Pk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/dfUS73/hyY8KWP7yc/8Hv5RdIakGP0wMz1Z72SO0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.) 지난&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;bull;&amp;nbsp; 앞으로의 계획&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글 이전에 마지막으로 작성한 포트폴리오 제작기 글이 벌써 한 달 전이네요. 하지만 그렇다고 포트폴리오 제작을 멈춘 것은 아니었습니다. 포트폴리오는 리팩토링도 하고 다른 기능도 추가하면서 계속 개선해 왔습니다. 게다가 지난 한 달 동안 포트폴리오 제작 못지않게 중요한 주제(캐싱 등)들에 대해 학습하고 그에 대한 포스팅을 작성했으니 저에겐 의미 있는 기간이었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다음 포스팅은 Next.js의 Intercepting Routes와 Parallel Routes 기능을 활용해 구현한 모달에 대한 것입니다. 사실 구현은 이미 완료되었으니 이제는 글을 작성하는 것만 남았습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>개인 프로젝트/Portfolio</category>
      <category>Navigation Bar</category>
      <category>개인 포트폴리오</category>
      <category>내비게이션 바</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/53</guid>
      <comments>https://unhandled.tistory.com/entry/07-%EA%B0%9C%EC%9D%B8-%ED%8F%AC%ED%8A%B8%ED%8F%B4%EB%A6%AC%EC%98%A4-%EB%82%B4%EB%B9%84%EA%B2%8C%EC%9D%B4%EC%85%98-%EB%B0%94-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry53comment</comments>
      <pubDate>Fri, 13 Jun 2025 21:53:48 +0900</pubDate>
    </item>
    <item>
      <title>[Web API] Intersection Observer의 threshold 옵션이란?</title>
      <link>https://unhandled.tistory.com/entry/Web-API-Intersection-Observer%EC%9D%98-threshold-%EC%98%B5%EC%85%98%EC%9D%B4%EB%9E%80</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;+) 이 글은 JavaScript 카테고리에 속해있지만 사실 Web API는 자바스크립트라는 언어에 국한되는 기능은 아닙니다. (ex) Web Assembly)&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749447474589&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기&quot; data-og-description=&quot;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.) 내비게이션 바제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다. (아래 About&quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/mSsN0/hyY5d0fLMa/0Tr8ZLpHXaKi9jl5CAM6j1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/EZyys/hyY4495FkO/tHDy0KAq16mNx3N6QvPfyk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/mSsN0/hyY5d0fLMa/0Tr8ZLpHXaKi9jl5CAM6j1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/EZyys/hyY4495FkO/tHDy0KAq16mNx3N6QvPfyk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.) 내비게이션 바제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다. (아래 About&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 매우 간단합니다. 제가 Intersection Observer의 threshold 옵션을 잘못 이해하고 있었고, 그걸 정정했다는 내용의 글입니다.&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;b&gt;threshold 옵션은 무엇인가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Navbar.tsx의 useEffect 훅 내부 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749447663317&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; useEffect(() =&amp;gt; {
    SECTION_ID_ARRAY.forEach((id) =&amp;gt; {
      sectionRefs.current[id] = document.getElementById(id);
    });
    const observer = new IntersectionObserver(
      (entries) =&amp;gt; {
        entries.forEach((entry, index) =&amp;gt; {
          if (entry.isIntersecting) {
            if (!doNotChangeRef.current) {
              setActiveSection(entry.target.id);
            }
          }
        });
      },
      {
        root: null,
        // 바로 이 부분
        threshold: 0.0,
      }
    );
    SECTION_ID_ARRAY.forEach((id) =&amp;gt; {
      console.log(&quot;Cleanup 실행됨&quot;);
      const el = sectionRefs.current[id];
      if (el) {
        observer.observe(el);
      }
    });
    return () =&amp;gt; observer.disconnect();
  }, []);&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;threshold가 무엇인지 찾아보면 감시하는 대상의 &lt;b&gt;가시성&lt;/b&gt;에 기반하여 인터섹션 옵저버를 트리거하는 설정이라고 합니다. 가시성은 &lt;b&gt;보일 수 있는 정도&lt;/b&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;threshold 옵션에 대한 오해&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 초반에는 threshold 옵션에 대해 착각하고 있었습니다. 그러다 보니 이런 오해들도 했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;threshold: 0.3 이라는 수치를 주었다고 가정합니다. 앗..지금 생각해보니까 3분의 1이 아니라 30%이군요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이미 시각자료를 3분의 1 기준으로 만들어버렸으니 혼동을 방지하기위해 &lt;b&gt;threshold: 0.33 &lt;/b&gt;이란 수치를 주었다고 다시 가정합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해 1. &lt;/b&gt;threshold는 &lt;b&gt;감시하는 요소의 3분의 1 지점이&lt;/b&gt; 뷰포트에 보이기 시작한 순간이다. (X)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;threshold_misunderstand_1.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sBjYZ/btsOurgt0wR/ZSSkhU3PjLhvAIdG6K7eQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sBjYZ/btsOurgt0wR/ZSSkhU3PjLhvAIdG6K7eQK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sBjYZ/btsOurgt0wR/ZSSkhU3PjLhvAIdG6K7eQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsBjYZ%2FbtsOurgt0wR%2FZSSkhU3PjLhvAIdG6K7eQK%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;312&quot; height=&quot;720&quot; data-filename=&quot;threshold_misunderstand_1.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1182&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;&lt;b&gt;오해 2. &lt;/b&gt;threshold는 &lt;b&gt;뷰포트의 3분의 1 지점에&lt;/b&gt; 감시하는 요소가 보이기 시작한 순간이다. (X)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;threshold_misunderstand_2.png&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;1182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsSPWY/btsOugzvZvZ/KTK35274xvw3esOnlnrkk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsSPWY/btsOugzvZvZ/KTK35274xvw3esOnlnrkk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsSPWY/btsOugzvZvZ/KTK35274xvw3esOnlnrkk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsSPWY%2FbtsOugzvZvZ%2FKTK35274xvw3esOnlnrkk0%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;312&quot; height=&quot;685&quot; data-filename=&quot;threshold_misunderstand_2.png&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;1182&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;사실 둘 다 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정확한 개념&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Intersection Observer의 threshold 옵션은&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;감시하고 있는 요소의 전체 면적의 몇 %가 현재 뷰포트 내에 보여야 트리거를 발동시킬 것인가?&amp;nbsp;&lt;/b&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;를 기준으로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;포트폴리오의 첫 페이지&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제로 제 포트폴리오의 랜딩 페이지(/)는 이렇게 구성되어 있습니다. 프로젝트 섹션이 다른 두 섹션에 비해 훨씬 깁니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;threshold_1.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cX1lyM/btsOsy9xvzV/rkbiu8AdPbjQuvLqRSWkPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cX1lyM/btsOsy9xvzV/rkbiu8AdPbjQuvLqRSWkPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cX1lyM/btsOsy9xvzV/rkbiu8AdPbjQuvLqRSWkPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcX1lyM%2FbtsOsy9xvzV%2Frkbiu8AdPbjQuvLqRSWkPK%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;182&quot; height=&quot;698&quot; data-filename=&quot;threshold_1.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;threshold: 0.3을 부여하면 어떻게 되는가&lt;/b&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;는 우리가 한 화면에서 볼 수 있는 &lt;b&gt;뷰포트&lt;/b&gt;를 의미합니다.&lt;/p&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. About Me 섹션의 경우&lt;/b&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;About Me 섹션의 경우 한 화면에 충분히 해당 섹션의 30% 이상을 담을 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;threshold_about_me.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;770&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLoWEy/btsOtI4oUe3/I6AWD0FxYMO09oy4Y6kHD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLoWEy/btsOtI4oUe3/I6AWD0FxYMO09oy4Y6kHD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLoWEy/btsOtI4oUe3/I6AWD0FxYMO09oy4Y6kHD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLoWEy%2FbtsOtI4oUe3%2FI6AWD0FxYMO09oy4Y6kHD1%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;300&quot; height=&quot;451&quot; data-filename=&quot;threshold_about_me.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;770&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;&lt;b&gt;2. Skills 섹션의 경우&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;Skills 섹션의 경우 살짝 아슬했지만 충분히 해당 섹션의 30% 이상을 담을 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;threshold_skills.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1144&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bwv2Dm/btsOt6jE45G/Os4GRnQeo7kmZb6Ennza9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bwv2Dm/btsOt6jE45G/Os4GRnQeo7kmZb6Ennza9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bwv2Dm/btsOt6jE45G/Os4GRnQeo7kmZb6Ennza9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbwv2Dm%2FbtsOt6jE45G%2FOs4GRnQeo7kmZb6Ennza9k%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;300&quot; height=&quot;670&quot; data-filename=&quot;threshold_skills.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1144&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;&lt;b&gt;3. Projects 섹션의 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이미지 길이가 너무 길어져서 About Me와 Skills 섹션의 길이는 축소시켰습니다)&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;Projects 섹션의 경우 절대 한 화면에 해당 섹션의 30% 이상을 담을 수 없습니다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;threshold_projects.png&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AncE0/btsOtJWwJaN/PUwWFzKWBw7V2mePvCMCMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AncE0/btsOtJWwJaN/PUwWFzKWBw7V2mePvCMCMK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AncE0/btsOtJWwJaN/PUwWFzKWBw7V2mePvCMCMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAncE0%2FbtsOtJWwJaN%2FPUwWFzKWBw7V2mePvCMCMK%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;300&quot; height=&quot;829&quot; data-filename=&quot;threshold_projects.png&quot; data-origin-width=&quot;512&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;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 경우 인터섹션 옵저버의 트리거는 발동되지 않습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 threshold: 0.0으로 수정하여 이 부분을 해결한 것입니다. 0.0이라는 옵션을 주면 감시하는 요소가 한 픽셀이라도 뷰포트에 보이게 되면 트리거가 동작하게 되니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&quot;&lt;/span&gt;이 간단한 개념을 이렇게까지..?&quot;라는 생각이 들 수도 있다고 생각합니다.&lt;/span&gt; 사실 위 시각자료를 만들 때만 해도 &lt;b&gt;threshold 변경&lt;/b&gt;과 &lt;b&gt;useRef의 사용&lt;/b&gt;으로 대부분의 문제가 해결된 줄 알았습니다. 사실 더 큰 버그가 있었죠. 제 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;내비게이션 바의 인터섹션&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;옵저버&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이슈는 이 threshold에 잘못된 값을 부여해서라기보단 다른 요소들이 더 복잡하게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;얽혀있던 게&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;훨씬 더 컸습니다. 하지만 초반에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;이 또한&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;분명한 원인 중 하나였습니다.&lt;/span&gt; 아무튼 그 당시에는 포스팅도 금방 작성할 수 있을 것 같아서 시각 자료도 제작해 보았던 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제가 처음에 이 threshold 속성에 대해 검색해 보았을 때는&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. 해당 요소가 뷰포트에 얼마나 나타나는지&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 해당 요소의 30%가 뷰포트에 교차될 때&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;3. threshold 임계점을 충족시킬 때&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;4. 해당 요소의 30%를 통과할 때&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;5. 타겟이 30%만큼 보였을&amp;nbsp;때&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;등으로 나와있었는데 대부분 그 감시하는 &quot;요소&quot;의 길이가 뷰포트를 한참 벗어날 만큼 길지 않음을 상정한 설명 같았습니다. 물론 저 설명들이 틀린 것은 아닙니다. 의미적으로는 정확하지만 다만 저는 직관적으로 이해되지 않았다는 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(물론 정확하고 명백한 설명의 글들도 많습니다! 혹시 저만 헷갈린 것은 아니겠죠..??)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 이에 더해 감시하는 요소가 아래로 갈수록 면적이 넓어거나 좁아지는 형태를 가지고 있다면 다른 결과를 보여줄 것입니다. 저는 길이가 매우 긴 경우의 예시를 들었지만 이 감시 요소의 &quot;길이&quot;만이 변수가 되는 것은 아닙니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;타겟이 30%만큼 보였을 때&quot;라면 &lt;b&gt;한 뷰포트 내에 타겟의 전체 면적의 30%가 보여야 하는 건지,&lt;/b&gt; 아니면 그 컨텐츠가 처음 보인시점부터 해당 &lt;b&gt;컨텐츠&lt;/b&gt; &lt;b&gt;총길이의 30% 지점까지 스크롤을 내렸을 때&lt;/b&gt;를 의미하는 것인지 헷갈렸기 때문입니다. (사용자 입장에서 후자의 경우도 타겟의 30%를 눈으로 본 것은 맞으니까요!)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이런 글이 있으면 도움을 받을 수 있는 (저 같은) 개발자도 있지 않을까? 하는 생각에 이 글을 작성해 보았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FE/JavaScript</category>
      <category>Intersection Observer</category>
      <category>Threshold</category>
      <category>Web API</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/52</guid>
      <comments>https://unhandled.tistory.com/entry/Web-API-Intersection-Observer%EC%9D%98-threshold-%EC%98%B5%EC%85%98%EC%9D%B4%EB%9E%80#entry52comment</comments>
      <pubDate>Mon, 9 Jun 2025 15:41:59 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)</title>
      <link>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.)&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749392272492&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기&quot; data-og-description=&quot;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.) 내비게이션 바제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다. (아래 About&quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/XM3kK/hyY5f4FoN4/E5HdzjvKbue9svqPPKPFzK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bCCzz1/hyY46No4YS/7O8UtDvKOOwb6Y28TirF91/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/XM3kK/hyY5f4FoN4/E5HdzjvKbue9svqPPKPFzK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bCCzz1/hyY46No4YS/7O8UtDvKOOwb6Y28TirF91/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.) 내비게이션 바제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다. (아래 About&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;혼란스러운 테스트 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 내용은 이전 포스팅에서 작성했던 글입니다. (본문에는 포함되지 않았죠.)&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;하지만 테스트 결과가 일관적이지 않았습니다. 이 현상에 영향을 끼치는 변수들이 많은 것 같은데, 문제는 변수를 통제해서 동일한 환경을 조성해도 이전 테스트와는 다른 결과를 보여줄 때가 빈번했다는 것입니다. 같은 개발 환경인데 같은 코드가 다르게 동작하는 게 이해가 되지 않았습니다. 이 현상 자체는 코드 외적인 기타 메모리나 캐시 등 환경적인 부분에서의 변수가 영향을 끼치고 있는 가능성이 높다는 것입니다.&lt;br /&gt;&lt;br /&gt;1. 코드를 수정한 후 개발 서버를 처음 실행했을 때와 그 처음 실행한 서버를 종료하고 2번째로 실행했을 때 보이는 양상이 다릅니다. (코드를 수정한 후 첫 번째 개발 환경을 실행했을 때 어떤 캐시를 생성하는 것 같은데 App Router의 4개 캐시 레이어와는 별개의 캐시로 추측 중입니다.)&lt;br /&gt;&lt;br /&gt;2. 처음 접속한 상황에서&amp;nbsp;새로 고침을 기점으로 이후의 양상이 달라집니다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;3. 어쩔 때는 위의 1번 이슈(중간 버튼 포커스 문제)는 잘 해결된 것처럼 보였는데 스크롤을 조금 움직인 후 다시 시도하면 중간 버튼을 지나칠 때 버튼이 잠깐 포커스 되는 현상이 재발합니다.&amp;nbsp;&amp;nbsp;&lt;br /&gt;&lt;br /&gt;4. 개발 환경과 프로덕션 환경에서의 양상이 각각 다릅니다. (개발 환경에서는 버그, 프로덕션 환경에서는 잘됨 &amp;lt;- 대체 왜.... 개발 환경에서는 (reactStrictMode: false를 명시적으로 추가하니 해결 &amp;rarr; 어? 다시 돌아왔네?))&lt;br /&gt;&lt;br /&gt;이 이상한 현상들은&amp;nbsp; useState로 선언한 변수가 업데이트될 때마다 계속 리렌더링을 유발하기 때문에 무언가가 꼬여서 발생한 것 같습니다.&lt;br /&gt;&lt;br /&gt;계속 예상치 못한 결과를 보여주는데 복잡하고 많은 변수들이 얽혀있는 것 같습니다. 그리고&amp;nbsp;당장 모든 것을 이해하는 것은 비효율적이라는 생각이 들었습니다. 어차피 우리는 useRef를 사용하는 것이 정답이라는 것을 알고 있으니까요. (그러므로 여기에 테스트 결과를 올리는 것도 큰 의미가 없을 것 같습니다.)&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;사실 이 내용을 작성했을 때까지만 해도 App Router의 어떤 특성 때문일 것이라는 생각을 하지 못했습니다.&amp;nbsp;&lt;/span&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;제가 이번 이슈에 대해서 인터섹션 옵저버(웹 API), 클로저(자바스크립트)와 useRef(리액트)에 대한 생각만 하다가 한 가지 중요한 사실을 간과했습니다. 이 프로젝트가 Next.js App Router였다는 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개발 환경과 배포 환경에서의 양상이 달랐던 이유&lt;/b&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;Next.js App Router의 정적 렌더링 페이지는&lt;/b&gt;&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;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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;app/page.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749365789310&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import AboutMe from &quot;@/component/AboutMe/AboutMe&quot;;
import StacksSection from &quot;@/component/Stacks/StacksSection&quot;;
import Projects from &quot;@/component/Projects/Projects&quot;;

export default function Home() {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;AboutMe /&amp;gt;
      &amp;lt;StacksSection /&amp;gt;
      &amp;lt;Projects /&amp;gt;
    &amp;lt;/div&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;각각 컴포넌트의 하위요소에 id값을 부여해서 사용하고 있었는데요. AboutMe 컴포넌트와 달리 StacksSection 컴포넌트와 Projects 컴포넌트는 데이터 페칭이 발생하는 비동기 서버 컴포넌트입니다.&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;StacksSection.tsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Stacks 컴포넌트를 2번 사용(Skills, Tools)하므로 id 값은 부모컴포넌트인 StackSection 컴포넌트에 부여했습니다.)&lt;/p&gt;
&lt;pre id=&quot;code_1749366105338&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import type { Stack } from &quot;@/types/stacks&quot;;

import Stacks from &quot;@/component/Stacks/Stacks&quot;;

import getSkillsData from &quot;@/services/getSkillsData&quot;;
import getToolsData from &quot;@/services/getToolsData&quot;;

export default async function StacksSection() {
  const { data } = await getSkillsData();
  const { mappedData } = await getToolsData();
  return (
    &amp;lt;section id=&quot;skills-title&quot;&amp;gt;
      &amp;lt;Stacks title=&quot;Skills&quot; data={data ?? []} /&amp;gt;
      &amp;lt;Stacks title=&quot;Tools&quot; data={(mappedData ?? []) as Stack[]} /&amp;gt;
    &amp;lt;/section&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;&lt;b&gt;Projects.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749366198668&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { type Project } from &quot;@/types/project&quot;;
import getAllProjectData from &quot;@/services/getAllProjectData&quot;;

import ProjectCard from &quot;./ProjectCard&quot;;
import SectionTitle from &quot;../common/SectionTitle&quot;;

import styles from &quot;./Projects.module.css&quot;;

export default async function Projects() {
  const { data } = await getAllProjectData();

  return (
    &amp;lt;section id=&quot;projects-title&quot;&amp;gt;
      &amp;lt;div className={styles.content}&amp;gt;
        &amp;lt;SectionTitle title=&quot;Projects&quot; /&amp;gt;
        &amp;lt;div className={styles[&quot;project-container&quot;]}&amp;gt;
          {data?.map((project: Project) =&amp;gt; (
            &amp;lt;ProjectCard key={project.project_id} project={project} /&amp;gt;
          ))}
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&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;이들은 비동기 서버 컴포넌트이기에 서버에서의 데이터 페칭이 완료되기 이전엔 (id 값이 부여된) 내부의 section 태그는 렌더링 되지 않습니다.&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;즉, 메인 페이지에 진입해서 내비게이션 바 컴포넌트가 마운트 될 때 useEffect 훅이 실행되고 페이지의 id를 찾아서 배열에 저장합니다. 하지만 이 시점에 skills 섹션과 projects 섹션의 id 정보가 존재하지 않았던 것입니다. 이 시점에서 id 정보가 존재하려면&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 data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;데이터를 클라이언트로 전송&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;데이터가 클라이언트에 도착&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;DOM 요소에 데이터 붙이기&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;까지 완료되어야 인터섹션 옵저버가 id값을 감시 목록에 넣을 수 있는 것입니다.&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;만약 이 컴포넌트들이 클라이언트 컴포넌트들이고, 데이터 페칭도 클라이언트 사이드에서 발생했다면 id 값은 아마 데이터가 도착하기 전에 먼저 그려졌을 것입니다. 하지만 위의 두 컴포넌트는 &quot;비동기 서버 컴포넌트'이므로 그럴 수 없었던 것입니다.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 해결법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 데이터 페칭을 페이지 레벨에서 하기 (보류)&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;이렇게 하면 데이터가 전부 도착하고 useEffect가 실행되기에 위 문제를 해결할 수 있습니다. 다만 이렇게 하면 App Router의 특장점인 Streaming의 이점을 누릴 수 없습니다. 현재의 제 포트폴리오 메인 페이지는 정적 렌더링 페이지라서 당장은 서스펜스 스트리밍이 필요하지 않지만 앞으로 어떻게 변화할지 모르니까요. 스트리밍을 사용하려면 서버 컴포넌트에서 데이터 페칭을 하고 그 컴포넌트를 서스펜스 바운더리로 감싸주어야 합니다. 그러므로 이 방법은 적용하지 않겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. id 태그의 위치 변경하기 (채택)&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 수정합니다. id 부여는 Suspense 바운더리 바로 위 태그에서 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고로 이 id를 Suspense 바운더리 내에 넣거나 fallback에 넣으면 정상적으로 동작하지 않습니다.)&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;page.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749349642294&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div id=&quot;skills-title&quot;&amp;gt; 
    &amp;lt;StacksSection /&amp;gt;
&amp;lt;/div&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 기존 section 태그에 부여했던 id들은 중복 방지를 위해 삭제합니다.&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;b&gt;이전의 비일관적인 테스트 결과의 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음과 같이 디버깅을 위한 코드를 useEffect 훅 내에 추가해 줍니다. 이는 처음 마운팅되고 useEffect가 실행될 때 페이지의 모든 id값이 존재하는지를 알아내기 위함입니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1749370667214&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  SECTION_ID_ARRAY.forEach((id) =&amp;gt; {
    const el = document.getElementById(id);
    console.log(`${id}:`, el ? &quot;찾음&quot; : &quot;못 찾음&quot;, el);
 });&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;b&gt;다음의 테스트는 최초의 테스트를 재현하기 위해 여러 요소들을 수정하기 전(비동기 서버 컴포넌트 내 섹션 태그에 id부여, useRef 대신 useState 사용) 상태로 롤백한 후 진행되었습니다.&lt;/b&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;(이 테스트에서 id는 각 컴포넌트 섹션에 직접 부여되어 있는 상태입니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. (코드 수정 후 개발 서버 시작) 초기 진입&lt;/b&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;개발 서버를 새롭게 시작한 시점에의 결과가 가장 일관성이 없었습니다.&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. 운 좋게 데이터를 빨리 받아와서 useEffect 실행 시점에 skills, projects 둘 다의 id를 감시 대상에 등록합니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;전부찾음.png&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;125&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUwbz0/btsOthL23zP/pJk8cDTisi6gUbof3yxObK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUwbz0/btsOthL23zP/pJk8cDTisi6gUbof3yxObK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUwbz0/btsOthL23zP/pJk8cDTisi6gUbof3yxObK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUwbz0%2FbtsOthL23zP%2FpJk8cDTisi6gUbof3yxObK%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;560&quot; height=&quot;105&quot; data-filename=&quot;전부찾음.png&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;125&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;2. 어쩔 땐 skills 데이터까지는 도착합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스킬만찾음.png&quot; data-origin-width=&quot;677&quot; data-origin-height=&quot;110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J5r7k/btsOsmAwVk0/p0P2bm2JwK9jxn3hARXqDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J5r7k/btsOsmAwVk0/p0P2bm2JwK9jxn3hARXqDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J5r7k/btsOsmAwVk0/p0P2bm2JwK9jxn3hARXqDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ5r7k%2FbtsOsmAwVk0%2Fp0P2bm2JwK9jxn3hARXqDK%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;572&quot; height=&quot;93&quot; data-filename=&quot;스킬만찾음.png&quot; data-origin-width=&quot;677&quot; data-origin-height=&quot;110&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;3. 어쩔 땐 둘 다 도착하지 않은 상태일 때도 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-06-08 171922.png&quot; data-origin-width=&quot;698&quot; data-origin-height=&quot;104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n82eI/btsOrGfhCMa/cxolwakjvbUuCXJFYeTMp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n82eI/btsOrGfhCMa/cxolwakjvbUuCXJFYeTMp1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n82eI/btsOrGfhCMa/cxolwakjvbUuCXJFYeTMp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn82eI%2FbtsOrGfhCMa%2FcxolwakjvbUuCXJFYeTMp1%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;584&quot; height=&quot;104&quot; data-filename=&quot;스크린샷 2025-06-08 171922.png&quot; data-origin-width=&quot;698&quot; data-origin-height=&quot;104&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;&lt;b&gt;내비게이션 바 버튼 클릭 시&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 클로저 이슈는 계속 발생합니다. (doNotChange는 계속 거짓)&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;스크롤 감지하여 버튼 포커스&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 1, 2번의 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;데이터를 일찍 받아온 섹션은 감시 대상에 등록이 되어있습니다. 스크롤 이동시 인터섹션 옵저버가 해당 감시 대상을 탐지해서 해당 탭을 활성화합니다. (2번 같은 경우 projects 섹션만 감지 못함)&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;위 3번의 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;아래의 새로고침을 한 이후의 상태처럼 aboutMe섹션 외에는 감지하지 못합니다.&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;size18&quot;&gt;&lt;b&gt;2. (위 1번의 상태에서) 새로고침 후&lt;/b&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;새로 고침 후에는 skills 섹션과 projects 섹션의 데이터 모두 useEffect 훅 실행 시 존재하지 않습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;못찾음.png&quot; data-origin-width=&quot;666&quot; data-origin-height=&quot;110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ewDBLw/btsOrtAyxOu/jXaJrrderyOtg1dxDFEn90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ewDBLw/btsOrtAyxOu/jXaJrrderyOtg1dxDFEn90/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ewDBLw/btsOrtAyxOu/jXaJrrderyOtg1dxDFEn90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FewDBLw%2FbtsOrtAyxOu%2FjXaJrrderyOtg1dxDFEn90%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;557&quot; height=&quot;92&quot; data-filename=&quot;못찾음.png&quot; data-origin-width=&quot;666&quot; data-origin-height=&quot;110&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;&lt;b&gt;내비게이션 바 버튼 클릭 시&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;rarr; 새로 고침 이후 클로저 문제가 해결된 것처럼 보였던 것&lt;/b&gt;은 애초에 &lt;b&gt;skills 섹션과 projects 섹션의 id값을 useEffect 실행 시 인터섹션 옵저버가 감시대상에 넣지 않았기 때문&lt;/b&gt;입니다. 그래서 stale closure이슈는 해결된 것처럼 보였던 것이죠(애초에 skills 섹션은 감시 대상이 아니었던 것이죠. 이전에 isintersecting이 콘솔에 찍혀야 하는데 왜 안 찍히지? 했던 의문에 대한 답을 찾았습니다. 애초에 감시대상이 아니니 트리거 자체가 실행되지 않은 것입니다!)&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;스크롤 감지하여 버튼 포커스&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr;&amp;nbsp;skills 섹션과 projects 섹션은 인터섹션 옵저버의 감시 대상에 포함되지 않은 상태입니다. 그렇기에 해당 섹션의 id 요소 위치로 스크롤을 이동시켜도 내비게이션 바의 해당 섹션 버튼을 포커스 하는 기능이 동작하지 않은 것입니다.&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;결국 이전의 혼란스러운 양상은 useState가 유발한 리렌더링 때문이 아니라 &lt;b&gt;App Router 비동기 서버 컴포넌트의 데이터 페칭이 매번 다른 시점에 완료되기 때문&lt;/b&gt;입니다. &lt;b&gt;개발 환경에서는 정적 페이지도 동적렌더링으로 전환되기 때문에&lt;/b&gt; 그때그때마다 새롭게 데이터를 가져와야 하니까요. (이 데이터는 useEffect훅의 인터섹션 옵저버가 id 배열을 정의할 때 이미 도착한 상태일 수도 있고 아닐 수도 있습니다. 이것이 바로 일관되지 못한 결과로 혼란을 야기하게 만든 원인인 것입니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 첫 개발 서버 접속에서만 데이터가 일찍 도착하는가?&lt;/b&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;&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;&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;범인은 Turbopack(터보팩)&lt;/b&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;(일단 이 테스트에서는 id값을 다시 섹션에 부여한 상태, 즉 데이터가 useEffect 실행 시에 도착했을 수도, 아닐 수도 있는 디버깅 이전 과거의 상태로 롤백했습니다.)&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;size18&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;package.json&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749428843334&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;next dev --turbopack&quot;,
  &quot;build&quot;: &quot;next build&quot;,
  &quot;start&quot;: &quot;next start&quot;,
  &quot;lint&quot;: &quot;next lint&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;저 &quot;--turbopack&quot; 부분을 삭제하면 더 이상 개발 환경에서 터보팩이 아닌 웹팩을 사용합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1749428868353&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;scripts&quot;: {
  &quot;dev&quot;: &quot;next dev&quot;,
  &quot;build&quot;: &quot;next build&quot;,
  &quot;start&quot;: &quot;next start&quot;,
  &quot;lint&quot;: &quot;next lint&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;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;전부찾음.png&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;125&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xd3D4/btsOtMrrB3P/dKJzW0zcptjD4tiHRlSOXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xd3D4/btsOtMrrB3P/dKJzW0zcptjD4tiHRlSOXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xd3D4/btsOtMrrB3P/dKJzW0zcptjD4tiHRlSOXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fxd3D4%2FbtsOtMrrB3P%2FdKJzW0zcptjD4tiHRlSOXK%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;550&quot; height=&quot;103&quot; data-filename=&quot;전부찾음.png&quot; data-origin-width=&quot;667&quot; data-origin-height=&quot;125&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;b&gt;useEffect가 실행되는 시점까지의 시간을 turbopack이 단축&lt;/b&gt;시켰기 때문입니다. 데이터가 늦게 도착해서가 아니라 Navbar 컴포넌트의 &lt;b&gt;자바스크립트가 오히려&lt;/b&gt; (데이터가 존재하지 않는 시점에) &lt;b&gt;더 빨리 실행&lt;/b&gt;되었기 때문에 발생한 이슈입니다! 오히려 저 2개의 id를 찾아서 아무런 이슈가 발생하지 않은 상황보다 찾지 못해서 문제가 발생한 상황이 더 최적화가 잘 적용되어 있던 것입니다! (첫 접속 시 종종 id를 셋 다 찾을 수 있던 것은 첫 구동을 위한 예열로 인한 성능저하로 오히려 딜레이가 발생한 것입니다.)&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;+) 그리고 터보팩은 현재 개발 환경에서만 지원됩니다. (프로덕션 환경은 개발 중) 그러므로 프로덕션 환경에서 해당 페이지를 동적(force-dynamic)으로 만들어도 이 이슈는 발생하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;공범은 Streaming(스트리밍)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 추가적인 테스트 중 loading.tsx의 존재 여부가 결과에 영향을 끼친다는 것 또한 알게 되었습니다.&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;터보팩을 사용하더라도, loading.tsx가 존재하지 않는다면 위 문제는 발생하지 않습니다. 즉 이 문제는 &lt;b&gt;터보팩을 사용하면서 loading.tsx도 존재한다&lt;/b&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;loading.tsx를 추가하면 자동적으로 서스펜스 바운더리가 형성되고 App Router의 &lt;b&gt;스트리밍&lt;/b&gt;이 적용되기 시작합니다. 그러므로 좀 더 정확하게 설명한다면 이 현상에 영향을 끼친 주원인 중 하나는 &lt;b&gt;Streaming(스트리밍)&lt;/b&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;제 추측으로는 &lt;b&gt;터보팩&lt;/b&gt;을 사용한 것이 Navbar 컴포넌트의 자바스크립트 실행 순간을 더 앞당겼고, 이에 더해 loading.tsx의 존재로 발생한 &lt;b&gt;스트리밍&lt;/b&gt;으로 인해 비동기 서버 컴포넌트의 데이터 페칭이 완료되기 전에 Navbar 컴포넌트부터 먼저 클라이언트로 전송한 걸로 보입니다. 그래서 그 간극(인터섹션 옵저버가 감시할 대상을 찾는 시점과 DOM에 id요소가 들어가는 시점)은 더 커졌고 결국 위와 같은 문제가 발생한 것입니다.&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;최적화를 위한 두 기능이 역설적으로 예상치 못한 불편한 순간을 만들어낸 것입니다. UX를 위해 DX가 희생된 전형적인 사례라고 볼 수 있겠습니다. (하지만 터보팩 자체는 개발환경에서의 DX를 위한 것이기도 합니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;정리&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. useState 사용으로 인한 Intersection Observer 내의 Stale Closure&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Intersection Observer의 threshold 속성에 대한 오해&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 데이터를 페칭 하는 비동기 서버 컴포넌트 내부에 id값이 존재했던 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 위 3번이 문제를 유발하게 만든 건 터보팩과 loading.tsx로 인한 스트리밍 (최적화)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5.&amp;nbsp;개발&amp;nbsp;환경과&amp;nbsp;프로덕션&amp;nbsp;환경의&amp;nbsp;차이&amp;nbsp;(동적&amp;nbsp;렌더링&amp;nbsp;vs&amp;nbsp;정적&amp;nbsp;렌더링,&amp;nbsp;터보팩&amp;nbsp;적용&amp;nbsp;vs&amp;nbsp;터보팩&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;1번 이슈는 어렵지 않습니다. 인터섹션 옵저버로 인한 Stale Closure는 너무 발생하기 쉬운 이슈라 이미 많은 사람들이 다룬 주제일 것 같습니다. (이에 대한 자세한 설명은 이전 포스팅에 있습니다.)&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;2번 이슈도 어렵지 않습니다. &quot;threshold 속성은 감시하는 대상의 전체 면적의 몇 퍼센트가 화면에 보이는지에 대한 것&quot;이라는 것을 알면 됩니다.&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;문제는 3번을 알아내기까지가 매우 어려웠습니다. 심지어 저런 특성으로 인해 일시적으로 스테일 클로저 이슈가 해결된 것처럼 보였다는 게 더 충격입니다. 버그가 버그를 숨긴 것입니다. 개발 서버(SSR)와 프로덕션 환경(SSG)에서 렌더링이 다르게 동작하는 것도 그렇고요. 너무 많은 변수들을 고려해야 했습니다.&lt;/span&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;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;프로덕션 환경에서는 데이터가 이미 빌드타임에 페칭 되어 렌더링 되어있으므로 서버 비동기 컴포넌트의 데이터 페칭 딜레이로 인해 특정 섹션의 id값을 인터섹션 옵저버가 알지 못하는 현상은 발생하지 않았던 것입니다.&lt;span&gt; (게다가 현시점 터보팩은 개발 환경에서만 적용됩니다.)&lt;/span&gt;&lt;/span&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;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;예전부터 항상 느낀 건 참 디버깅하기 쉽지 않다고 느낀 것들은 대부분 App Router를 사용했기 때문에 발생한 이슈들이었습니다. 위 3~5번 문제도 App Router가 아니었으면 발생하지 않았을 것 같습니다. (스트리밍을 사용하는 다른 프레임워크들은 혹시 또 모르겠지만요!)&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/span&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-filename=&quot;가장_교활하고_악랄한_문제.png&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;76&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUByrY/btsOsebQOzc/5FjnZkkzHCFHF4B4Nl7Qa0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUByrY/btsOsebQOzc/5FjnZkkzHCFHF4B4Nl7Qa0/img.png&quot; data-alt=&quot;Gemini의 한마디. 심지어 이 시점에서는 3번 문제를 알아내지도 못했다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUByrY/btsOsebQOzc/5FjnZkkzHCFHF4B4Nl7Qa0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUByrY%2FbtsOsebQOzc%2F5FjnZkkzHCFHF4B4Nl7Qa0%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;726&quot; height=&quot;38&quot; data-filename=&quot;가장_교활하고_악랄한_문제.png&quot; data-origin-width=&quot;1454&quot; data-origin-height=&quot;76&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Gemini의 한마디. 심지어 이 시점에서는 3번 문제를 알아내지도 못했다.&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;/span&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;&lt;span&gt;사실 이전 포스팅을 쓰면서 &quot;드디어 내가 Next.js가 아닌 다른 주제의 글을 쓰는구나&quot;라고 생각했는데 결국 Next.js로 돌아와 버렸습니다.&lt;/span&gt;&lt;/span&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;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;전 App Router에 애정이 있는데 이럴 때마다 우려스럽습니다. 다른 프레임워크를 쓰면 몇 시간 내에 해결할 수 있던 문제였는데 이전에는 없었던 App Router의 복잡한 렌더링 메커니즘이 디버깅을 난해하게 만든 것입니다. 이러한 복잡함과 예측불가한 상황을 감당하려면 그에 대한 유인가가 존재해야 합니다. App Router는 UX를 위해 DX를 희생한 프레임워크라고 생각하는데 이번 이슈는 그 정도가 좀 심한 것 같군요.&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/span&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;</description>
      <category>FE/Next.js</category>
      <category>App router</category>
      <category>Intersection Observer</category>
      <category>next.js</category>
      <category>Streaming</category>
      <category>turbopack</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/51</guid>
      <comments>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8#entry51comment</comments>
      <pubDate>Mon, 9 Jun 2025 10:22:51 +0900</pubDate>
    </item>
    <item>
      <title>[React] Intersection Observer의 Stale Closure(오래된 클로저) 이슈 useRef로 해결하기</title>
      <link>https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전 프로젝트를 기준으로 작성되었습니다.)&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;내비게이션 바&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제 포트폴리오에서 구현한 내비게이션 바입니다. 여기서는 2가지의 스크롤 이벤트가 발생합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(아래 &lt;b&gt;&lt;/b&gt;&lt;i&gt;&lt;b&gt;About Me &lt;/b&gt;&lt;/i&gt;버튼의 상태가 포커스 된 상태입니다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;포커스.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;72&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KMDSy/btsOteu3Cpc/GQKLnYGTPhEVCoeIeFpWI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KMDSy/btsOteu3Cpc/GQKLnYGTPhEVCoeIeFpWI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KMDSy/btsOteu3Cpc/GQKLnYGTPhEVCoeIeFpWI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKMDSy%2FbtsOteu3Cpc%2FGQKLnYGTPhEVCoeIeFpWI0%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;940&quot; height=&quot;72&quot; data-filename=&quot;포커스.png&quot; data-origin-width=&quot;940&quot; data-origin-height=&quot;72&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;1. 내비게이션 바 섹션 버튼 클릭 시 클릭한 버튼 포커스하고 해당 섹션으로 스크롤 이동&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;버튼클릭시스크롤이동.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/edcPcD/btsOtMZb6b3/0QVO6r6fdCtI4nOATevbD1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/edcPcD/btsOtMZb6b3/0QVO6r6fdCtI4nOATevbD1/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/edcPcD/btsOtMZb6b3/0QVO6r6fdCtI4nOATevbD1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/edcPcD/btsOtMZb6b3/0QVO6r6fdCtI4nOATevbD1/img.gif&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;524&quot; height=&quot;524&quot; data-filename=&quot;버튼클릭시스크롤이동.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&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;2. 스크롤 이동시 스크롤 위치를 감지해서 내비게이션 바 포커스 상태 변경&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크롤감지후내브바포커스변경.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpxgYv/btsOs2alLsZ/kaNZ2daFmVn9bXRIsfoffK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpxgYv/btsOs2alLsZ/kaNZ2daFmVn9bXRIsfoffK/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpxgYv/btsOs2alLsZ/kaNZ2daFmVn9bXRIsfoffK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/cpxgYv/btsOs2alLsZ/kaNZ2daFmVn9bXRIsfoffK/img.gif&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;524&quot; height=&quot;524&quot; data-filename=&quot;스크롤감지후내브바포커스변경.gif&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(화면 캡처 프로그램이 한 템포 느려서 버벅거리는 것처럼 녹화되었으나 실제로는 그렇지 않습니다.)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;내비게이션 바 디버깅&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위 두 개의 기능 중에 1번 기능에서 버그가 발생했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 내비게이션 바 섹션 버튼 클릭 시 클릭한 버튼 포커스하고 해당 섹션으로 스크롤 이동&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 스크롤 이동시 스크롤 위치를 감지해서 내비게이션 바 포커스 상태 변경&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;About Me&lt;/b&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&amp;nbsp; &amp;nbsp;|&amp;nbsp; &amp;nbsp;Skills&amp;nbsp; &amp;nbsp;|&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&amp;nbsp;Projects&amp;nbsp; |&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;현재 이 상태가 첫 접속 시 기본 상태입니다. (About Me가 랜딩 페이지 첫 뷰포트에 보이므로 포커스 되어있죠.)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;About Me 버튼이 선택되어 있는 상태에서 Projects 버튼을 누르면&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&amp;nbsp;&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;nbsp;&lt;b&gt;About Me&lt;/b&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&amp;nbsp; &amp;nbsp;Skills&amp;nbsp; &amp;nbsp;&lt;span style=&quot;text-align: start;&quot;&gt;|&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Projects&amp;nbsp; &amp;nbsp;|&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&amp;nbsp; &amp;nbsp;About Me&amp;nbsp; &amp;nbsp;|&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;nbsp;Skills&amp;nbsp; &amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&lt;/span&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&amp;nbsp;&lt;b&gt;Projects&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;|&amp;nbsp; &amp;nbsp;&amp;lt;- 버튼을 누른 즉시&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&amp;nbsp; &amp;nbsp;About Me&amp;nbsp; &amp;nbsp;|&amp;nbsp;&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;nbsp;&lt;b&gt;Skills&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;nbsp;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;|&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Projects&amp;nbsp; &amp;nbsp;|&amp;nbsp;&lt;/span&gt;&amp;nbsp; &amp;lt;- 하지만 스크롤이 Skills 섹션을 지나면서 이를 탐지함&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&amp;nbsp; &amp;nbsp;About Me&amp;nbsp; &amp;nbsp;|&amp;nbsp; &amp;nbsp;Skills&amp;nbsp; &amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;|&lt;/span&gt;&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&amp;nbsp;&lt;b&gt;Projects&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;|&amp;nbsp; &amp;nbsp;&amp;lt;- 마지막에 스크롤 이벤트가 끝나고 현재 섹션 기준으로 id를 저장하기 때문&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스크롤은 Projects 섹션이 있는 하단까지 이동하는데, 스크롤이 Skills 섹션을 지나칠 때 인터섹션 옵저버가 이를 순간적으로 인식합니다. 이 때문에 Projects 버튼을 누르면 즉시 Projects 버튼이 포커스 되었다가 스크롤이 Skills 섹션을 지나는 순간 Skills 섹션이 포커스 되었다가 다시 Projects 섹션이 포커스 되게 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;사용자 관점에서 시각적으로 상당히 불편합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 현상을 방지하기 위한 불리언 변수를 하나 추가했습니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1749389317199&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [doNotChange, setDoNotChange] = useState&amp;lt;boolean&amp;gt;(false);&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;이제 스크롤 이벤트가 발생하면 이 변수는 즉시 true가 되고 약 0.7초 동안은 새로운 스크롤 이벤트를 발생시키지 않습니다. (0.7초보다 좀 더 짧게 주어야 할 것 같습니다)&lt;/p&gt;
&lt;pre id=&quot;code_1749389338869&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  const scrollTo = (id: string) =&amp;gt; {
    setDoNotChange(true);

    let target = sectionRefs.current[id];

    if (!target) {
      target = document.getElementById(id);
      sectionRefs.current[id] = target;
    }

    target?.scrollIntoView({ behavior: &quot;smooth&quot; });
    setActiveSection(id);
    setTimeout(() =&amp;gt; {
      setDoNotChange(false);
    }, 700);
  };&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;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;스크롤 탐지에 따른 버튼 포커스가 안됨&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. 내비게이션 바 섹션 버튼 클릭 시 클릭한 버튼 포커스하고 해당 섹션으로 스크롤 이동&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 스크롤 이동시 스크롤 위치를 감지해서 내비게이션 바 포커스 상태 변경&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번엔 2번 기능에 문제가 생겼습니다. 스크롤 위치를 감지하여 섹션 버튼 포커스를 바꾸는 기능이 제대로 동작하지 않는 것입니다. 첫 번째 섹션(About Me)은 잘 감지하고 해당하는 버튼을 포커스 하는데 두 번째 섹션(Skills)과 세 번째 섹션(Projects)은 탐지하지 못하고 있더군요.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;사실 고쳤다고 생각한 문제도 재발&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이건 추후 테스트를 통해 알게 된 건데, 사실 그전에도 결과가 일관적이지 않았거든요. 이번 테스트에서 새로고침을 기점으로 양상이 달라진다는 것을 알게 되었습니다. (추가) 사실 저 불리언 변수를 추가해서 고쳤다고 생각한 건 착각이었습니다.)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;최초 진입 시&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1번 기능 - 에러&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2번 기능 - 멀쩡&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새로고침 후&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1번 기능 - 멀쩡&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2번 기능 - 에러&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;알고보니 각각 문제의 원인은 다음과 같았습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1번 -&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;클로저(Closure)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이슈&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2번 - 잘못된 id 속성 부여와 잘못된 threshold 수치 부여&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1번 기능에 문제가 생겼던 이유&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;최초 진입 시 useEffect를 실행하고, 이때 doNotChange 변수의 기본값인 false를 기억합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;About Me 버튼이 포커스 되어있는 상태에서 Projects 버튼을 누르면 스크롤이 Skills 섹션을 지나면서 인터섹션 옵저버가 이를 감지하겠죠. 아까 저는 doNotChange라는 불리언 변수를 추가했습니다. 그런데 scroll 이벤트를 담당하는 useEffect 밖의 함수에서 이 불리언 변수의 값을 변경하는데, 인터섹션 옵저버 내의 이 doNotChange 변수가 참인지 거짓인지를 판별하는 코드는 useEffect 내에 있습니다. 그러므로 이 판별 코드는 외부에서 업데이트된 doNotChange의 값을 알지 못합니다. 함수는 선언될 당시의 렉시컬 스코프를 기억하므로 이 코드는 &quot;doNotChange는 false&quot; 만을 기억하게 됩니다. 이것이 바로 자바스크립트의 클로저입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 if(! doNotChange){ setActiveSection(id) }라는 조건문을 추가해도 doNotChange의 값은 계속 거짓이라고 알고 있기 때문에 setActiveSection(id)는 항상 실행됩니다. 즉, 중간에 지나치는 Skills 섹션을 무시하지 못하고 다시 포커스 하게 되는 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 클로저 이슈를 해결하려면 불리언 변수를 useState가 아닌 useRef로 선언해야 합니다. &lt;b&gt;useState&lt;/b&gt;를 사용하면 그 &lt;b&gt;값&lt;/b&gt;을 기억하지만 &lt;b&gt;useRef&lt;/b&gt;를 사용하면 참조하는 &lt;b&gt;객체의 주소&lt;/b&gt;를 기억합니다. 그래서 그 객체 내부의 값의 변경되었다고 해도 그 참조하고 있는 &quot;객체&quot;가 변경된 것은 아닙니다. 그러므로 .current의 방법으로 &quot;같은 객체&quot;의 &quot;최신 값&quot;을 가져올 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2번 기능에 문제가 생겼던 이유&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스크롤이 인터섹션 옵저버가 감지하는 요건(threshold)을 충족하면 트리거를 발생시켜서 해당 섹션 버튼을 포커스 합니다. 이 기능이 원활하게 동작하지 않아서 시도해 본 것은 다음과 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. id를 h2 태그에 부여해서&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;기존 코드에서는 id가 h2 태그에 부여되어 있었습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749389379280&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function AboutMe() {
  return (
    &amp;lt;section&amp;gt;
      &amp;lt;div className={styles.content}&amp;gt;
        // id가 h2 태그에 부여되어있다.
        &amp;lt;h2 id=&quot;about-me-title&quot; className={styles.title}&amp;gt;
          About Me
        &amp;lt;/h2&amp;gt;
        ...
      &amp;lt;/div&amp;gt;
    &amp;lt;/section&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;이렇게 하면 인터섹션 옵저버가 AboutMe 섹션을 감시하는 게 아니라 섹션 내의 이 h2 텍스트만을 감시하게 됩니다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이 경우 이 작은 텍스트를 지나치는 순간 해당 섹션의 entry.isintersecting은 false가 됩니다. 즉 섹션에 직접 부여해야 합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749389398133&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function AboutMe() {
  return (
    // id를 섹션 태그에 부여
    &amp;lt;section id=&quot;about-me-title&quot;&amp;gt;
      ...
    &amp;lt;/section&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;좀 더 정석적인 방법으로 수정했습니다. 그런데 이러한 수정 자체가 별다른 변화를 가져오진 않았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. threshold 수치에 문제가 있었습니다. 처음에 0.3으로 적용했는데 이건 제가 threshold 요소가 의미하는 바를 명확하게 이해하지 못한 상황에서 잘못 부여한 것입니다. threshold: 0.3은 해당 섹션의 30% 이상이 화면에 보이기 시작할 때를 의미합니다. 화면 안에 다 들어가는 작은 요소를 감시하는 거라면 모를까 projects 섹션은 상대적으로 매우 길기 때문에 threshold 0.3을 절대 충족시킬 수 없습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;[이 부분도 추가 예정]&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;useRef로 변경 후&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;클로저 이슈는 useRef로 해결했습니다. 배포하니까 아주 잘 되더군요.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 여전히 납득가지 않는 부분들이 많았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위에서 진행한 테스트는 useRef를 적용하기 이전에 해본 테스트인데&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 새로고침 후에는 클로저 이슈가 해결된 것처럼 보였을까요?&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;게다가 아직 모든 문제가 해결되지 않은 것처럼 보입니다. 분명 useRef를 사용하는 방식으로 코드를 수정했는데&lt;span&gt;&amp;nbsp;배포 버전과 달리 개발 환경에서는 여전히 문제가 발생했습니다. 최초 접속 시에는 두 기능 다 제대로 동작하는데 한 번이라도 &lt;/span&gt;새로고침을 하면 그 순간부터는 스크롤을 감지해 버튼 포커스를 바꾸는 기능이 제대로 동작하지 않는 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;설마..(또) &lt;b&gt;Next.js&lt;/b&gt; 때문일까요?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(만약 순수 리액트 또는 Next.js Pages Router 등의 다른 프레임워크라면 useRef 추가로 모든 이슈가 해결되었을 가능성이 높습니다. 하지만 저와&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;동일한 문제가 발생했다면 Next.js App Router를 사용해서 발생한 문제일 가능성이 높습니다. 그런 경우에는 다음 포스팅인 2탄을 읽어주세요!&lt;/span&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749432241901&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)&quot; data-og-description=&quot;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.) 지난 &quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bkN786/hyY7XIweRo/S8sb0P71rbHPvwOAwkJlx1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cGX4jk/hyY5hO2xHw/NJMsM4TeHUdPJpHG8Ax3u1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-Intersection-Observer%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%A0-%EB%95%8C-%EC%A3%BC%EC%9D%98%ED%95%A0-%EC%A0%90-feat-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bkN786/hyY7XIweRo/S8sb0P71rbHPvwOAwkJlx1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/cGX4jk/hyY5hO2xHw/NJMsM4TeHUdPJpHG8Ax3u1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[Next.js] App Router에서 Intersection Observer를 사용할 때 주의할 점 (feat. 비동기 서버 컴포넌트)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 직접 진행한 실험을 토대로 제 개인적인 추측이 포함되어 있습니다. 그러므로 정확하지 않은 가설이 포함되어 있을 수 있습니다.) 지난&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FE/React</category>
      <category>closure</category>
      <category>Intersection Observer</category>
      <category>next.js</category>
      <category>react</category>
      <category>useref</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/50</guid>
      <comments>https://unhandled.tistory.com/entry/React-Intersection-Observer%EC%9D%B8%ED%84%B0%EC%84%B9%EC%85%98-%EC%98%B5%EC%A0%80%EB%B2%84%EC%9D%98-Stale-Closure%EC%98%A4%EB%9E%98%EB%90%9C-%ED%81%B4%EB%A1%9C%EC%A0%80-%EC%9D%B4%EC%8A%88-useRef%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0#entry50comment</comments>
      <pubDate>Sun, 8 Jun 2025 23:03:23 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router 캐싱(Caching) - 클라이언트 라우터 캐시(Client Router Cache)</title>
      <link>https://unhandled.tistory.com/entry/Nextjs-App-Router-%EC%BA%90%EC%8B%B1Caching-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EB%9D%BC%EC%9A%B0%ED%84%B0-%EC%BA%90%EC%8B%9CClient-Router-Cache</link>
      <description>&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(이 포스팅은 Next.js App Router 15 버전을 기준으로 작성되었습니다. &lt;b&gt;라우터 캐시의 경우 15 버전을 기준으로 이전 버전과 동작 양상이 다릅니다.&lt;/b&gt; 이전 버전을 사용하시는 분들은 참고해 주세요.)&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;(클라이언트) 라우터 캐시&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&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-filename=&quot;라우터캐시3.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;1156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mUUAe/btsOtfmzSre/GQebd0zMihwYzdOKVokX40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mUUAe/btsOtfmzSre/GQebd0zMihwYzdOKVokX40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mUUAe/btsOtfmzSre/GQebd0zMihwYzdOKVokX40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmUUAe%2FbtsOtfmzSre%2FGQebd0zMihwYzdOKVokX40%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;665&quot; height=&quot;454&quot; data-filename=&quot;라우터캐시3.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;1156&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;이 부분에 대해 피그마로 번역버전을 만들어봤습니다.&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-filename=&quot;라우터캐시4.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;1156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvS7W0/btsOsvQZ7nQ/2z4mFhJhkWWuat7o6suwbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvS7W0/btsOsvQZ7nQ/2z4mFhJhkWWuat7o6suwbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvS7W0/btsOsvQZ7nQ/2z4mFhJhkWWuat7o6suwbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvS7W0%2FbtsOsvQZ7nQ%2F2z4mFhJhkWWuat7o6suwbk%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;665&quot; height=&quot;454&quot; data-filename=&quot;라우터캐시4.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;1156&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;캐시 레이어 다이어그램&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;아래는 정적 렌더링 기준으로 캐시가 어떻게 상호작용하고 동작하는지를 보여주는 다이어그램입니다. 제가 체크한 부분이 바로 (클라이언트) 라우터 캐시입니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; 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-filename=&quot;라우터캐시1.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cuOlxp/btsOqF1WbLT/mX7JMr8tzrE8JPWf7spvsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuOlxp/btsOqF1WbLT/mX7JMr8tzrE8JPWf7spvsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuOlxp/btsOqF1WbLT/mX7JMr8tzrE8JPWf7spvsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuOlxp%2FbtsOqF1WbLT%2FmX7JMr8tzrE8JPWf7spvsk%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;라우터캐시1.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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;App Router의 독자적인 캐시들 중 라우터 캐시가 다른 캐시들과 다른 점은 유일하게 &lt;b&gt;클라이언트 사이드에서 동작&lt;/b&gt;한다는 것입니다. (Request Memoization은 App Router의 독자적인 기능이 아니라 리액트 차원에서 제공하는 기능입니다.)&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-filename=&quot;라우터캐시2.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AROtX/btsOsgGr2sl/UQh3mF8gFeO99xOW2QWs80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AROtX/btsOsgGr2sl/UQh3mF8gFeO99xOW2QWs80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AROtX/btsOsgGr2sl/UQh3mF8gFeO99xOW2QWs80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAROtX%2FbtsOsgGr2sl%2FUQh3mF8gFeO99xOW2QWs80%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;라우터캐시2.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;메모리캐시.png&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;482&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lCdxw/btsOsSLRoDo/qDqRnWqdgPfIvKLLMzoXrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lCdxw/btsOsSLRoDo/qDqRnWqdgPfIvKLLMzoXrk/img.png&quot; data-alt=&quot;메모리 캐시와는 다르다!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lCdxw/btsOsSLRoDo/qDqRnWqdgPfIvKLLMzoXrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlCdxw%2FbtsOsSLRoDo%2FqDqRnWqdgPfIvKLLMzoXrk%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;2308&quot; height=&quot;482&quot; data-filename=&quot;메모리캐시.png&quot; data-origin-width=&quot;2308&quot; data-origin-height=&quot;482&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;디스크캐시.png&quot; data-origin-width=&quot;2018&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfRTW8/btsOqBSFqTQ/TD3wP2pWpT6kLTf7CRUO0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfRTW8/btsOqBSFqTQ/TD3wP2pWpT6kLTf7CRUO0k/img.png&quot; data-alt=&quot;디스크 캐시와도 다르다!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfRTW8/btsOqBSFqTQ/TD3wP2pWpT6kLTf7CRUO0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfRTW8%2FbtsOqBSFqTQ%2FTD3wP2pWpT6kLTf7CRUO0k%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;2018&quot; height=&quot;458&quot; data-filename=&quot;디스크캐시.png&quot; data-origin-width=&quot;2018&quot; data-origin-height=&quot;458&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;p data-ke-size=&quot;size16&quot;&gt;메모리 캐시와 디스크 캐시는 HTTP 기반 요청이라면 탑재되어 있는 기본 캐시지만, 라우터 캐시는 Next.js App Router에서 독자적으로 제공하는 캐시 기능입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;어떻게 동작?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;사실 라우터 캐시는 App Router의 4개 캐시 레이어에서 간단한 편에 속합니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 정적 렌더링(Static Rendering) 페이지의 경우&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시5.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RvDOG/btsOqCc4qZr/WgmwaxnuozMczreT75w4p0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RvDOG/btsOqCc4qZr/WgmwaxnuozMczreT75w4p0/img.png&quot; data-alt=&quot;정적 렌더링 페이지의 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RvDOG/btsOqCc4qZr/WgmwaxnuozMczreT75w4p0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRvDOG%2FbtsOqCc4qZr%2FWgmwaxnuozMczreT75w4p0%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;3200&quot; height=&quot;2628&quot; data-filename=&quot;라우터캐시5.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2628&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;정적 렌더링 페이지의 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 최초 진입 시&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 경우는 주소창 접근과 Link와 같은 클라이언트 사이드 내비게이션 접근 시 둘 다 동일합니다. 아직 /a 페이지에 진입하기 전에는 이 경로에 해당하는 라우터 캐시는 존재하지 않습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 페이지는 &lt;b&gt;정적 렌더링 페이지&lt;/b&gt;이므로 해당 페이지에 접속하면 서버는 빌드 때 만들어놓은 &lt;b&gt;풀 라우트 캐시(HTML, RSC Payload)&lt;/b&gt;를 클라이언트에 보내줍니다. 클라이언트는 서버가 전송한 &lt;b&gt;풀 라우트 캐시를 받고 그것을 기반으로 RSC Payload 형태의 라우터 캐시를 만들어 임시적으로 저장(SET)&lt;/b&gt;합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 후속 탐색 시&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;후속 탐색은 최초 진입이 아닌 해당 페이지에 다시 접근하는 것을 의미합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;최초 진입 시 만들어놓은 라우터 캐시를 일정시간(5분) 계속 사용합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;다만 &lt;b&gt;클라이언트 사이드 내비게이션(앞으로 가기, 뒤로 가기, Link, router)&lt;/b&gt;으로 이동하는 경우에만 라우터 캐시가 적용됩니다. 하지만 새로고침 하면 라우터 캐시는 초기화됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 동적 렌더링(Dynamic Rendering) 페이지의 경우&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시6.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2628&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bON1pN/btsOssUgF95/SWaUrLV6wNlBkYMyXv5d20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bON1pN/btsOssUgF95/SWaUrLV6wNlBkYMyXv5d20/img.png&quot; data-alt=&quot;동적 렌더링 페이지의 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bON1pN/btsOssUgF95/SWaUrLV6wNlBkYMyXv5d20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbON1pN%2FbtsOssUgF95%2FSWaUrLV6wNlBkYMyXv5d20%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;3200&quot; height=&quot;2628&quot; data-filename=&quot;라우터캐시6.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2628&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동적 렌더링 페이지의 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 최초 진입 시&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 경우는 주소창 접근과 Link와 같은 클라이언트 사이드 내비게이션 접근 시 둘 다 동일합니다. 아직 /a 페이지에 진입하기 전 이 경로에 해당하는 라우터 캐시는 존재하지 않습니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 페이지는 &lt;b&gt;동적 렌더링 페이지&lt;/b&gt;이므로 서버의 풀 라우트 캐시 또한 존재하지 않으니 서버의 데이터 캐시나 데이터 소스(백엔드)에 가서 데이터를 가져옵니다. 주소창으로 접근 시에는 서버가 즉시 가져온 데이터를 기반으로 HTML 파일을 생성해서 보내줄 것이고, 클라이언트 사이드 내비게이션으로 접근 시에는 서버가 가져온 데이터를 기반으로 RSC 페이로드를 생성해 보내줄 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 데이터를 기반으로 RSC Payload 형태의 클라이언트 라우터 캐시를 생성해서 인메모리에 저장(SET)한다는 것은 정적 렌더링 페이지와 동일합니다. &lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만 동적 렌더링의 경우 정적 렌더링과는 달리 항상 사용하는 것은 아닙니다.&lt;/b&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(아래에서 자세하게 다룹니다.)&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 후속 탐색 시&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;후속 탐색은 최초 진입이 아닌 해당 페이지에 다시 접근하는 것을 의미합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;최초 진입 시 만들어놓은 라우터 캐시를 일정시간 계속 사용(HIT)합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;다만 클라이언트 사이드 내비게이션으로 이동하는 경우에 라우터 캐시를 활용할 수 있는데, &lt;b&gt;정적 렌더링 페이지에 비해 제약&lt;/b&gt;이 있습니다. &lt;b&gt;(앞으로 가기, 뒤로 가기, Link(15 업데이트부터는 정적 렌더링 페이지가 아닌 경우 추가설정 필요), router.prefetch() 사용) &lt;/b&gt;이 제약에 대해선 아래에서 다루겠습니다!&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;이때도 새로고침 하면 라우터 캐시는 초기화됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;뒤로 가기 vs Link &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 처음에 동적 렌더링 페이지에서 Link 같은 클라이언트 사이드 내비게이션을 통해 페이지를 이동하면 라우터 캐시가 적용될 것이라 예상했는데 그렇지 않았습니다.&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;동적 렌더링&lt;/b&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;&lt;b&gt;1. 뒤로 가기, 앞으로 가기&amp;nbsp;&lt;/b&gt;&lt;/p&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 data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Link (클라이언트 사이드 내비게이션)&lt;/b&gt;&lt;/p&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 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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;공식문서의 Router Cache&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시2.png&quot; data-origin-width=&quot;837&quot; data-origin-height=&quot;286&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clSTaP/btsOry8855p/tzEbPkC8QDQsJ0acH3qrdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clSTaP/btsOry8855p/tzEbPkC8QDQsJ0acH3qrdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clSTaP/btsOry8855p/tzEbPkC8QDQsJ0acH3qrdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FclSTaP%2FbtsOry8855p%2FtzEbPkC8QDQsJ0acH3qrdK%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;697&quot; height=&quot;286&quot; data-filename=&quot;라우터캐시2.png&quot; data-origin-width=&quot;837&quot; data-origin-height=&quot;286&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;라우터 캐시를 사용하면:&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;레이아웃&lt;/b&gt;들은 캐시 되고 내비게이션시 재사용됩니다. (부분 렌더링)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;로딩 상태&lt;/b&gt;는 즉각적인 내비게이션을 위해 캐시 되고 재사용됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;페이지들은 기본적으로 캐시 되지 않습니다.&lt;/b&gt; 다만, &lt;b&gt;브라우저의 뒤로 가기, 앞으로 가기 내비게이션에는 재사용&lt;/b&gt;됩니다. 당신은 실험적 기능인 staleTimes 설정 옵션을 통해 &lt;b&gt;페이지 세그먼츠의 캐싱&lt;/b&gt;을 활성화할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 말하는 Pages, 즉 &lt;b&gt;Page segments&lt;/b&gt;는 &lt;b&gt;라우터 캐시에 저장될 수 있는 그 페이지의 데이터&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(layout.tsx나 loading.tsx의 RSC Payload도 라우터 캐시에 포함됩니다. 그리고 여러 페이지의 데이터가 라우터 캐시에 저장될 수 있습니다.)&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;즉, layout.tsx나 loading.tsx의 RSC Payload는 라우터 캐시로서 여전히 동작하지만, Next.js 15 업데이트부터는 페이지 세그먼츠 캐싱은 라우터 캐시에서 기본적으로 제외됩니다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시옵트아웃.png&quot; data-origin-width=&quot;840&quot; data-origin-height=&quot;241&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3FTz9/btsOsPIuajZ/qW2JLDuWUzA3EgpXat69i1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3FTz9/btsOsPIuajZ/qW2JLDuWUzA3EgpXat69i1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3FTz9/btsOsPIuajZ/qW2JLDuWUzA3EgpXat69i1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3FTz9%2FbtsOsPIuajZ%2FqW2JLDuWUzA3EgpXat69i1%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;676&quot; height=&quot;241&quot; data-filename=&quot;라우터캐시옵트아웃.png&quot; data-origin-width=&quot;840&quot; data-origin-height=&quot;241&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래는 Link를 통한 이동시에도 클라이언트 라우터 캐시가 적용되었는데 욕을 거하게 드시고 15 버전 업데이트에서 변경된 것 같습니다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시.png&quot; data-origin-width=&quot;840&quot; data-origin-height=&quot;231&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/J2UZg/btsOs30Nbdu/xCywEZ51W4d0sob0EGfsOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/J2UZg/btsOs30Nbdu/xCywEZ51W4d0sob0EGfsOK/img.png&quot; data-alt=&quot;분노가 느껴지는 제목&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/J2UZg/btsOs30Nbdu/xCywEZ51W4d0sob0EGfsOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJ2UZg%2FbtsOs30Nbdu%2FxCywEZ51W4d0sob0EGfsOK%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;695&quot; height=&quot;231&quot; data-filename=&quot;라우터캐시.png&quot; data-origin-width=&quot;840&quot; data-origin-height=&quot;231&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;b&gt;스테일타임즈(staleTimes)&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749175162808&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;next.config.js: staleTimes | Next.js&quot; data-og-description=&quot;Learn how to override the invalidation time of the Client Router Cache.&quot; data-og-host=&quot;nextjs.org&quot; data-og-source-url=&quot;https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes&quot; data-og-url=&quot;https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bybo4a/hyY1gctsGE/cpQMx3kjEkNwdtxVyxU14k/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/c0k6lH/hyY49W3rbo/UE8PikrP3g1AhpPqRKHloK/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628&quot;&gt;&lt;a href=&quot;https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nextjs.org/docs/app/api-reference/config/next-config-js/staleTimes&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bybo4a/hyY1gctsGE/cpQMx3kjEkNwdtxVyxU14k/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628,https://scrap.kakaocdn.net/dn/c0k6lH/hyY49W3rbo/UE8PikrP3g1AhpPqRKHloK/img.png?width=1200&amp;amp;height=628&amp;amp;face=0_0_1200_628');&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;next.config.js: staleTimes | Next.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Learn how to override the invalidation time of the Client Router Cache.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 staleTimes는 현시점(25.06.06)에는 실험적 기능입니다! 참고해 주세요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시_스테일타임.png&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1163&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8NEMY/btsOrATqbGM/erKi32sdQgkLxk9tz6Kd5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8NEMY/btsOrATqbGM/erKi32sdQgkLxk9tz6Kd5K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8NEMY/btsOrATqbGM/erKi32sdQgkLxk9tz6Kd5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8NEMY%2FbtsOrATqbGM%2FerKi32sdQgkLxk9tz6Kd5K%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;552&quot; height=&quot;759&quot; data-filename=&quot;라우터캐시_스테일타임.png&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1163&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;size18&quot;&gt;&lt;b&gt;동적 프로퍼티 (Dynamic Property)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 페이지가 동적 렌더링 된 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 프리페치(prefetch)가 완전하게 적용되지 않은 경우 (Link의 prefetch 옵션이 null이거나 {false}인 경우)&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749174963726&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 프리페치 null
&amp;lt;Link href={`/test`}&amp;gt;
  테스트 페이지로 가기
&amp;lt;/Link&amp;gt;

// 프리페치 false
&amp;lt;Link href={`/test`} prefetch={false}&amp;gt;
  테스트 페이지로 가기
&amp;lt;/Link&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 기본 0초 캐싱 (사실 저장은 합니다. 다만 재검증 시간이 0초로 설정되어 있으므로 곧바로 만료되고 매번 새로운 요청을 보내게 됩니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까의 경우 저는 아무런 프리페치 설정을 부여하지 않았으니 null에 해당합니다. 그래서 동적 렌더링 페이지에서 Link를 통해 이동했을 때 라우터 캐시가 적용되지 않았던 것입니다.&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;size18&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; Q.&lt;/b&gt; 0초 만에 만료되는데 어떻게 앞으로 가기/뒤로 가기 시에는 라우터 개시가 적용되나요?&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;A. &lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;이때는 브라우저가 과거의 history entry를 복원하고 App Router는 그 안에 있는 Router Tree를 그대로 적용한다고 합니다. (Link를 통한 이동과는 다른 로직이나 라우터 캐시를 사용하는 것은 맞다고 합니다. 이 부분은 저도 명확하게 이해하진 못 했습니다.) 그리고 이러한 페이지 전환 시에는 staleTimes 설정의 영향을 받지 않습니다. (이는 브라우저의 BF캐시와는 유사하게 동작하나 엄밀히 말하면 다릅니다.)&lt;/span&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;size18&quot;&gt;&lt;b&gt;정적 프로퍼티 (Static Property)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 페이지가 정적 렌더링 된 경우&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 프리페치(prefetch)가 완전하게 적용된 경우 (Link의 prefetch 옵션이 {true}인 경우)&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. router.prefetch() 사용&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749175022429&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 프리페치 true
&amp;lt;Link href={`/test`} prefetch={true}&amp;gt;
  테스트 페이지로 가기
&amp;lt;/Link&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;&amp;rarr; 기본 5분 캐싱&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;size18&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q.&lt;/b&gt; 동적 렌더링인데 Link prefetch={true}라면 어떻게 되나요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A.&lt;/b&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;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Link prefetch={true}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. router.prefetch() 사용&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결법 정리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. config 파일에 staleTimes 옵션 추가&lt;/b&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;링크 컴포넌트는 그대로 두고 config 파일을 수정합니다.&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;next.config.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749174441784&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const nextConfig: NextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 10, // 10초 동안 캐시
      static: 600, // 10분 동안 캐시
    },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&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;동적 페이지는 기본 0초 &amp;rarr; 10초&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 페이지는 기본 5분 &amp;rarr; 10분&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 링크 컴포넌트에 prefetch={true}로 명시적 설정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749174540449&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  &amp;lt;Link prefetch={true} href={`/test`}&amp;gt;
      테스트 페이지로 가기
  &amp;lt;/Link&amp;gt;&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;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘의 주제인 라우터 캐시는 아마도 App Router의 4중 캐시 레이어 중 구조상 상대적으로 간단한(?) 캐시일 것 같습니다. (하지만 제가 아는 것이 전부가 아니기에 아닐 수도 있습니다...) 이번 15 버전 업데이트에서 상당히 큰 변화가 있었던 기능으로 보입니다.&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;App Router의 캐시 레이어는 복잡하지만 그래도 하나하나씩 알아가면 &quot;아 그래서 그때 그랬구나&quot;를 깨닫게 되는 순간이 오는 것 같습니다!&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;</description>
      <category>FE/Next.js</category>
      <category>App router</category>
      <category>caching</category>
      <category>Link</category>
      <category>next.js</category>
      <category>Prefetch</category>
      <category>router cache</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/49</guid>
      <comments>https://unhandled.tistory.com/entry/Nextjs-App-Router-%EC%BA%90%EC%8B%B1Caching-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EB%9D%BC%EC%9A%B0%ED%84%B0-%EC%BA%90%EC%8B%9CClient-Router-Cache#entry49comment</comments>
      <pubDate>Fri, 6 Jun 2025 12:14:06 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router의 클라이언트 컴포넌트에서의 { cache: &amp;quot;no-store&amp;quot; }는 동적 렌더링으로 전환시키지 않는다</title>
      <link>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%9D%98-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%97%90%EC%84%9C-cache-no-store-%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EB%8F%99%EC%A0%81-%EB%A0%8C%EB%8D%94%EB%A7%81%EC%9C%BC%EB%A1%9C-%EC%A0%84%ED%99%98%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
      <description>&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전을 기준으로 작성되었으며 현 시점 실험적 기능인 ppr(Partial Prerendering)을 사용하지 않았습니다.)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;데이터 페칭시 cache 옵션 부여&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;지난 포스팅에서 &quot;데이터 fetch시 cache: no-store 옵션을 추가했는데도 html파일이 생성된다.&quot; &quot;이상하다. 제 예상과 다르다&quot;라고 언급한 적이 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1749090441915&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Next.js] App Router의 클라이언트 컴포넌트의 자바스크립트는 서버에서 실행될 수도 있다&quot; data-og-description=&quot;사실 제목 자체는 관심을 끌기 위한 문장입니다. 세부사실을 명시하지 않았죠. 결론부터 말하자면 클라이언트 컴포넌트의 &amp;ldquo;일부 자바스크립트&amp;rdquo;는 서버에서 실행될 수도 있고 실행되지 않을 &quot; data-og-host=&quot;unhandled.tistory.com&quot; data-og-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%9D%98-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%8B%A4%ED%96%89%EB%90%A0-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8B%A4&quot; data-og-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%9D%98-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%8B%A4%ED%96%89%EB%90%A0-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8B%A4&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/zOPm0/hyY30FUqpE/rg7phjasZEvQejtR9LM8gK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/5IGGb/hyY393UieD/8FoTSGwxZlKAy41FvYZlk0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/Ht3ee/hyY4fbZhhy/mKYyb8lOkl2LPcLqsgR2Q1/img.png?width=1876&amp;amp;height=1244&amp;amp;face=0_0_1876_1244&quot;&gt;&lt;a href=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%9D%98-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%8B%A4%ED%96%89%EB%90%A0-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8B%A4&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%9D%98-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%9D%98-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-%EC%8B%A4%ED%96%89%EB%90%A0-%EC%88%98%EB%8F%84-%EC%9E%88%EB%8B%A4&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/zOPm0/hyY30FUqpE/rg7phjasZEvQejtR9LM8gK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/5IGGb/hyY393UieD/8FoTSGwxZlKAy41FvYZlk0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/Ht3ee/hyY4fbZhhy/mKYyb8lOkl2LPcLqsgR2Q1/img.png?width=1876&amp;amp;height=1244&amp;amp;face=0_0_1876_1244');&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;[Next.js] App Router의 클라이언트 컴포넌트의 자바스크립트는 서버에서 실행될 수도 있다&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;사실 제목 자체는 관심을 끌기 위한 문장입니다. 세부사실을 명시하지 않았죠. 결론부터 말하자면 클라이언트 컴포넌트의 &amp;ldquo;일부 자바스크립트&amp;rdquo;는 서버에서 실행될 수도 있고 실행되지 않을&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;unhandled.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;아래는 위 포스팅의 해당 부분 캡처입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;이전글.png&quot; data-origin-width=&quot;851&quot; data-origin-height=&quot;932&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpHQs3/btsOqruruwh/ewygEnUehiQkiQh1UJIgBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpHQs3/btsOqruruwh/ewygEnUehiQkiQh1UJIgBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpHQs3/btsOqruruwh/ewygEnUehiQkiQh1UJIgBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpHQs3%2FbtsOqruruwh%2FewygEnUehiQkiQh1UJIgBk%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;693&quot; height=&quot;932&quot; data-filename=&quot;이전글.png&quot; data-origin-width=&quot;851&quot; data-origin-height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제가 저 당시에 했던 생각을 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. 데이터 페칭시 부여하는 캐시 옵션이 그 페이지의 렌더링 방식에 영향을 주는 줄 알았습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 그런데 { cache: &quot;no-store&quot; } 옵션이나 { next: { revalidate: 0, }, } 옵션을 추가해도 여전히 HTML 파일을 생성했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;3. 경로에 해당하는 HTML 파일을 생성했다는 것은 해당 페이지가 정적 렌더링되었다는 것입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;4. { cache: &quot;no-store&quot; } 옵션이 페이지를 동적 렌더링되게 만들거라 생각했던 제 기존의 지식에 반하는 결과였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 결과만 봤을 때에는 &quot;그럼 저 캐시 옵션은 페이지의 렌더링 방식에는 영향을 끼치지 않는 건가?&quot;라는 생각이 들었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;같은 캐시 옵션 추가, 하지만 다른 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이번에 데이터 페칭시 { cache:no-store } 옵션을 추가하니 html파일이 생성되지 않았습니다. 지난 포스팅에 적혀있는 내용과는 대비되는 결과입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공식문서에는 { cache: &quot;no-store&quot; } 옵션을 fetch시 추가하면 해당 컴포넌트가 동적 렌더링으로 전환된다고 적혀있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;다이나믹페치캐시.png&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;550&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LmF7I/btsOqTcV52W/qaeOESILnz63ptKbStkfeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LmF7I/btsOqTcV52W/qaeOESILnz63ptKbStkfeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LmF7I/btsOqTcV52W/qaeOESILnz63ptKbStkfeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLmF7I%2FbtsOqTcV52W%2FqaeOESILnz63ptKbStkfeK%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;554&quot; height=&quot;550&quot; data-filename=&quot;다이나믹페치캐시.png&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;550&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;컴포넌트 중 하나라도 동적 렌더링 되면 페이지 전체가 동적 렌더링으로 전환됩니다. (저는 현재 실험적 기능인 ppr은 아직 사용하지 않고 있습니다.)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;제가 무엇을 착각하고 있던 걸까요?&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;차이점 발견&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;지난 게시글은 테스트 대상이 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/span&gt;였습니다. 그리고 이번 테스트는 &lt;b&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;서버 컴포넌트&lt;/span&gt;&lt;/b&gt;에서 진행했죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;지난 포스팅의 페이지는 HTML을 생성했으므로 정적 렌더링된 것이 맞습니다. 근데 핵심은 &lt;span style=&quot;color: #8a3db6;&quot;&gt;&lt;b&gt;클라이언트 컴포넌트&lt;/b&gt;&lt;/span&gt;의 데이터 페칭은 이 페이지가 정적 렌더링되냐 동적 렌더링되냐 여부에 상관없이 &lt;b&gt;런타임에 클라이언트에서 발생&lt;/b&gt;한다는 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;서버 컴포넌트&lt;/span&gt;&lt;/b&gt;는 &lt;b&gt;정적 렌더링의 경우 빌드 타임&lt;/b&gt;에, &lt;b&gt;동적 렌더링의 경우 런타임&lt;/b&gt;에 &lt;b&gt;서버에서&lt;/b&gt; 데이터를 페칭 합니다. 그래서 서버 컴포넌트에서의 데이터 페칭시 no-store 옵션을 추가하는 행위는&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;저장되어 있는 서버의 데이터 캐시를 쓰지 않겠다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 데이터를 새로(&lt;b&gt;new&lt;/b&gt;) 가져오겠다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;라는 의미가 내포되어 있으므로 자동적으로 해당 페이지를 동적 렌더링으로 전환시켜 버린 거죠. 정적 렌더링 상태를 유지하면 빌드 전까지는 새로운 데이터를 가져오는 것이 불가능하니까요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;다이나믹페치캐시.png&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;550&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnrLOz/btsOpFmOGZ3/PSUThbJaElv6rxdhw39pA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnrLOz/btsOpFmOGZ3/PSUThbJaElv6rxdhw39pA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnrLOz/btsOpFmOGZ3/PSUThbJaElv6rxdhw39pA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnrLOz%2FbtsOpFmOGZ3%2FPSUThbJaElv6rxdhw39pA1%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;579&quot; height=&quot;550&quot; data-filename=&quot;다이나믹페치캐시.png&quot; data-origin-width=&quot;823&quot; data-origin-height=&quot;550&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;결국 이 문서는 맞지만 &lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;서버 컴포넌트&lt;/b&gt;&lt;/span&gt;에서 &lt;b&gt;데이터 페칭&lt;/b&gt;시에만 &lt;b&gt;적용되는 옵션이었던 것입니다.&lt;/b&gt; 저 cache 옵션 자체는 서버의&lt;b&gt; 데이터 캐시&lt;/b&gt;(App Router의 독자적 캐시, 기존의 HTTP 헤더의 캐시와는 다릅니다.)와 관련된 설정입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;&lt;span style=&quot;color: #8a3db6;&quot;&gt;클라이언트 컴포넌트&lt;/span&gt;&lt;/b&gt;에서의 데이터 페칭에서는 의미 없는 옵션일 가능성이 높습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;데이터 페칭시 아래의 경우에는 { cache: &quot;no-store&quot; } 옵션이 유효합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ServerComponent.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749094326537&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function fetchUserData() {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/2`, {
    cache: &quot;no-store&quot;,
  });&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 아래의 경우에는 { cache: &quot;no-store&quot; } 옵션을 부여한 것이 아무런 영향을 미치지 못합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ClientComponent.tsx&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749094296557&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  useEffect(() =&amp;gt; {
    async function getDataFiveClient() {
      const res = await fetch(`https://jsonplaceholder.typicode.com/users/1`, {
        cache: &quot;no-store&quot;,
      });
      const data = await res.json();&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예전에는 저 페칭시 cache가 클라이언트의 캐시인 줄 알았습니다. (클라이언트에서 영향을 끼치는 건 사실상 라우터 캐시죠.) 당시에는 세세한 캐시 레이어를 구별해서 인식하지 못했기에&amp;nbsp;서버의 캐시를 가져온다는 생각을 하지 못했던 것 같습니다.&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 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FE/Next.js</category>
      <category>App router</category>
      <category>caching</category>
      <category>next.js</category>
      <category>no-store</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/48</guid>
      <comments>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%9D%98-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%97%90%EC%84%9C-cache-no-store-%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%8F%84-%EB%8F%99%EC%A0%81-%EB%A0%8C%EB%8D%94%EB%A7%81%EC%9C%BC%EB%A1%9C-%EC%A0%84%ED%99%98%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-%EC%9D%B4%EC%9C%A0#entry48comment</comments>
      <pubDate>Thu, 5 Jun 2025 12:48:38 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router 캐싱(Caching) - 풀 라우트 캐시(Full Route Cache)</title>
      <link>https://unhandled.tistory.com/entry/Nextjs-App-Router-Full-Route-Cache-%ED%92%80-%EB%9D%BC%EC%9A%B0%ED%8A%B8-%EC%BA%90%EC%8B%9C</link>
      <description>&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 App Router 15 버전을 기준으로 작성되었으며, 제 주관적인 추측과 확실하지 않은 가설에 관한 내용이 대부분입니다. 그러므로 명백하고 정확한 정보를 원하신다면 적합한 글이 아닐 가능성이 높습니다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 탐구는 며칠 전 같은 페이지라도 주소창 접근 시와 클라이언트 사이드 내비게이션으로&amp;nbsp;접근 시 반환하는 데이터가 다르다는 (&lt;s&gt;공식문서에 대놓고 적혀있는&lt;/s&gt;) 놀라운 사실을 알게 된 후 즉흥적으로 해본 실험에서 시작되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;페이지가 정적 렌더링 된다면&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 페이지가 정적 렌더링(빌드타임 렌더링)되면 그 경로에 대응되는 파일이 빌드 폴더에 생성됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트2.png&quot; data-origin-width=&quot;752&quot; data-origin-height=&quot;2134&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6usca/btsOmJixjjM/bb73m9V9Kt2KTC8G0BhJa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6usca/btsOmJixjjM/bb73m9V9Kt2KTC8G0BhJa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6usca/btsOmJixjjM/bb73m9V9Kt2KTC8G0BhJa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6usca%2FbtsOmJixjjM%2Fbb73m9V9Kt2KTC8G0BhJa1%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;220&quot; height=&quot;624&quot; data-filename=&quot;풀라우트2.png&quot; data-origin-width=&quot;752&quot; data-origin-height=&quot;2134&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;예를 들어 app/list/page.tsx가 존재하고 이 페이지가 정적 렌더링 된다면&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;.next/server/app/list.html&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.next/server/app/list.meta&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.next/server/app/list.rsc&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;이렇게 3개의 파일들을 빌드타임에 생성합니다.&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;여기에서 html 파일과 rsc 파일이 각각 App Router의 4중 캐시 레이어의 &lt;b&gt;풀 라우트 캐시(Full Route Cache)&lt;/b&gt;에 해당하는 파일인 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;풀 라우트 캐시(Full Route Cache) - 4중 캐시 레이어&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&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;아래의 이미지는 공식 문서의 Caching 항목에서 가져온 것으로, 해당 페이지가 &lt;b&gt;정적으로 렌더링 되는 경우&lt;/b&gt;(빌드타임 렌더링)의 캐싱 동작 프로세스를 보여줍니다.&amp;nbsp;&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;풀 라우트 캐시는 App Router의 4중 캐시 시스템 중에 아래 체크된 부분에 해당합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트4.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cokjib/btsOmbtfSSB/khpUEYxRKGHESRch1vJE21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cokjib/btsOmbtfSSB/khpUEYxRKGHESRch1vJE21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cokjib/btsOmbtfSSB/khpUEYxRKGHESRch1vJE21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcokjib%2FbtsOmbtfSSB%2FkhpUEYxRKGHESRch1vJE21%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;풀라우트4.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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;(위치가 서버인 것은 빌드 산출물 폴더(.next/server/app/ 폴더 하위)에 존재하기 때문입니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;Full Route Cache (풀 라우트 캐시)&lt;/b&gt;&lt;br /&gt;Next.js automatically renders and caches routes at build time. This is an optimization that allows you to serve the cached route instead of rendering on the server for every request, resulting in faster page loads.&lt;br /&gt;&lt;br /&gt;Next.js는 빌드타임에 자동적으로 경로를 렌더링 하고 캐싱합니다. 이는 모든 요청에 대해서 서버에서 렌더링(SSR)하는 대신 캐시된 경로(SSG)를 제공할 수 있게 하는 최적화 기능으로, 결과적으로 페이지 로드 속도가 향상됩니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;React renders Server Components into a special data format, optimized for streaming, called the React Server Component Payload.&lt;br /&gt;&lt;br /&gt;리액트는 서버 컴포넌트를 스트리밍을 위해 최적화된 RSC Payload라고 불리는 특별한 데이터 형식으로 렌더링 합니다.&amp;nbsp;&lt;br /&gt;&lt;br /&gt;Next.js uses the React Server Component Payload and Client Component JavaScript instructions to render HTML on the server.&lt;br /&gt;&lt;br /&gt;Next.js는 서버에서 RSC Payload와 클라이언트 컴포넌트의 자바스크립트 지침들(Placeholder)을 HTML로 렌더링 합니다.&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;이 설명을 보면 풀 라우트 캐시가 생성될 때 둘이 동시에 하나의 소스를 참조하여 생성되는 것이 아니라 먼저 생성된 list.rsc를 기반으로 list.html이 생성되는 것으로 보입니다. 그리고 새로고침이 아닌 Link로 해당 페이지 재진입시 특정 시간(30초였나?)이 지나지 않았다면 이 풀 라우트 캐시(.rsc)를 요청하지 않고 이전 진입으로 인해 생성된 클라이언트 라우터 캐시를 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;풀 라우트 캐시를 삭제하면 어떤 일이 발생할까?&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;그래서 왠지 저 list.html은 주소창 접근 시 갖다 쓰는 파일이고, list.rsc은 클라이언트 사이드 내비게이션으로 접근 시 갖다쓰는 파일 같다는 생각이 들더군요.&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;list.meta는 일종의 메타데이터입니다.&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;그렇다면 저걸 지워버리면 해당 페이지 접속 시 에러가 발생하겠군 싶었습니다. 예를 들어 html을 지우면 해당 경로에 주소창 접근 시 에러 발생, rsc을 지우면 링크 컴포넌트를 통해 접근시 에러 발생이 예상되는 시나리오였죠. 하지만 결과는 그렇지 않았습니다.&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;프로젝트 빌드&lt;/b&gt; &amp;rarr; &lt;b&gt;해당 파일(.html, .rsc) 삭제&lt;/b&gt; &amp;rarr; &lt;b&gt;npm start(프로덕션 환경 시작)&lt;/b&gt; &amp;rarr; &lt;b&gt;localhost:3000 접속&amp;nbsp;&lt;/b&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;화면이 멀쩡하게 잘 떴습니다. 하지만 네트워크 창을 자세히 살펴보니 Response Headers가 조금 다른 부분이 있었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-06-03 084134.png&quot; data-origin-width=&quot;1337&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b59Mu9/btsOnXtOQpV/PcyOZgd6xbxkYqevYFeK6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b59Mu9/btsOnXtOQpV/PcyOZgd6xbxkYqevYFeK6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b59Mu9/btsOnXtOQpV/PcyOZgd6xbxkYqevYFeK6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb59Mu9%2FbtsOnXtOQpV%2FPcyOZgd6xbxkYqevYFeK6K%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;1337&quot; height=&quot;823&quot; data-filename=&quot;스크린샷 2025-06-03 084134.png&quot; data-origin-width=&quot;1337&quot; data-origin-height=&quot;823&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;X-Nextjs-Cache라는 key가 MISS를 반환했습니다. 그리고 또 신기한 것은 제가 분명 html 파일과 rsc 파일을 삭제했었는데 다시 생성되어 있습니다.&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;그리고 이 상태에서 다시 &quot;새로고침&quot;을 하면 아까 MISS였던 X-Nextjs-Cache가 HIT이 됩니다.&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; 이외의 Revalidation 과정 또한 서버의 캐시 파일을 업데이트할 수도 있는 것으로 추측합니다.&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;게다가 이 페이지는 분명 정적 렌더링 페이지(SSG)인데 동적 렌더링(SSR)처럼 아예 데이터베이스에서 새로 업데이트된 데이터를 가져옵니다. (현재 상황에서 HTML, rsc 파일을 생성하기 위해 참조하는 데이터의 출처가 빌드 폴더 내의 파일이 아닐 가능성이 높다는 의미) 마치 ISR 페이지처럼 동작한 것입니다.&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;(근데 supabase를 백엔드처럼 쓰니 캐시 테스트가 번거롭네요... 후회 중)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;X-Nextjs-Cache : HIT or MISS&lt;/b&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;&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. 빌드 후 &lt;b&gt;풀 라우트 캐시를 지우지 않고&lt;/b&gt; npm start 했을 경우 (주소창 초기접속)&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;X-Nextjs-Cache가 HIT입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트8.png&quot; data-origin-width=&quot;1357&quot; data-origin-height=&quot;830&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ccOeH6/btsOnZFblLK/ldlTshNM4Yw69ifBzB26h1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ccOeH6/btsOnZFblLK/ldlTshNM4Yw69ifBzB26h1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ccOeH6/btsOnZFblLK/ldlTshNM4Yw69ifBzB26h1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FccOeH6%2FbtsOnZFblLK%2FldlTshNM4Yw69ifBzB26h1%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;1357&quot; height=&quot;830&quot; data-filename=&quot;풀라우트8.png&quot; data-origin-width=&quot;1357&quot; data-origin-height=&quot;830&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;위 상태에서 새로고침 (200 -&amp;gt; 304 응답)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태는 저장된 브라우저 캐시? 클라이언트 캐시를 사용하므로 304 응답이 온 것 같습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트10.png&quot; data-origin-width=&quot;1347&quot; data-origin-height=&quot;705&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUILYM/btsOmUdv6Wc/2KZiVyeS7mZ5KNqh42yfFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUILYM/btsOmUdv6Wc/2KZiVyeS7mZ5KNqh42yfFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUILYM/btsOmUdv6Wc/2KZiVyeS7mZ5KNqh42yfFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUILYM%2FbtsOmUdv6Wc%2F2KZiVyeS7mZ5KNqh42yfFK%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;1347&quot; height=&quot;705&quot; data-filename=&quot;풀라우트10.png&quot; data-origin-width=&quot;1347&quot; data-origin-height=&quot;705&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;다음 과정으로 넘어가기 전에 서버 캐시가 남아서 혹여라도 영향을 끼칠지 모르니 빌드 폴더를 한 번 삭제하고 다시 빌드하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. 빌드 후&amp;nbsp; 풀 라우트 캐시를 &lt;b&gt;지우고&lt;/b&gt; npm start 했을 경우 (주소창 초기접속)&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 X-Nextjs-Cache가 MISS입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트5.png&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfSey2/btsOmwRvD9g/hcBLdXMPVhXbBd8oiGeeBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfSey2/btsOmwRvD9g/hcBLdXMPVhXbBd8oiGeeBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfSey2/btsOmwRvD9g/hcBLdXMPVhXbBd8oiGeeBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfSey2%2FbtsOmwRvD9g%2FhcBLdXMPVhXbBd8oiGeeBK%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;1026&quot; height=&quot;768&quot; data-filename=&quot;풀라우트5.png&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;768&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;그리고 다시 빌드폴더를 보면 아까 제가 삭제했던 rsc, html파일들이 다시 생성되어 있습니다.&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;그리고 HIT이었을 때와 MISS가 떴을 때 특정 지표에 큰 차이가 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DOMContentLoaded / Load 지표&lt;/b&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;1. (HIT) 빌드 후&amp;nbsp; &lt;b&gt;풀 라우트 캐시를 지우지 않고&lt;/b&gt; npm start 했을 경우 (주소창 초기접속)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트13.png&quot; data-origin-width=&quot;1076&quot; data-origin-height=&quot;48&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvaGFw/btsOnpqpGgy/wSnjogGTabIg4jHwxm7O91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvaGFw/btsOnpqpGgy/wSnjogGTabIg4jHwxm7O91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvaGFw/btsOnpqpGgy/wSnjogGTabIg4jHwxm7O91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvaGFw%2FbtsOnpqpGgy%2FwSnjogGTabIg4jHwxm7O91%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;1076&quot; height=&quot;48&quot; data-filename=&quot;풀라우트13.png&quot; data-origin-width=&quot;1076&quot; data-origin-height=&quot;48&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;2. (MISS) 빌드 후&amp;nbsp; &lt;b&gt;풀 라우트 캐시를 지우고&lt;/b&gt; npm start 했을 경우 (주소창 초기접속)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트7.png&quot; data-origin-width=&quot;1126&quot; data-origin-height=&quot;46&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sBhBr/btsOogUhFa3/2wd1LN7uJJAPOMcE7urMnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sBhBr/btsOogUhFa3/2wd1LN7uJJAPOMcE7urMnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sBhBr/btsOogUhFa3/2wd1LN7uJJAPOMcE7urMnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsBhBr%2FbtsOogUhFa3%2F2wd1LN7uJJAPOMcE7urMnk%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;1126&quot; height=&quot;46&quot; data-filename=&quot;풀라우트7.png&quot; data-origin-width=&quot;1126&quot; data-origin-height=&quot;46&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;이 풀 라우트 캐시는 DOMContentLoaded 지표와 Load 지표에 큰 영향을 끼치는 것 같습니다. 풀 라우트 캐시가 없으므로 데이터 소스(백엔드, DB)까지 찾아가서 데이터를 가져와 다시 HTML와 RSC를 생성해서 보내주었기 때문에 그만큼의 시간이 더 소요된 것 같습니다. (아마 SSR과 유사할지도 모르겠습니다.)&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;신기한 부분&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 위에서 &lt;b&gt;풀라우트 캐시가 없어도 풀라우트 캐시를 런타임에 다시 생성한다고 했잖아요? 그런데 이 생성한다는 것은 신기하게 &quot;서버의 데이터 캐시&quot;를 참조하지 않고 데이터 베이스에서 새로 가져옵니다&lt;/b&gt;. 현재 페이지는 정적 렌더링(빌드타임 렌더링) 페이지인데, 이 순간에는 런타임 동적 렌더링의 방식으로 동작한 것입니다. 마치 ISR처럼 말입니다.&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;풀라우트 캐시가 없을 때 빌드를 하면 풀라우트 캐시를 &quot;서버의 데이터 캐시&quot;를 참조해서 생성&lt;/b&gt;한 것과는 대조적입니다. (이때는 그때 서버 데이터 캐시가 stale 하기 때문에 갱신 전 데이터를 기반으로 HTML을 생성했죠.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;풀 라우트 캐시를 삭제하고 npm start&lt;/b&gt;&lt;/h3&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-filename=&quot;html_rsc_삭제후_start.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/be2CGN/btsOoPhswI6/KAigrjKRCkOWqcPwP2JBq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/be2CGN/btsOoPhswI6/KAigrjKRCkOWqcPwP2JBq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/be2CGN/btsOoPhswI6/KAigrjKRCkOWqcPwP2JBq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbe2CGN%2FbtsOoPhswI6%2FKAigrjKRCkOWqcPwP2JBq0%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;html_rsc_삭제후_start.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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;사실 기존 표(바로 아래에 있습니다)에는 위의 제가 임의로 추가한 REQUEST TIME에서의 MISS, SET 단계가 없습니다. 근데 다시 보니.. 이 부분은 복잡해서인지 따로 분리해 놓았더라고요.&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-filename=&quot;풀라우트4.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baMNO7/btsOnv5rndk/sdq6ykeqcyn8Y6MpfdIZW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baMNO7/btsOnv5rndk/sdq6ykeqcyn8Y6MpfdIZW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baMNO7/btsOnv5rndk/sdq6ykeqcyn8Y6MpfdIZW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaMNO7%2FbtsOnv5rndk%2Fsdq6ykeqcyn8Y6MpfdIZW0%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;풀라우트4.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;재검증(Revalidation) 트리거가 동작된 경우&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 표에는 표현되지 않았지만 이 풀 라우트 캐시의 분기가 MISS/HIT으로 절대 나누어지지 않는 것은 아닙니다.&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-filename=&quot;풀라우트캐시_재검증.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;1776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cIWRFW/btsOoTRCiVp/WkIi6Ll9NyA6HuWdZ6TYR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cIWRFW/btsOoTRCiVp/WkIi6Ll9NyA6HuWdZ6TYR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cIWRFW/btsOoTRCiVp/WkIi6Ll9NyA6HuWdZ6TYR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcIWRFW%2FbtsOoTRCiVp%2FWkIi6Ll9NyA6HuWdZ6TYR1%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;3200&quot; height=&quot;1776&quot; data-filename=&quot;풀라우트캐시_재검증.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;1776&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;런타임에서도 &quot;재검증&quot;이 발생하면 MISS/HIT 분기가 나누어집니다.&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;재검증은 보통 세 경우에서 발생하고, 이것이 바로 위 이미지에서 MISS를 반환하는 경우입니다.&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. 시간 기반 - 특정 시간이 지나서 요청한 경우&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;3. 경로 기반 - 특정 경로에서 요청이 발생한 경우&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;(반대로 특정 시간이 지나지 않았거나, 특정 요청이 발생하지 않았거나, 특정 경로에서 요청이 발생하지 않았다면 HIT을 반환하고, 이전에 만들어 놓은 Full Route Cache를 업데이트하지 않고 그대로 보내줄 것입니다.)&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;위 경우에서 반환하는 MISS는 트리거 기반으로 발생한 것이지 풀라우트 캐시 자체가 존재하지 않아서 발생한 것은 아닙니다. 그러므로 이전의 테스트에서의 양상은 런타임에 발생하긴 했지만 위 3가지 트리거에는 해당하지 않습니다. 그러므로 아까의 현상은 revalidation(재검증) 트리거로 인해 발생한 것은 아닙니다. 다만 있어야 할 HTML/RSC Payload 파일이 없다는 것을 감지해서 발생한 이벤트입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;풀 라우트 캐시를 삭제하고 npm start - 일반적인 상황은 아니다&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재 이 상황(풀 라우트 캐시를 삭제한 상태에서 npm start)은 제가 테스트를 위해 인위적으로 만든 환경입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Full Route Cache는 해당 페이지가 정적 렌더링 과정이 있는 경우(SSG, ISR)에만 빌드 타임에 생성되고, 해당 페이지가 동적 렌더링(런타임 렌더링)만 되게 설정(SSR)되어 있다면 생성되지 않습니다. 그러니까 해당 페이지가 정적 렌더링 과정을 거친다면 무조건 존재하고, 동적 렌더링만 발생한다면 무조건 존재하지 않습니다. 그러므로 이 풀라우트 캐시가 &lt;b&gt;있는지 없는지&lt;/b&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;런타임&lt;/b&gt;에서 확인해야 하는 과정은 자연적으로 발생하지 않을 가능성이 높습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;풀 라우트 캐시를 삭제하고 npm run build&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;html_rsc_삭제후_build.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvfwWl/btsOmHryYNr/tyVmaxEeBjiUYsD3GGbeMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvfwWl/btsOmHryYNr/tyVmaxEeBjiUYsD3GGbeMK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvfwWl/btsOmHryYNr/tyVmaxEeBjiUYsD3GGbeMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvfwWl%2FbtsOmHryYNr%2FtyVmaxEeBjiUYsD3GGbeMK%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;html_rsc_삭제후_build.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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;풀 라우트 캐시를 삭제한 상태에서 다시 빌드를 하면 이전에 저장된 서버의 데이터 캐시를 기반으로 파일을 생성합니다. 그래서 예전 테스트의 경우 stale 한 데이터를 가져온 것입니다.&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-filename=&quot;풀라우트캐시_빌드2.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;1776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmpRac/btsOmsatMIj/aqUvRwwknJmpTu3okSAov0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmpRac/btsOmsatMIj/aqUvRwwknJmpTu3okSAov0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmpRac/btsOmsatMIj/aqUvRwwknJmpTu3okSAov0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmpRac%2FbtsOmsatMIj%2FaqUvRwwknJmpTu3okSAov0%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;3200&quot; height=&quot;1776&quot; data-filename=&quot;풀라우트캐시_빌드2.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;1776&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;빌드 폴더를 삭제하고 npm run build&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 빌드 전에 기존의 빌드 폴더를 삭제해 버리면 서버의 Data Cache 자체가 존재하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;build_폴더_삭제후_build.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b4Oe20/btsOnt7yUcm/zsdw0W2mSzf0UqjOerC5wk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b4Oe20/btsOnt7yUcm/zsdw0W2mSzf0UqjOerC5wk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b4Oe20/btsOnt7yUcm/zsdw0W2mSzf0UqjOerC5wk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb4Oe20%2FbtsOnt7yUcm%2Fzsdw0W2mSzf0UqjOerC5wk%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;build_폴더_삭제후_build.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&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;Data Cache가 존재하지 않으므로 당연히 Data Source까지 가서 데이터를 가져와야 합니다.&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-filename=&quot;풀라우트캐시_빌드1.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;1776&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qGzFi/btsOofuklNk/wDBv5gihHWhcnsGVVUzy91/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qGzFi/btsOofuklNk/wDBv5gihHWhcnsGVVUzy91/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qGzFi/btsOofuklNk/wDBv5gihHWhcnsGVVUzy91/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqGzFi%2FbtsOofuklNk%2FwDBv5gihHWhcnsGVVUzy91%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;3200&quot; height=&quot;1776&quot; data-filename=&quot;풀라우트캐시_빌드1.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;1776&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;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;주소창이 아닌 Link 컴포넌트로 접속하면?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우에도 비슷합니다. 동일하게 html, rsc 파일이 없다면 X-Nextjs-Cache는 MISS가 뜨고 해당 파일을 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(주소창 접근이나 클라이언트 사이드 내비게이션 접근이나 저 둘(.html, .rsc) 중 하나라도 없다면 .html, .meta, .rsc를 전부 생성합니다. 같은 상황에서 .meta 파일은 없다해도 다른 두 파일이 존재한다면 다시 생성되지 않습니다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-06-03 083843.png&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;769&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dYQtsq/btsOmsO2yI0/tHmGTf1rAwnzFCdETouy81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dYQtsq/btsOmsO2yI0/tHmGTf1rAwnzFCdETouy81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dYQtsq/btsOmsO2yI0/tHmGTf1rAwnzFCdETouy81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdYQtsq%2FbtsOmsO2yI0%2FtHmGTf1rAwnzFCdETouy81%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;1026&quot; height=&quot;769&quot; data-filename=&quot;스크린샷 2025-06-03 083843.png&quot; data-origin-width=&quot;1026&quot; data-origin-height=&quot;769&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;(이 경우는 메인 페이지(/)에서 해당 페이지(/list)로 Link 컴포넌트를 통해 이동한 경우입니다. Link 컴포넌트는 프로덕션 모드에서 기본적으로 프리페치를 활성화하므로 해당 페이지(/list)로 이동하지 않아도 메인 페이지(/)에서 이 페이지로 이동하는 링크 컴포넌트가 뷰포트에 보이는 즉시 저 프리페치(list?_rsc=1mu70을 가져오는 것)를 진행합니다. )&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;위 캡처는 해당 페이지(/list)에 대해 클라이언트 사이드 내비게이션을 통한 최초 진입 시 받아오는 데이터입니다. 지금 이 데이터는 서버에서 가져온 RSC payload에 대한 것입니다. 이때 받아온 Full Route Cache의 데이터를 기반으로 클라이언트에서 사용하는 Router Cache를 SET 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;라우터캐시.png&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1210&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Pmd2U/btsOmbzZLpG/rgWV2QzFxg2X9kdUEzRgJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Pmd2U/btsOmbzZLpG/rgWV2QzFxg2X9kdUEzRgJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Pmd2U/btsOmbzZLpG/rgWV2QzFxg2X9kdUEzRgJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPmd2U%2FbtsOmbzZLpG%2FrgWV2QzFxg2X9kdUEzRgJ0%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;709&quot; height=&quot;1210&quot; data-filename=&quot;라우터캐시.png&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1210&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;이후에 해당 페이지에 클라이언트 사이드 내비게이션의 방식(link, router, 이전 페이지 버튼, 다음 페이지 버튼)으로 다시 진입하면 Full Route Cache가 아닌 클라이언트에 존재하는 라우터 캐시를 사용합니다. 다만 새로고침시 또는 일정 시간이 지나면 라우터 캐시가 초기화됩니다. 즉, 다시 서버에서 Full Route Cache를 받아옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까 다이어그램의 Full Route Cache 부분을 다시 살펴보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;풀라우트3.png&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;738&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cifUvi/btsOorVqrjO/SDdnA2kz773Jvt437kWeR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cifUvi/btsOorVqrjO/SDdnA2kz773Jvt437kWeR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cifUvi/btsOorVqrjO/SDdnA2kz773Jvt437kWeR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcifUvi%2FbtsOorVqrjO%2FSDdnA2kz773Jvt437kWeR0%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;437&quot; height=&quot;456&quot; data-filename=&quot;풀라우트3.png&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;738&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;size18&quot;&gt;&lt;b&gt;1.&lt;/b&gt; &lt;b&gt;&lt;span style=&quot;color: #7da3fc; background-color: #000000;&quot;&gt;&amp;nbsp; &lt;span style=&quot;color: #9ad5ca;&quot;&gt;Persistent &lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; Persistent는 지속성이라는 것이죠. 임시적으로 메모리에만 존재하는 것이 아니라 실체가 있는 파일(.html, .rsc)로 계속 존재할 수 있다는 의미입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2.&lt;/b&gt; &lt;b&gt;&lt;span style=&quot;color: #7da3fc; background-color: #000000;&quot;&gt;&amp;nbsp; &lt;span style=&quot;color: #ceafe8;&quot;&gt;RSC Payload&lt;/span&gt;&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; RSC Payload - list.rsc처럼 rsc 확장자를 가진 파일입니다. 클라이언트 사이드 내비게이션 접속 시 사용하는 것처럼 보였는데, 위의 도표를 보니 3번의 HTML 파일을 생성할 때에도 혹시 이 파일이 사용되나? 싶었습니다. 정말로 그런지는 아직 파악하지 못했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. &lt;span style=&quot;color: #7da3fc; background-color: #000000;&quot;&gt;&amp;nbsp; HTML&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; HTML - list.html처럼 html 파일입니다. 빌드 시 렌더링 된 html 결과물입니다. 주소창 접근 시 서버에서 이 파일을 보내줄 것입니다. (HTML 파일이지만 RSC Payload 형태의 데이터를 일부 포함하고 있습니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;+) 덤&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아까의 캡처를 보시면.. Response Headers에 X-Nextjs-Stale-Time이라는 key가 있습니다. 처음에는 이건 뭐지? 지금 이 상황과 관련이 있는 건가?라고 생각했는데.. 그건 아닙니다. 방금 이슈와 관련 있는 문제는 아니었습니다. (정말일까..)&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-filename=&quot;스크린샷 2025-06-03 084134.png&quot; data-origin-width=&quot;1337&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IPbLs/btsOojQVbwS/TNITvayCOhUU1YrilZDGu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IPbLs/btsOojQVbwS/TNITvayCOhUU1YrilZDGu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IPbLs/btsOojQVbwS/TNITvayCOhUU1YrilZDGu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIPbLs%2FbtsOojQVbwS%2FTNITvayCOhUU1YrilZDGu1%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;1337&quot; height=&quot;823&quot; data-filename=&quot;스크린샷 2025-06-03 084134.png&quot; data-origin-width=&quot;1337&quot; data-origin-height=&quot;823&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;4294967294라는 숫자는 이 페이지가 새로운 재검증 없이 정적 렌더링 될 때 모든 사람한테 동일하다고 합니다. 즉 이 페이지가 ISR이 아닌 SSG 페이지일 때 표시됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1748913475948&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;Why is the x-nextjs-stale-time always 4294967294? &amp;middot; vercel next.js &amp;middot; Discussion #72398&quot; data-og-description=&quot;Summary I'm debugging caching and I noticed the following in /app/.next/server/app/page/example.meta (the ISR cache): { &amp;quot;headers&amp;quot;: { &amp;quot;x-nextjs-stale-time&amp;quot;: &amp;quot;4294967294&amp;quot;, &amp;quot;x-next-cache-tags&amp;quot;: &amp;quot;...&amp;quot; ...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/vercel/next.js/discussions/72398&quot; data-og-url=&quot;https://github.com/vercel/next.js/discussions/72398&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dWds2w/hyY47dqvTO/h6TkzQUhVmrpbszhmpBPEK/img.png?width=1200&amp;amp;height=600&amp;amp;face=988_129_1055_202,https://scrap.kakaocdn.net/dn/h2fyb/hyY4btBx1A/zxSeUvUfDEsxO5K97dpJj0/img.png?width=1200&amp;amp;height=600&amp;amp;face=988_129_1055_202&quot;&gt;&lt;a href=&quot;https://github.com/vercel/next.js/discussions/72398&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/vercel/next.js/discussions/72398&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dWds2w/hyY47dqvTO/h6TkzQUhVmrpbszhmpBPEK/img.png?width=1200&amp;amp;height=600&amp;amp;face=988_129_1055_202,https://scrap.kakaocdn.net/dn/h2fyb/hyY4btBx1A/zxSeUvUfDEsxO5K97dpJj0/img.png?width=1200&amp;amp;height=600&amp;amp;face=988_129_1055_202');&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;Why is the x-nextjs-stale-time always 4294967294? &amp;middot; vercel next.js &amp;middot; Discussion #72398&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Summary I'm debugging caching and I noticed the following in /app/.next/server/app/page/example.meta (the ISR cache): { &quot;headers&quot;: { &quot;x-nextjs-stale-time&quot;: &quot;4294967294&quot;, &quot;x-next-cache-tags&quot;: &quot;...&quot; ...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 이 페이지는 정적 페이지이므로 빌드타임에 생성된 데이터를 사용합니다. HTTP 헤더에는 무한대를 표시할 수 없으니 32비트 최댓값을 &quot;무한대&quot;의 의미로 사용한 것입니다. 이 페이지는 4294967294초(약 136년) 동안 유효하다는 의미입니다.&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;즉 이 지표의 이 숫자는 &quot;이 페이지는 빌드타임에 정적 렌더링되고 명시적인 revalidate 설정이 적용되지 않았으므로 (새로운 빌드가 발생하기 전까지) 이 페이지는 영원하다&quot;라는 의미라고 하네요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왠지.. 또 길이 엄청 길어졌습니다! 이 캐시 부분은 공식문서에서는 &quot;학습하지 않아도 Next.js 활용하는데 전혀 문제가 없다.&quot;라고 했지만 예상치 못한 상황을 마주하지 않으려면 알고 있어야 하는 중요한 부분 같습니다. 글은.. 좀 다듬어야 할 것 같아요..&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;</description>
      <category>FE/Next.js</category>
      <category>App router</category>
      <category>caching</category>
      <category>full route cache</category>
      <category>next.js</category>
      <category>캐싱</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/47</guid>
      <comments>https://unhandled.tistory.com/entry/Nextjs-App-Router-Full-Route-Cache-%ED%92%80-%EB%9D%BC%EC%9A%B0%ED%8A%B8-%EC%BA%90%EC%8B%9C#entry47comment</comments>
      <pubDate>Tue, 3 Jun 2025 12:28:11 +0900</pubDate>
    </item>
    <item>
      <title>[Next.js] App Router에서 업데이트된 데이터가 빌드 후에도 반영되지 않는 문제 트러블 슈팅 (서버 데이터 캐시)</title>
      <link>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-%EB%B9%8C%EB%93%9C-%EC%A0%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EB%90%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%B9%8C%EB%93%9C-%ED%9B%84%EC%97%90%EB%8F%84-%EA%B0%80%EC%A0%B8%EC%98%A4%EC%A7%80-%EC%95%8A%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-feat-%EC%84%9C%EB%B2%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%9C</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;(이 글은 Next.js App Router 15 버전을 기준으로 한 포스팅입니다.)&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DB 업데이트&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Supabase에서 데이터베이스를 업데이트했습니다. 프로젝트 데이터가 몇 개 더 추가되었죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 페이지는 정적 렌더링(빌드타임 렌더링)되므로 데이터베이스를 업데이트해도 새로 빌드하기 전에는 이전 stale 한 목록을 보여줄 것입니다. 프로덕션 환경에서 새로운 데이터를 보고 싶다면 빌드를 해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1748865334414&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm run build
npm start&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;빌드 후 프로덕션 환경&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이상했습니다. 여전히 같은 목록을 보여주고 있군요. 로컬 개발 환경에서는 정적 렌더링 페이지도 동적 렌더링 방식과 유사하게 동작해서 그런 것 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결법&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;빌드 폴더를 삭제하고 다시 빌드하니까 업데이트된 데이터가 정상적으로 화면에 보입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;용의자 검거&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;빌드 산출물 폴더에 있는 서버 캐시가 매우 유력한 용의자입니다. 빌드할 때 데이터를 Supabase를 통해 새로 가져오는 게 아니라 이전에 생성된 빌드 폴더 내부의 무언가를 참조하고 그것을 기반으로 가져왔을 겁니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;사실 저번에 또 다른 테스트를 하다가 런타임 동작이 빌드 폴더의 내용물을 업데이트할 수도 있다는 것을 알게 되었거든요. 아마 나중에 갖다 쓰기 용이하게 서버 캐시를 만들어놓는 것 같았는데 이 내용을 Next.js 공식문서에서 Deep Dive(심화) &amp;gt; Caching 문서에다 짱박아놔서 저때까지 몰랐습니다. (심지어 이 부분은 심화내용이라 공부하지 않아도 된다고 적혀있었지만 제가 보기엔 전혀 그렇지 않습니다; 매우 중요해 보입니다.)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;App Router의 4중 캐시 레이어 중에서 아래 Data Cache 이 녀석이 가장 가능성이 높아 보입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Group 425.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxgJjR/btsOoq938xd/1YcE2l098xbKCvEPS9csjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxgJjR/btsOoq938xd/1YcE2l098xbKCvEPS9csjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxgJjR/btsOoq938xd/1YcE2l098xbKCvEPS9csjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxgJjR%2FbtsOoq938xd%2F1YcE2l098xbKCvEPS9csjK%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;3200&quot; height=&quot;2358&quot; data-filename=&quot;Group 425.png&quot; data-origin-width=&quot;3200&quot; data-origin-height=&quot;2358&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;서버 캐시 중 Full Route Cache는 server 폴더 내에 생기는 html, rsc파일일 것이고, Request Memoization은 인메모리에만 유효하니까 빌드를 해도 계속 남아있으면서 영향을 끼칠 가능성은 없죠. (게다가 Full Route Cache인 파일들(html, rsc)을 삭제하고 다시 빌드해도 여전히 업데이트되기 전 DB를 기준으로 파일(html)을 생성했습니다. 이것은 Full Route Cache를 생성할 때 서버의 또 다른 캐시를 참조해서 생성했다는 것이고 이 시점에서 그 캐시(데이터 캐시)가 stale 한 상태였다는 것입니다.)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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-filename=&quot;의심스럽다2.png&quot; data-origin-width=&quot;2734&quot; data-origin-height=&quot;742&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5C2Bx/btsOnuyBI9O/IMZmIURJRikneZSTkkHWP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5C2Bx/btsOnuyBI9O/IMZmIURJRikneZSTkkHWP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5C2Bx/btsOnuyBI9O/IMZmIURJRikneZSTkkHWP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5C2Bx%2FbtsOnuyBI9O%2FIMZmIURJRikneZSTkkHWP1%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;2734&quot; height=&quot;742&quot; data-filename=&quot;의심스럽다2.png&quot; data-origin-width=&quot;2734&quot; data-origin-height=&quot;742&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 위 캡처에서 project_with_stacks 이건 지금 문제가 생긴 부분의 데이터에 대응되는, 즉 현재 요청에 대해서 SQL에서 반환하는 뷰 테이블이잖아요?&amp;nbsp; 그러므로... 굉장히 유력하다...! 그리고 이것이 서버 데이터 캐시가 맞는 것 같습니다..! 보시다시피 파일명 앞 로고는 vscode의 자동인식 때문인지 JS로 선택되었지만 JSON 형태의 데이터였습니다!&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 몰랐는가?&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 과거의 팀 프로젝트&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; SSG 페이지가 있었지만 그 페이지에서 사용하는 데이터는 최초의 데이터셋 이후 새로운 데이터가 추가된 적이 없었습니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 저의 포트폴리오&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 데이터를 가져오는 정적 렌더링 페이지가 있었지만 vercel로 배포했기에 마법같이 아무런 문제도 발생하지 않았던 것입니다. (아마 기존 빌드 폴더를 자동으로 지우고 다시 빌드하지 않았을까요?) 하지만 지금 상황은 로컬에서 빌드해서 프로덕션 모드로 진입한 것이죠. vercel의 개입이 없으니 그 &quot;마법&quot;이 안 벌어진 것입니다. fetch시 캐시만 손볼게 아니라 이것도 손봐야 했던 것 아닌가요? (어.. 찾아보니 15 버전부터 fetch시 기본적으로 사용하지 않는 걸로 변경된 캐시가 바로 이 서버 데이터 캐시라고 합니다.. 이상한데요;; 왜냐면 전 supabase로 데이터를 가져오는데 캐싱 설정을 막상 하려니까 생각보다 복잡해지길래 지금까지 캐시 설정을 따로 명시적으로 안 하고 사용해 왔었거든요.. 그럼 아예 적용이 안되어야 하는 것이 아닌가..?? Supabase 요청 내부적으로 fetch를 쓰는 걸로 알고 있는데 말입니다.. 혼란스럽네요)&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;공식 문서에서의 Data Cache&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;공식 문서의 캐싱 항목을 다시보면&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Group 444.png&quot; data-origin-width=&quot;1700&quot; data-origin-height=&quot;1696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHYDuH/btsOqsnKjKp/fjrfe2Az51F6jTq8i6i9W0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHYDuH/btsOqsnKjKp/fjrfe2Az51F6jTq8i6i9W0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHYDuH/btsOqsnKjKp/fjrfe2Az51F6jTq8i6i9W0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHYDuH%2FbtsOqsnKjKp%2Ffjrfe2Az51F6jTq8i6i9W0%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;599&quot; height=&quot;1696&quot; data-filename=&quot;Group 444.png&quot; data-origin-width=&quot;1700&quot; data-origin-height=&quot;1696&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;For uncached data (e.g. no cache option defined or using { cache: 'no-store' }), the result is always fetched from the data source, and memoized.&lt;br /&gt;&lt;br /&gt;&quot;cache 옵션을 정의하지 않았거나 { cache: 'no-store' }을 사용할 경우, 결과는 항상 데이터 소스(백엔드)에서 fetch 되고 memoized 됩니다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;라고 나와있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;저 부분의 데이터를 가져오는 코드는 현재 아래와 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;supabase.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749105007956&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createClient } from &quot;@supabase/supabase-js&quot;;

if (!process.env.SUPABASE_URL || !process.env.SUPABASE_ANON_KEY) {
  throw new Error(&quot;Supabase 환경변수를 찾을 수 없습니다.&quot;);
}

const supabaseUrl = process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;

const supabase = createClient(supabaseUrl, supabaseAnonKey);

export default supabase;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;getAllProjectData.ts&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1749104974431&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import supabase from &quot;@/lib/supabase&quot;;

export default async function getAllProjectData() {
  const { data, error } = await supabase
    .from(&quot;project_with_stacks&quot;)
    .select(&quot;*&quot;);
  return { data, error };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따로 저러한 명시적인 캐시설정을 해주지 않았는데 말입니다. 제가 모르고 있는 Supabase 자체 캐시 사용 규칙 같은 것이 존재하는 걸까요? 하지만 찾아본 결과 Supabase에서 따로 캐싱 기능을 제공하는 것 같지는 않아 보였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이 서버 데이터 캐시가 존재하는 이유?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마도 빌드 시간 단축이 목적일 가능성이 높아 보입니다. 그리고 데이터를 데이터 소스(백엔드)까지 가서 가져오는 것보다 서버 데이터 캐시(빌드 폴더)에서 가져오는 것이 더 짧은 시간이 걸려서라는 이유도 있는 것 같습니다. 이러한 부분은 대규모 프로젝트에서는 유용할 수 있을 것 있지만 이 캐시 사용이 기본으로 설정되어 있다면 업데이트가 필요할 것 같습니다. 사실 이러니 저러니 해도 결국 로컬 프로덕션 환경과 vercel 배포 환경에서 차이를 보이는 것은 납득이 잘 가지 않는 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Package.json 수정&lt;/b&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;package.json&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1748866395736&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;next dev --turbopack&quot;,
    &quot;build&quot;: &quot;rm -rf .next &amp;amp;&amp;amp; next build&quot;,
    &quot;start&quot;: &quot;next start&quot;,
    &quot;lint&quot;: &quot;next lint&quot;
  },&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;(25.06.20 추가) Next.js 15에서의 캐시 업데이트&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이건 기본적으로 서버 데이터 캐시를 사용하지 않겠다는 게 no-store이 아니라 auto-no-cache라는 옵션을 부여하는 것이라 그런 것 같습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;auto no cache (default): Next.js fetches the resource from the remote server on every request in development, but will fetch once during next build because the route will be statically prerendered. If Dynamic APIs are detected on the route, Next.js will fetch the resource on every request.&lt;/blockquote&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;아마 캐시를 사용하는 기본 옵션(auto-no-cache)이라고 해도 런타임이 아니라 빌드 타임에는 서버 데이터 캐시를 형성하기 때문에 그런 것 같긴 한데... 이 부분은 제가 찾아보고 글을 수정하겠습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;전 Next.js가 모든 걸 너무 마법같이 제공하려고 하지는 않았으면 합니다. 그들 입장에서는 이게 더 큰 편의를 주는 것이라 생각했었기에 이렇게 했을 수도 있지만 그래도.. 그리고 이 4중 캐시 레이어에 대해서는 다시 한번 살펴봐야 할 것 같습니다.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>FE/Next.js</category>
      <category>App router</category>
      <category>caching</category>
      <category>data cache</category>
      <category>full route cache</category>
      <category>next.js</category>
      <author>unhandled</author>
      <guid isPermaLink="true">https://unhandled.tistory.com/46</guid>
      <comments>https://unhandled.tistory.com/entry/Nextjs-App-Router%EC%97%90%EC%84%9C-%EB%B9%8C%EB%93%9C-%EC%A0%84-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8%EB%90%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%B9%8C%EB%93%9C-%ED%9B%84%EC%97%90%EB%8F%84-%EA%B0%80%EC%A0%B8%EC%98%A4%EC%A7%80-%EC%95%8A%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-feat-%EC%84%9C%EB%B2%84-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%BA%90%EC%8B%9C#entry46comment</comments>
      <pubDate>Mon, 2 Jun 2025 22:49:30 +0900</pubDate>
    </item>
  </channel>
</rss>